Нынешняя интернет-экосистема буквально была захвачена API, и на то есть веские причины. Используя сторонние API в своих продуктах или услугах, вы получаете доступ к множеству полезных функций, таких как службы аутентификации и хранения, которые могут принести пользу как вам, так и вашим пользователям. Предоставляя свой собственный API, ваше приложение становится «частью микса» и будет использоваться способами, о которых вы никогда не думали раньше… если вы делаете это правильно, очевидно.
В этой серии из двух частей я покажу вам, как создать слой API RESTful для ваших PHP-приложений, используя коллекцию лучших мировых практик .
Полный исходный код этого проекта будет доступен в конце части 2.
REST: приятный интерфейс для разработчиков
Прежде всего, API — это пользовательский интерфейс для разработчиков , поэтому он должен быть дружественным, простым, простым в использовании и, конечно, приятным; или иначе это будет еще один кусок цифрового мусора.
Документация, даже в форме простого, но хорошо написанного файла README
, является хорошим местом для начала. Минимальная информация, которая нам нужна, — это краткая информация о сфере действия службы, а также список методов и точек доступа.
Хорошее резюме может быть:
Наше приложение представляет собой простой сервис списка контактов, который управляет контактами со связанными заметками. Он имеет два типа объектов , контакты и заметки. Каждый контакт имеет основные атрибуты, такие как имя, фамилия и адрес электронной почты. Кроме того, с каждым контактом может быть связано несколько заметок в формате уценки.
Тогда хорошей идеей будет составить список всех ресурсов и действий, которые мы собираемся реализовать. Это можно рассматривать как эквивалент каркаса для визуальных приложений. Следуя основным принципам REST, каждый ресурс представлен URL-адресом, где действие — это метод HTTP, используемый для доступа к нему.
Например, GET /api/contacts/12
извлекает контакт с id
12, а PUT /api/contacts/12
обновит этот же контакт.
Полный список методов показан ниже:
URL HTTP Method Operation /api/contacts GET Returns an array of contacts /api/contacts/:id GET Returns the contact with id of :id /api/contacts POST Adds a new contact and return it with an id attribute added /api/contacts/:id PUT Updates the contact with id of :id /api/contacts/:id PATCH Partially updates the contact with id of :id /api/contacts/:id DELETE Deletes the contact with id of :id /api/contacts/:id/star PUT Adds to favorites the contact with id of :id /api/contacts/:id/star DELETE Removes from favorites the contact with id of :id /api/contacts/:id/notes GET Returns the notes for the contact with id of :id /api/contacts/:id/notes/:nid GET Returns the note with id of :nid for the contact with id of :id /api/contacts/:id/notes POST Adds a new note for the contact with id of :id /api/contacts/:id/notes/:nid PUT Updates the note with id if :nid for the contact with id of :id /api/contacts/:id/notes/:nid PATCH Partially updates the note with id of :nid for the contact with id of :id /api/contacts/:id/notes/:nid DELETE Deletes the note with id of :nid for the contact with id of :id
Для получения более полной и профессиональной документации вы можете рассмотреть некоторые инструменты, такие как Swagger , apiDoc или служба обнаружения API Google : ваши пользователи будут любить вас!
Инструменты и настройка
Основным инструментом, который я собираюсь использовать для создания нашего API, является Slim Framework . Почему?
[Это] поможет вам быстро написать простые, но мощные веб-приложения и API.
И это правда. Его мощная функция маршрутизации позволяет легко использовать методы, отличные от стандартных GET
и POST
, обеспечивает встроенную поддержку переопределения методов HTTP (через заголовок HTTP и скрытые поля POST), и его можно подключить с помощью промежуточного программного обеспечения и дополнительных функций, чтобы сделать разработка приложений и API действительно проста.
Наряду с Slim я использую Idiorm для доступа к слою базы данных и Monolog для ведения журнала. Итак, наш файл composer.json
будет выглядеть так:
{ "name": "yourname/my-contacts", "description": "Simple RESTful API for contacts management", "license": "MIT", "authors": [ { "name": "Your Name", "email": "[email protected]" } ], "require": { "slim/slim": "*", "slim/extras": "*", "slim/middleware": "*", "monolog/monolog": "*", "j4mie/paris": "*", "flynsarmy/slim-monolog": "*" }, "archive": { "exclude": ["vendor", ".DS_Store", "*.log"] }, "autoload": { "psr-0": { "API": "lib/" } } }
Пакеты slim/extras
и slim/middleware
предоставляют полезные функции, такие как анализ типа контента и базовая аутентификация. Наши пользовательские классы находятся в пространстве имен API
и находятся внутри каталога lib
.
На данный момент наша рабочая структура каталогов будет выглядеть так:
bootstrap . php composer . json README . md bin / import install lib / API / public / . htaccess index . php share / config / default . php db / logs / sql / data / contacts . sql users . sql tables / contacts . sql notes . sql users . sql ssl / mysitename . crt mysitename . key
-bootstrap . php composer . json README . md bin / import install lib / API / public / . htaccess index . php share / config / default . php db / logs / sql / data / contacts . sql users . sql tables / contacts . sql notes . sql users . sql ssl / mysitename . crt mysitename . key
Фронт-контроллер нашего приложения — public/index.php
, где весь трафик, не относящийся к файлам или каталогам, перенаправляется через стандартные правила перезаписи URL.
Затем я поместил весь код инициализации в bootstrap.php
который мы увидим позже. Каталог общего share
содержит такие данные, как журналы, файлы конфигурации, базы данных SQLite и файлы дампа, а также сертификаты SSL. Каталог bin
содержит служебные сценарии, которые создают базу данных и импортируют некоторые данные с использованием предоставленных файлов .sql
.
SSL везде
Наш API будет доступен только в режиме HTTPS, без перенаправления. Это упрощает логику аутентификации и предотвращает доступ плохо настроенных клиентов к незашифрованным конечным точкам. Самый простой и логичный способ настроить это — действовать прямо на веб-сервере или через прокси-сервер.
Я использую старый доверенный Apache для этого, и мой файл виртуального хоста выглядит так:
<Directory "/path/to/MyApp/public"> # Required for mod_rewrite in .htaccess AllowOverride FileInfo Options All -Indexes DirectoryIndex index.php index.shtml index.html <IfModule php5_module> # For Development only! php_flag display_errors On </IfModule> # Enable gzip compression <ifModule filter_module> AddOutputFilterByType DEFLATE application/json </ifModule> Order deny,allow Deny from all Allow from 127.0.0.1 </Directory> <VirtualHost *:80> ServerAdmin [email protected] DocumentRoot "/path/to/MyApp/public" ServerName myapp.dev <IfModule rewrite_module> RewriteEngine on ## Throw a 403 (forbidden) status for non secure requests RewriteCond %{HTTPS} off RewriteRule ^.*$ - [L,R=403] </IfModule> </VirtualHost> <IfModule ssl_module> NameVirtualHost *:443 Listen 443 SSLRandomSeed startup builtin SSLRandomSeed connect builtin <VirtualHost *:443> ServerAdmin [email protected] DocumentRoot "/path/to/MyApp/public" ServerName myapp.dev SSLEngine on SSLCertificateFile /path/to/MyApp/share/ssl/mysitename.crt SSLCertificateKeyFile /path/to/MyApp/share/ssl/mysitename.key SetEnv SLIM_MODE development </VirtualHost> </IfModule>
Настройки каталога определяются в первую очередь, так что они совпадают как с HTTP, так и с HTTPS-версией нашего сайта. В незащищенной конфигурации хоста я использую mod_rewrite
для выдачи ошибки 403 Forbidden
для любого незащищенного соединения, затем в защищенном разделе я устанавливаю SSL, используя свои самозаверяющие сертификаты, вместе с переменной SLIM_ENV
которая сообщает Slim текущий режим приложения.
Для получения дополнительной информации о том, как создать самозаверяющий сертификат и установить его на свой Apache, см. Эту статью о SSLShopper .
Теперь, когда у нас есть четкие цели, базовая структура каталогов и настройка сервера, давайте запустим composer.phar install
и начнем писать некоторый код.
Bootstrap и Фронт-контроллер
Как уже было сказано, файл bootstrap.php
отвечает за загрузку настроек нашего приложения и настроек автозагрузчика.
// Init application mode if (empty($_ENV['SLIM_MODE'])) { $_ENV['SLIM_MODE'] = (getenv('SLIM_MODE')) ? getenv('SLIM_MODE') : 'development'; } // Init and load configuration $config = array(); $configFile = dirname(__FILE__) . '/share/config/' . $_ENV['SLIM_MODE'] . '.php'; if (is_readable($configFile)) { require_once $configFile; } else { require_once dirname(__FILE__) . '/share/config/default.php'; } // Create Application $app = new API\Application($config['app']);
Сначала я получаю текущую среду. Если файл с именем <EnvName>.php
присутствует, он загружается, если не загружен файл конфигурации по умолчанию. Специальные настройки Slim хранятся в массиве $config['app']
и передаются в конструктор нашего приложения, который расширяет базовый объект Slim (необязательно, но рекомендуется).
Например утверждение:
$config['app']['log.writer'] = new \Flynsarmy\SlimMonolog\Log\MonologWriter(array( 'handlers' => array( new \Monolog\Handler\StreamHandler( realpath(__DIR__ . '/../logs') .'/'.$_ENV['SLIM_MODE'] . '_' .date('Ym-d').'.log' ), ), ));
настраивает регистратор Monolog, который записывает в файл в app/path/share/logs/EnvName_YYYY-mm-dd.log
.
Затем, после некоторых улучшений (вы можете увидеть их в исходном коде), я получаю сгенерированный модуль записи журнала и пытаюсь подключиться к базе данных:
// Get log writer $log = $app->getLog(); // Init database try { if (!empty($config['db'])) { \ORM::configure($config['db']['dsn']); if (!empty($config['db']['username']) && !empty($config['db']['password'])) { \ORM::configure('username', $config['db']['username']); \ORM::configure('password', $config['db']['password']); } } } catch (\PDOException $e) { $log->error($e->getMessage()); } // Add middleware // Your middleware here... $app->add(new Some\Middleware\Class(...));
Наконец, я добавляю необходимое промежуточное программное обеспечение к своему экземпляру приложения. Промежуточное программное обеспечение Slim похоже на слои лука, первое промежуточное программное обеспечение, которое вы добавите, будет самым внутренним слоем, поэтому порядок нашего промежуточного программного обеспечения важен. Я использую следующее промежуточное ПО в нашем API:
- Кеш (самый внутренний);
- ContentTypes: анализирует тело в формате JSON у клиента;
- RateLimit: управляет пользовательскими ограничениями API;
- JSON: служебное промежуточное программное обеспечение для «лучших ответов JSON» и «кодированных тел JSON»;
- Аутентификация (внешняя).
Мы напишем все это, за исключением ранее существовавших ContentTypes
.
В конце файла начальной загрузки я определил две глобальные переменные $app
(экземпляр приложения) и $log
(средство записи журнала). Файл загружается нашим фронтальным контроллером index.php
где происходит волшебство.
Структура маршрутизации
Slim имеет замечательную функцию под названием Route Groups . С помощью этой функции мы можем определить маршруты наших приложений следующим образом:
// API group $app->group('/api', function () use ($app, $log) { // Version group $app->group('/v1', function () use ($app, $log) { // GET route $app->get('/contacts/:id', function ($id) use ($app, $log) { }); // PUT route, for updating $app->put('/contacts/:id', function ($id) use ($app, $log) { }); // DELETE route $app->delete('/contacts/:id', function ($id) use ($app, $log) { }); }); });
Я создал две вложенные группы, /api
и /v1
, чтобы мы могли легко следовать рекомендациям «Управление версиями в URL». Я также создал несколько дополнительных маршрутов для /api/
, которые могут содержать читабельное для пользователя содержимое, и общий URL-адрес корневого URL ( /
), который в реальном мире может содержать общедоступный пользовательский интерфейс для приложения.
Промежуточное ПО JSON
Мой первоначальный подход здесь заключался в использовании промежуточного программного обеспечения для маршрутов (другого типа промежуточного программного обеспечения Slim) внутри группы /v1
для аутентификации и запросов / ответов JSON, но я обнаружил, что более практично и чисто использовать классическое промежуточное программное обеспечение. Как было показано ранее, промежуточное программное обеспечение является экземпляром класса, который наследуется от Slim\Middleware
. Метод call()
промежуточного программного обеспечения Slim — это место, где происходит действие, оно выполняется автоматически, когда промежуточное программное обеспечение связывается как глобальное, с помощью $app->add()
.
$app->add(new API\Middleware\JSON('/api/v1'));
Наше промежуточное ПО JSON имеет две передовые практики: «Ответы только JSON» и «Закодированные в JSON тела». Вот как:
// JSON middleware call public function call() { if (preg_match('|^' . $this->root . '.*|', $this->app->request->getResourceUri())) { // Force response headers to JSON $this->app->response->headers->set( 'Content-Type', 'application/json' ); $method = strtolower($this->app->request->getMethod()); $mediaType = $this->app->request->getMediaType(); if (in_array( $method, array('post', 'put', 'patch') )) { if (empty($mediaType) || $mediaType !== 'application/json') { $this->app->halt(415); } } } $this->next->call(); }
Мы можем передать корневой путь нашему конструктору промежуточного программного обеспечения. В этом случае я передаю /api/v1
чтобы наше промежуточное ПО применялось только в части API нашего сайта. Если текущий путь совпадает с заголовком типа содержимого ответа, принудительно application/json
на application/json
, тогда я проверяю метод запроса. Если метод запроса является одним из методов с включенной записью ( PUT
, POST
, PATCH
), заголовок типа содержимого запроса должен быть application/json
, если нет, приложение выходит с кодом состояния HTTP 415 Unsupported Media Type
.
Если все правильно, оператор $this->next->call()
запускает следующее промежуточное ПО в цепочке.
Аутентификация
Поскольку по умолчанию наше приложение будет работать по протоколу HTTPS, я решил использовать подход «токен поверх базовой аутентификации»: ключ API отправляется в поле имени пользователя базовых заголовков HTTP AUTH (пароль не требуется). Для этого я написал класс промежуточного ПО Slim под названием TokenOverBasicAuth
, изменив существующий Slim HttpBasicAuth
. Это промежуточное программное обеспечение запускается первым в цепочке, поэтому оно добавляется как последнее и принимает необязательный параметр корневого пути в конструкторе.
// Auth middleware call public function call() { $req = $this->app->request(); $res = $this->app->response(); if (preg_match('|^' . $this->config['root'] . '.*|', $req->getResourceUri())) { // We just need the user $authToken = $req->headers('PHP_AUTH_USER'); if (!($authToken && $this->verify($authToken))) { $res->status(401); $res->header( 'WWW-Authenticate', sprintf('Basic realm="%s"', $this->config['realm']) ); } } $this->next->call(); }
Метод ищет токен аутентификации в заголовке запроса PHP_AUTH_USER
, если он не существует или недействителен, 401 Forbidden
статус и заголовки аутентификации передаются клиенту. Метод verify()
защищен, поэтому его можно переопределить дочерними классами; моя версия здесь проста:
protected function verify($authToken) { $user = \ORM::forTable('users')->where('apikey', $authToken) ->findOne(); if (false !== $user) { $this->app->user = $user->asArray(); return true; } return false; }
Здесь я просто проверяю наличие ключа API в таблице users
, и если я нахожу действительного пользователя, он добавляется в контекст приложения для использования со следующим уровнем (RateLimit). Вы можете изменить или расширить этот класс, чтобы добавить собственную логику аутентификации или использовать модуль OAuth . Для получения дополнительной информации об OAuth см . Статью Джейми Мунро .
Полезные данные об ошибках
Наш API должен показывать полезные сообщения об ошибках в формате расходных материалов, если это возможно, в формате JSON. Нам нужна минимальная полезная нагрузка, которая содержит код ошибки и сообщение. Кроме того, ошибки проверки требуют большей разбивки. С помощью Slim мы можем переопределить как ошибки 404
ошибки сервера с помощью $app->notFound()
и $app->error()
соответственно.
$app->notFound(function () use ($app) { $mediaType = $app->request->getMediaType(); $isAPI = (bool) preg_match('|^/api/v.*$|', $app->request->getPath()); if ('application/json' === $mediaType || true === $isAPI) { $app->response->headers->set( 'Content-Type', 'application/json' ); echo json_encode( array( 'code' => 404, 'message' => 'Not found' ) ); } else { echo '<html> <head><title>404 Page Not Found</title></head> <body><h1>404 Page Not Found</h1><p>The page you are looking for could not be found.</p></body></html>'; } });
Не найденные ошибки проще: сначала я выбираю медиа-тип запроса, затем флаг $isAPI
сообщает мне, находится ли текущий URL-адрес в группе /api/v*
. Если клиент запросил URL-адрес API или отправил заголовок типа контента JSON, я возвращаю вывод JSON, иначе я могу отобразить шаблон или просто напечатать некоторый статический HTML, как в этом примере.
Другие ошибки немного сложны, метод $app->error()
запускается, когда возникает исключение, и Slim преобразует стандартные ошибки PHP в объектах ErrorException
. Нам нужен способ дать клиенту полезную ошибку, не раскрывая слишком много нашего внутреннего механизма, чтобы избежать недостатков безопасности. Я создал два пользовательских исключения для этого приложения: API\Exception
и API\Exception\ValidationException
, которые доступны для общественности, все другие типы исключений регистрируются и отображаются только в режиме разработки.
$app->error(function (\Exception $e) use ($app, $log) { $mediaType = $app->request->getMediaType(); $isAPI = (bool) preg_match('|^/api/v.*$|', $app->request->getPath()); // Standard exception data $error = array( 'code' => $e->getCode(), 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), ); // Graceful error data for production mode if (!in_array( get_class($e), array('API\\Exception', 'API\\Exception\ValidationException') ) && 'production' === $app->config('mode')) { $error['message'] = 'There was an internal error'; unset($error['file'], $error['line']); } // Custom error data (eg Validations) if (method_exists($e, 'getData')) { $errors = $e->getData(); } if (!empty($errors)) { $error['errors'] = $errors; } $log->error($e->getMessage()); if ('application/json' === $mediaType || true === $isAPI) { $app->response->headers->set( 'Content-Type', 'application/json' ); echo json_encode($error); } else { echo '<html> <head><title>Error</title></head> <body><h1>Error: ' . $error['code'] . '</h1><p>' . $error['message'] .'</p></body></html>'; } });
Метод $app->error()
получает выброшенное исключение в качестве аргумента. По умолчанию я извлекаю все необходимые данные и заполняю массив $error
, затем, если я нахожусь в рабочем режиме, я сбрасываю личные данные и переписываю сообщение универсальным. ValidationException
класс ValidationException
имеет пользовательский метод getData()
который возвращает массив ошибок проверки, которые добавляются в окончательную полезную нагрузку. Затем ошибка отображается в JSON или HTML в зависимости от запроса. На стороне API у нас может быть простая ошибка, подобная этой:
{ "code": 123, "message": "An error occurred, a support person is being notified of this" }
или полная ошибка проверки как это:
{ "code": 0, "message": "Invalid data", "errors": { "contact": [ { "field": "email", "message": "Email address already exists" } ] } }
Вывод
Теперь у нас есть ядро нашего API. В следующей части мы добавим немного мяса для полноценного функционирования сервиса. В то же время, не стесняйтесь читать статьи, ссылки на которые есть в этой части — они являются сокровищницей полезных принципов разработки API.