17 lipca 2019

Rest api w CakePHP 3

Pomyślałem ostatnio, że warto było by zbudować restowe api w cakephp 3. Dlaczego? Wpiszcie sobie w google „Rest api CakePHP 3” a się dowiecie 🙂

Nie ma sensu chyba tłumaczyć na czym polega restowe api, bo to zupełnie inny temat. Po prostu przejdziemy do rzeczy, zaczniemy od małych zmian w czystej instalacji CakePHP 3 (tutaj dowiesz się jak zainstalować framework na którym zaczniemy prace).

Domyślny adres Api w CakePHP

Usuńmy zbędne pliki widoku, zbędne wpisy w „config/routes.php” oraz plik „Controller/FrontpageController.php„.

Poniżej pokażę aktualną zawartość pliku „config/routes.php„:

Router::scope('/', function ($routes) {
    $routes->connect('/', ['controller' => 'Home', 'action' => 'index']);

    $routes->connect('/company', ['controller' => 'Company', 'action' => 'index']);
    
    $routes->connect('/company/:id', ['controller' => 'Company', 'action' => 'view'])
    ->setPatterns(['id' => '\d+'])
    ->setPass(['id']);

    $routes->connect('/company/add/', ['controller' => 'Company', 'action' => 'add']);

    $routes->connect('/company/edit/:id', ['controller' => 'Company', 'action' => 'edit'])
    ->setPatterns(['id' => '\d+'])
    ->setPass(['id']);

    $routes->connect('/company/delete/:id', ['controller' => 'Company', 'action' => 'delete'])
    ->setPatterns(['id' => '\d+'])
    ->setPass(['id']);
});

Zacznijmy od pierwszego wpisu, czyli strony głównej naszego api. Możemy wyświetlić cokolwiek, ja postanowiłem umieścić tekst: „Api version 1.0„. Gdy ktoś wywoła adres naszego api bez dodatkowych parametrów, zostanie przekierowany do kontrolera o nazwie „Home” oraz metody „index„, zobaczmy jaki kod tam się kryje:

class HomeController extends AppController
{

    public function index():void
    {
        $this->response->header('HTTP/1.0 200', 'OK');

        $this->set(['return' => 'Api version 1.0']);

        $this->render('/json');
    }
}

Ustawiamy za pomocą funkcji „header” odpowiedni nagłówek http, do widoku wrzucamy ustalony przez nas string oraz ustawiamy renderowanie strony na plik „json.ctp” który jest w katalogu „Templates„.

INFO:
Gdybyśmy nie wpisali „/” przed nazwą pliku ctp w którym chcemy renderować stronę. CakePHP szukał by odpowiedniego pliku w katalogu o takiej samej nazwie jak nazwa kontrolera. Musieli byśmy powielać ten sam plik wielokrotnie wrzucając go do odpowiednich katalogów.

Plik „json.ctp” wygląda następująco:

<?php
if($return)
    echo json_encode($return);

Po wywołaniu adresu w przeglądarce internetowej, zobaczymy po prostu napis:

"Api version 1.0"

Uprawnienia: Basic Authorization

Jedną z najważniejszych elementów przy budowie naszego api, jest autoryzacja użytkownika. Aby napisać komponent odpowiedzialny za autoryzację, musimy przygotować sobie środowisko testowe.

Polecam testować swoje api przy pomocy jednego z dodatków do przeglądarki:
Firefox: https://addons.mozilla.org/pl/firefox/addon/restclient/
Google Chrome: https://chrome.google.com/webstore/detail/advanced-rest-client/hgmloofddffdnphfgcellkdfbfbjeloo?hl=pl
Opera: https://addons.opera.com/pl/extensions/details/restman/

Możecie użyć również zwyczajnie Curl w PHP:

$ch = curl_init("http://rest.test/company");

curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
curl_setopt($ch, CURLOPT_USERNAME, "admin");
curl_setopt($ch, CURLOPT_PASSWORD, "pass");
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, true);

$output = curl_exec($ch);

echo '<pre>';
	print_r($output);
echo '</pre>';

curl_close($ch);

Powyżej przykładowy skrypt curl który pobierze wszystkie rekordy z tabelki company.

Tworzenie użytkownika w bazie danych

To dobry moment, aby opisać potrzebną nam strukturę bazy danych, bez niej nie dokonamy autoryzacji.

W bazie stwórzmy sobie tabelkę przechowującą informacje o użytkownikach i ich uprawnieniach:

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(45) COLLATE utf8_polish_ci DEFAULT NULL,
  `password` char(128) COLLATE utf8_polish_ci DEFAULT NULL,
  `access` text COLLATE utf8_polish_ci,
  PRIMARY KEY (`id`),
  UNIQUE KEY `id_UNIQUE` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COLLATE=utf8_polish_ci

Ok, teraz dodajmy do tabelki dane:

username„: nazwa użytkownika, może być np „admin
password”: hasło zahashowane metodą sha512„.
access„: json z strukturą klas oraz metod do których ma dostęp użytkownik

Przykładowe zapytanie do bazy dodające nowego użytkownika:

INSERT INTO `company`.`user` (`username`, `password`, `access`) VALUES ('admin', '5b722b307fce6c944905d132691d5e4a2214b7fe92b738920eb3fce3a90420a19511c3010a0e7712b054daef5b57bad59ecbd93b3280f210578f547f4aed4d25', '{\"App\\\\Controller\\\\CompanyController\":[\"index\",\"edit\",\"delete\"]}');

Powyższy użytkownik będzie mieć dostęp do kontrolera „Company” a w nim do metod „index„, „edit” oraz „delete„.

Model dla tabeli „User”

Stworzyłeś już tabelkę oraz dodałeś nowy rekord, teraz możemy przejść do stworzenia modelu, który będzie pobierał dane z bazy:

class UsersTable extends Table
{
    public function initialize(array $config)
    {
        parent::initialize($config);
        
        $this->table('user');
    }

    public function checkAccess(string $login, string $password):?object
    {
        $data = $this->find('all', ['fields' => ['access'], 'conditions' => ['username' => $login, 'password' => hash('sha512', $password)]])->first();

        return $data;
    }
}

Metoda „checkAccess” przyjmuje wartości „username” oraz „password„, jeśli użytkownik istnieje, zwróci nam strukturę zawartą w kolumnie „access” w bazie, w ten sposób dowiemy się jakie uprawnienia ma użytkownik.

Jeśli użytkownik nie istnieje, zwróci null.

Tworzenie kontrolera

Metodę „checkAccess” wykorzystamy oczywiście w kontrolerze, zobaczmy w jaki sposób.

class CompanyController extends AppController
{
    private $access = false;
    private $accessData = null;

    public function initialize()
    {
        parent::initialize();
        $this->loadComponent('RequestHandler');

        $this->loadModel('Users');

        $data = $this->Users->checkAccess(env('PHP_AUTH_USER'), env('PHP_AUTH_PW'));

        if ($data) {
            $this->accessData = json_decode($data->access);
            $this->access = true;
        }
    }
    private function accessCode():bool
    {
        $this->response->header('HTTP/1.0 403', 'Forbidden');

        $this->set(['return' => null]);

        $this->render('/json');

        return true;
    }
    public function index():void
    {
        $this->response->header('HTTP/1.0 200', 'OK');

        $this->set(['return' => 'witaj']);

        $this->render('/json');

        return true;
    }
}

Funkcja „initialize” jest uruchamiana automatycznie przy każdym wywołaniu adresu. Jej zadaniem jest odczytanie „username” oraz „password” użytkownika. Przekazanie danych użytkownika do funkcji w modelu, o którym wspomniałem wcześniej.
Jeśli funkcja modelu zwróci null, zostanie wywołana metoda „accessCode” która wywoła pusty widok i ustawi „Status Code 403” informując o braku dostępu.

Opisana sytuacja będzie miała miejsce, gdy ktoś połączy się z naszym api, mając błędne dane dostępowe („username” lub”password„).

Gdyby dane były prawidłowe. Zmienną klasową „access” zmienimy na „true” oraz do zmiennej „accessData” zapiszemy strukturę dostępową z bazy. Umożliwimy również wywołanie metody wewnątrz kontrolera, może to być dla przykładu metoda o nazwie”index()”.

Powyższy przykład wyświetli nam tekst „witaj” (oczywiście w sytuacji gdy będziemy posiadali prawidłową nazwę użytkownika oraz hasło).

Zmodyfikujmy teraz nasz „index” tak, aby sprawdzał czy użytkownik ma dostęp do wywołanej metody.

public function index():void
    {
        if ($this->access && $this->Access->checkAccess(__CLASS__, __FUNCTION__, $this->accessData)) {
            #pozwalamy wejść
        } else {
            $this->accessCode();
        }
    }

Teraz aby api pozwoliło na operacje wewnątrz metody „index” muszą zostać spełnione określone warunki.
Zmienna „access” musi mieć wartość „true„, domyślnie jest to „false„. Zmieniamy jej wartość na „true” tylko wtedy, gdy użytkownik istnieje.
Metoda „checkAccess” znajdująca się w komponencie „Access” również musi zwrócić „true„.

Komponent Access

Co znajduje się w komponencie ?

class AccessComponent extends Component
{
    public function checkAccess(string $class, string $function, object $data):bool
    {

        if (isset($data->$class) && in_array($function, $data->$class)) {
            return true;
        }

        return false;
    }
}

Prosty kod, pobieramy tylko nazwę klasy, nazwę funkcji do której użytkownik chce się dostać. Pobieramy również strukturę dostępową.

Funkcja „checkAccess” sprawdza czy w strukturze istnieje nazwa klasy a w niej nazwa funkcji do której użytkownik chce się dostać.

INFO:
Wywołując metodę „checkAccess” możemy podać na sztywno nazwę klasy oraz nazwę metody, natomiast z pomocą przychodzą nam stałe magiczne.

Jeśli „checkAccess” zwróci prawdę, wykonamy dalszą część kodu, w przeciwnym wypadku wywołamy metodę „accessCode” a tam zwracamy kod 403 i puste body.

Na koniec opisu części związanej z dostępami, nie zapomnij załadować komponentu „Access„:

$this->loadComponent('Access');

Tabela company

Potrzebować będziemy na tym etapie tabelkę „company”, wykonamy ją poniższym zapytaniem:

CREATE TABLE `company` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(45) COLLATE utf8_polish_ci DEFAULT NULL,
  `city` varchar(45) COLLATE utf8_polish_ci DEFAULT NULL,
  `address` varchar(150) COLLATE utf8_polish_ci DEFAULT NULL,
  `website` varchar(150) COLLATE utf8_polish_ci DEFAULT NULL,
  `created` varchar(45) COLLATE utf8_polish_ci DEFAULT NULL,
  `longitude` float DEFAULT NULL,
  `latitude` float DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `id_UNIQUE` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=809 DEFAULT CHARSET=utf8 COLLATE=utf8_polish_ci

Funkcja index()

Wróćmy w tej chwili do routów:

$routes->connect('/company', ['controller' => 'Company', 'action' => 'index']);

Wywołanie adresu: „domena/company„.
W przypadku istnienia rekordów: Status Code: 200 OK
W przypadku braku rekordów: Status Code: 404 Not Found

W przypadku istnienia rekordów otrzymamy json z danymi.
W przypadku braku rekordów otrzymamy puste body.

W przypadku braku dostępu otrzymamy puste body oraz Status Code: 403 Forbidden.

Funkcja index() prezentuje się następująco:

public function index():void
    {
        if ($this->access && $this->Access->checkAccess(__CLASS__, __FUNCTION__, $this->accessData)) {
            $data = $this->Companys->getAll();

            $this->response->header('HTTP/1.0 404', 'Not Found');

            if ($data) {
                $this->response->header('HTTP/1.0 200', 'OK');
            }

            $this->set(['return' => $data]);

            $this->render('/json');
        } else {
            $this->accessCode();
        }
    }

Czyli na początku standardowo jak przy każdej funkcji, sprawdzamy czy użytkownik ma do niej dostęp.
$this->access” jest prawdą w przypadku gdy użytkownik istnieje.
$this->Access->checkAccess” sprawdza czy użytkownik ma dostęp do funkcji której nazwę oraz klasę podamy jako argument przy wywołaniu.

Pobieramy następnie wszystkie rekordy z bazy oraz ustawiamy domyślną wartość „Status Code” na „404„. Gdy zapytanie do bazy zwróci nam dane (rekordy w bazie istnieją), zmienimy „Status Code” na „200

Przekazujemy dane do widoku i renderujemy w pliku „json.ctp„.

Funkcja view()

Sprawdźmy wpis w routerze który odnosi się do metody view():

$routes->connect('/company/:id', ['controller' => 'Company', 'action' => 'view'])
    ->setPatterns(['id' => '\d+'])
    ->setPass(['id']);

Tym razem przy wywołaniu view() musimy podać argument, a będzie nim numer id firmy (int).

Wywołanie adresu: „domena/company/{id}”.
W przypadku istnienia rekordu Status Code: 200 OK
W przypadku braku rekordu Status Code: 404 Not Found

W przypadku istnienia rekordu otrzymamy json z danymi.
W przypadku braku rekordu otrzymamy puste body.

W przypadku braku dostępu otrzymamy puste body oraz Status Code: 403 Forbidden.

Funkcja view() prezentuje się następująco:

public function view(int $id):void
    {
        if ($this->access && $this->Access->checkAccess(__CLASS__, __FUNCTION__, $this->accessData)) {
            $data = $this->Companys->getRow($id);
            
            $this->response->header('HTTP/1.0 404', 'Not Found');
            if ($data) {
                $this->response->header('HTTP/1.0 200', 'OK');
            }
            $this->set(['return' => $data]);
            $this->render('/json');
        } else {
            $this->accessCode();
        }
    }

Nie warto tutaj wyjaśniać zasady działania funkcji, ponieważ jest niemalże identyczna jak funkcja index(), różnice wynikają jedynie z faktu, że pobierając dane, przekazujemy id interesującego nas wpisu. Pobieramy zatem, w przeciwieństwie do funkcji index(), jeden rekord zamiast całej listy.

Funkcja delete()

Wpis w routerze wygląda bardzo podobnie do „view„:

    $routes->connect('/company/delete/:id', ['controller' => 'Company', 'action' => 'delete'])
    ->setPatterns(['id' => '\d+'])
    ->setPass(['id']);

Podobieństwo jest widoczne, ponieważ znów musimy przekazać w adresie numer id rekordu, tak samo jak do funkcji „view„.

Funkcja „delete” odczyta numer id przekazany w adresie w celu odnalezienia go w bazie i ostatecznie usunięcie.

Wywołanie adresu: „domena/company/delete/{id}”.

W przypadku zapytania innego typu niż „delete„, api zwróci Status Code: 400 Bad Request
W przypadku istnienia rekordu: Status Code: 200 OK
W przypadku braku rekordu: Status Code: 404 Not Found

W każdym przypadku funkcja delete zwróci puste body.

W przypadku braku dostępu otrzymamy Status Code: 403 Forbidden.

Kod funkcji wygląda następująco:

    public function delete(int $id):void
    {
        if ($this->access && $this->Access->checkAccess(__CLASS__, __FUNCTION__, $this->accessData)) {
            $this->response->header('HTTP/1.0 400', 'Bad Request');
            if ($this->request->is('delete')) {
                $entity = $this->Companys->getRow($id);
                try {
                    $this->Companys->delete($entity);
                    $this->response->header('HTTP/1.0 200', 'OK');
                } catch (\Throwable $th) {
                    $this->response->header('HTTP/1.0 404', 'Not Found');
                }
                $this->set(['return' => null]);
                $this->render('/json');
            }
        } else {
            $this->accessCode();
        }
    }

Jeśli mamy dostęp do funkcji, ustawimy od razu „Status Code: 400 Bad Request„. Po spełnieniu następnych warunków zmienimy go.

Jeśli rekord istnieje, usuwamy go oraz zwracamy „Status Code: 200 OK„, w przeciwnym wypadku będzie to „Status Code: 404 Not Found„, ponieważ rekord który staramy się usunąć nie istnieje.

Funkcja add()

W routerze wpis odpowiadający za wywołanie funkcji „add”, wygląda następująco:

$routes->connect('/company/add/', ['controller' => 'Company', 'action' => 'add']);

Wywołanie funkcji odbywa się poprzez wysłanie requestu na adres: „domena/company/add”

W przypadku zapytania innego typu niż „POST„, api zwróci Status Code: 400 Bad Request
W przypadku poprawnego dodania rekordu: ” Status Code: 201 Created
W przypadku niepowodzenia: „Status Code: 500 Internal Server Error

W przypadku wywołania adresu bez danych „POST” otrzymamy puste body.
W przypadku poprawnego stworzenia rekordu, otrzymamy w body json z nowo powstałym rekordem.
W przypadku niepowodzenia, otrzymamy w body json z treścią błędu.

W przypadku braku dostępu otrzymamy Status Code: 403 Forbidden.

Funkcja add() prezentuje się następująco:

    public function add():void
    {
        if ($this->access && $this->Access->checkAccess(__CLASS__, __FUNCTION__, $this->accessData)) {
            $return = null;
            $this->response->header('HTTP/1.0 400', 'Bad Request');
            if ($this->request->is('post')) {
                $data = [
                    'name' => $this->request->getData('name'),
                    'address' => $this->request->getData('address'),
                    'city' => $this->request->getData('city'),
                    'website' => $this->request->getData('website'),
                    'longitude' => $this->request->getData('longitude'),
                    'latitude' => $this->request->getData('latitude'),
                    'created' => null
                ];
                    
                $recipe = $this->Companys->newEntity($data);
                if (empty($recipe->errors())) {
                    if ($this->Companys->save($recipe)) {
                        $this->response->header('HTTP/1.0 201', 'Created');
                        $return = json_encode($recipe);
                    } else {
                        $this->response->header('HTTP/1.0 500', 'Internal Server Error');
                        $return = json_encode('error');
                    }
                } else {
                    $this->response->header('HTTP/1.0 400', 'Bad Request');
                    $return = json_encode($recipe->errors());
                }
            }
            $this->set(['return' => $return]);
            $this->render('/json');
        } else {
            $this->accessCode();
        }
    }

Po sprawdzeniu, czy użytkownik ma niezbędny dostęp, ustawiamy „Status Code: 400 Bad Request„.
W zależności od spełnienia dalszych warunków „Status Code” będzie zmieniany.

Sprawdzamy czy użytkownik wysłał „POST„, jeśli nie, zwracamy puste body oraz wcześniej ustawiony kod odpowiedzi.

Gdy dane „POST” są wysłane, tworzymy nową tablicę „$data” i przekazujemy ją do newEntity„. Jeśli metoda „newEntity” zwróci błąd, wyświetlimy jego treść w body oraz ustawimy „Status Code: 500 Internal Server Error„, w przeciwnym przypadku dodamy nowy rekord do bazy i zwrócimy jego dane oraz „Status Code” wyniesie wartość „201 OK„.

Błąd zwrócony przez metodę „newEntity” jest zależny również od walidacji którą zapisaliśmy w pliku: „src/Model/Table/CompanysTable.php„:

public function validationDefault(Validator $validator)
    {
        $validator
        ->requirePresence('name', ['create', 'update'])
        ->notEmpty('name', 'Please fill this field')
        ->add('website', [
            'valid' => ['rule' => 'url', 'message' => 'Wrong url format'],
            'maxLength' => ['rule' => ['maxLength', 150], 'message' => 'The maximum number of characters is 150']
        ])
        ->add('city', [
            'valid' => ['rule' => 'alphanumeric', 'message' => 'Wrong alphanumeric format'],
            'maxLength' => ['rule' => ['maxLength', 45], 'message' => 'The maximum number of characters is 150']
        ])
        ->add('address', [
            'valid' => ['rule' => 'alphanumeric', 'message' => 'Wrong alphanumeric format'],
            'maxLength' => ['rule' => ['maxLength', 150], 'message' => 'The maximum number of characters is 150']
        ])
        ->add('latitude', 'valid', ['rule' => 'numeric', 'message' => 'Wrong numeric format'])
        ->add('longitude', 'valid', ['rule' => 'numeric', 'message' => 'Wrong numeric format'])
        ->add('name', [
            'minLength' => ['rule' => ['minLength', 5], 'last' => true, 'message' => 'The minimum number of characters is 5'],
            'maxLength' => ['rule' => ['maxLength', 44], 'message' => 'The maximum number of characters is 44']
        ]);
        return $validator;
    }

Metody służące do walidacji danych, to temat na osobny wpis.

W bazie, posiadamy jeszcze kolumnę „created” która też jest uzupełniona o datę utworzenia wpisu. Daty zapisanej w kolumnie „created” przekazujemy w tablicy „POST„. Wartość jaką przyjmie „created” jest ustawiona w pliku „src/Model/Entity/Company.php„, w metodzie „_setCreated()„.

    protected function _setCreated():string
    {
        return date('Y-m-d');
    }

Funkcja edit()

To ostatnia omawiana funkcja w naszym restowym api.

W routach wpis odpowiadający za wywołanie funkcji edit wygląda następująco:

$routes->connect('/company/edit/:id', ['controller' => 'Company', 'action' => 'edit'])
    ->setPatterns(['id' => '\d+'])
    ->setPass(['id']);

Adres z którym musimy się połączyć w celu edycji wpisu to:

/company/edit/{id}

W przypadku zapytania innego typu niż „PUT„, api zwróci Status Code: 400 Bad Request
W przypadku poprawnej edycji rekordu: ” Status Code: 200 OK
W przypadku niepowodzenia: „Status Code: 500 Internal Server Error

W przypadku wywołania adresu bez danych „PUT” otrzymamy puste body.
W przypadku poprawnej edycji rekordu, otrzymamy w body json z danymi edytowanego rekordu.
W przypadku niepowodzenia, otrzymamy w body json z treścią błędu.

W przypadku braku dostępu otrzymamy Status Code: 403 Forbidden.

Funkcja edit() prezentuje się następująco:

public function edit(int $id):void
    {
        if ($this->access && $this->Access->checkAccess(__CLASS__, __FUNCTION__, $this->accessData)) {
            $return = null;

            $this->response->header('HTTP/1.0 400', 'Bad Request');

            if ($this->request->is('put')) {
                $data = $this->Companys->getRow($id);
                try {
                    $recipe = $this->Companys->patchEntity($data, $this->request->getData());

                    if (empty($recipe->errors())) {
                        if ($this->Companys->save($recipe)) {
                            $this->response->header('HTTP/1.0 200', 'OK');
                            $return = json_encode($recipe);
                        } else {
                            $this->response->header('HTTP/1.0 500', 'Internal Server Error');
                            $return = json_encode('error');
                        }
                    } else {
                        $this->response->header('HTTP/1.0 400', 'Bad Request');
                        $return = json_encode($recipe->errors());
                    }
                } catch (\Throwable $th) {
                    $this->response->header('HTTP/1.0 500', 'Internal Server Error');
                    $return = json_encode('error');
                }
            }

            $this->set(['return' => $return]);
            $this->render('/json');

        } else {
            $this->accessCode();
        }
    }

Funkcja wygląda bardzo podobnie do add(), jednak tutaj ważnymi różnicami jest zmiana „POST” na „PUT” oraz skorzystaliśmy w tym przypadku z funkcji „patchEntity” zamiast „newEntity„.

Podsumowanie

Zachęcam do przetestowania powyższych metod, a jeszcze bardziej do napisania własnych, być może lepszych od powyższych rozwiązań.

Proszę nie traktować tego jako kurs lub jako poradnik. Jest to krótkie objaśnienie sposobu w jaki rozwiązałem potrzebę stworzenia na swoje własne potrzeby restowego api.

Gotowy, działający kod można pobrać z platformy GitHub

Download