Пишем свой API для сайта с использованием Apache, PHP и MySQL

С чего все началось



Разрабатывая проект, я столкнулся с необходимостью организации клиент-серверного взаимодействия приложений на платформах iOS и Android с моим сайтом на котором хранилась вся информация — собственно БД на mysql, картинки, файлы и другой контент.
Задачи которые нужно было решать — достаточно простые:
регистрация/авторизация пользователя;
отправка/получение неких данных (например список товаров).

И тут-то мне захотелось написать свой API для взаимодействия с серверной стороной — большей своей частью для практического интереса.

Входные данные



В своем распоряжении я имел:
Сервер — Apache, PHP 5.0, MySQL 5.0
Клиент — Android, iOS устройства, любой браузер

Я решил, что для запросов к серверу и ответов от него буду использовать JSON формат данных — за его простоту и нативную поддержку в PHP и Android. Здесь меня огорчила iOS — у нее нет нативной поддержки JSON (тут пришлось использовать стороннюю разработку).


Так же было принято решение, что запросы можно будет отсылать как через GET так и через POST запросы (здесь помог $_REQUEST в PHP). Такое решение позволило проводить тестирование API через GET запросы в любом доступном браузере.

Внешний вид запросов решено было сделать таким:
http://[адрес сервера]/[путь к папке api]/?[название_api].[название_метода]=[JSON вида {«Hello»:«Hello world»}]

Путь к папке api — каталог на который нужно делать запросы, в корне которого лежит файл index.php — он и отвечает за вызов функций и обработку ошибок
Название api — для удобства я решил разделить API группы — пользователь, база данных, конент и тд. В таком случае каждый api получил свое название
Название метода — имя метода который нужно вызвать в указанном api
JSON — строковое представление JSON объекта для параметров метода

Скелет API



Скелет API на серверной стороне состоит из нескольких базовых классов:
index.php — индексный файл каталога в Apache на него приходятся все вызовы API, он осуществляет парсинг параметров и вызов API методов
MySQLiWorker — класс-одиночка для работы с базой MySQL через MySQLi
apiBaseCalss.php — родительский класс для всех API в системе — каждый API должен быть наследован от этого класса для корректной работы
apiEngine.php — основной класс системы — осуществляет разбор переданных параметров (после их предварительного парсинга в index.php) подключение нужного класса api (через require_once метод), вызов в нем нужного метода и возврат результата в JSON формате
apiConstants.php — класс с константами для api вызовов и передачи ошибок
apitest.php — тестовый api для тестирования новых методов перед их включением в продакшн версию

Весь механизм выглядит следующим образом:
Мы делаем запрос на сервер — к примеру www.example.com/api/?apitest.helloWorld={}
На серверной стороне файл index.php — производит парсинг переданных параметров. Index.php берет всегда только первый элемент из списка переданных параметров $_REQUEST — это значит что конструкция вида www.example.com/api/?apitest.helloWorld={}&apitest.helloWorld2 — произведет вызов только метода helloWorld в apitest. Вызова же метода helloWorld2 непроизойдет

Теперь подробней о каждом



Я попробовал достаточно документировать файлы, чтобы не занимать много место под текст. Однако в тех файлах где нет комментариев, я все ж приведу описание.

Index.php


Как уже говорил раньше это входной индексный файл для Apache а значит все вызовы вида www.example.com/api будет принимать он.

<?php
header('Content-type: text/html; charset=UTF-8');
if (count($_REQUEST)>0){
    require_once 'apiEngine.php';
    foreach ($_REQUEST as $apiFunctionName => $apiFunctionParams) {
        $APIEngine=new APIEngine($apiFunctionName,$apiFunctionParams);
        echo $APIEngine->callApiFunction(); 
        break;
    }
}else{
    $jsonError->error='No function called';
    echo json_encode($jsonError);
}
?>


Первым делом устанавливаем тип контента — text/html (потом можно сменить в самих методах) и кодировку — UTF-8.
Дальше проверяем, что у нас что-то запрашивают. Если нет то выводим JSON c ошибкой.
Если есть параметры запроса, то подключаем файл движка API — apiEngine.php и создаем класс движка с переданными параметрами и делаем вызов api метода.
Выходим из цикла так как мы решили что будем обрабатывать только один вызов.

apiEngine.php


Вторым по важности является класс apiEngine — он представляет собой движок для вызова api и их методов.
<?php
require_once('MySQLiWorker.php');
require_once ('apiConstants.php');

class APIEngine {

    private $apiFunctionName;
    private $apiFunctionParams;

    //Статичная функция для подключения API из других API при необходимости в методах
    static function getApiEngineByName($apiName) {
        require_once 'apiBaseClass.php';
        require_once $apiName . '.php';
        $apiClass = new $apiName();
        return $apiClass;
    }
    
    //Конструктор
    //$apiFunctionName - название API и вызываемого метода в формате apitest_helloWorld
    //$apiFunctionParams - JSON параметры метода в строковом представлении
    function __construct($apiFunctionName, $apiFunctionParams) {
        $this->apiFunctionParams = stripcslashes($apiFunctionParams);
        //Парсим на массив из двух элементов [0] - название API, [1] - название метода в API
        $this->apiFunctionName = explode('_', $apiFunctionName);
    }

    //Создаем JSON ответа
    function createDefaultJson() {
        $retObject = json_decode('{}');
        $response = APIConstants::$RESPONSE;
        $retObject->$response = json_decode('{}');
        return $retObject;
    }
    
    //Вызов функции по переданным параметрам в конструкторе
    function callApiFunction() {
        $resultFunctionCall = $this->createDefaultJson();//Создаем JSON  ответа
        $apiName = strtolower($this->apiFunctionName[0]);//название API проиводим к нижнему регистру
        if (file_exists($apiName . '.php')) {
            $apiClass = APIEngine::getApiEngineByName($apiName);//Получаем объект API
            $apiReflection = new ReflectionClass($apiName);//Через рефлексию получем информацию о классе объекта
            try {
                $functionName = $this->apiFunctionName[1];//Название метода для вызова
                $apiReflection->getMethod($functionName);//Провераем наличие метода
                $response = APIConstants::$RESPONSE;
                $jsonParams = json_decode($this->apiFunctionParams);//Декодируем параметры запроса в JSON объект
                if ($jsonParams) {
                    if (isset($jsonParams->responseBinary)){//Для возможности возврата не JSON, а бинарных данных таких как zip, png и др. контетнта 
                        return $apiClass->$functionName($jsonParams);//Вызываем метод в API
                    }else{
                        $resultFunctionCall->$response = $apiClass->$functionName($jsonParams);//Вызыаем метод в API который вернет JSON обект
                    }
                } else {
                    //Если ошибка декодирования JSON параметров запроса
                    $resultFunctionCall->errno = APIConstants::$ERROR_ENGINE_PARAMS;
                    $resultFunctionCall->error = 'Error given params';
                }
            } catch (Exception $ex) {
                //Непредвиденное исключение
                $resultFunctionCall->error = $ex->getMessage();
            }
        } else {
            //Если запрашиваемый API не найден
            $resultFunctionCall->errno = APIConstants::$ERROR_ENGINE_PARAMS;
            $resultFunctionCall->error = 'File not found';
            $resultFunctionCall->REQUEST = $_REQUEST;
        }
        return json_encode($resultFunctionCall);
    }
}

?>


apiConstants.php


Данный класс используется только для хранения констант.

<?php
class APIConstants {

    //Результат запроса - параметр в JSON ответе
    public static $RESULT_CODE="resultCode";
    
    //Ответ - используется как параметр в главном JSON ответе в apiEngine
    public static $RESPONSE="response";
    
    //Нет ошибок
    public static $ERROR_NO_ERRORS = 0;
    
    //Ошибка в переданных параметрах
    public static $ERROR_PARAMS = 1;
    
    //Ошибка в подготовке SQL запроса к базе
    public static $ERROR_STMP = 2;

    //Ошибка запись не найдена
    public static $ERROR_RECORD_NOT_FOUND = 3;
    
    //Ошибка в параметрах запроса к серверу. Не путать с ошибкой переданных параметров в метод
    public static $ERROR_ENGINE_PARAMS = 100;
    
    //Ошибка zip архива
    public static $ERROR_ENSO_ZIP_ARCHIVE = 1001;
    
}
?>


MySQLiWorker.php


Класс-одиночка для работы с базой. В прочем это обычный одиночка — таких примеров в сети очень много.

<?php
class MySQLiWorker {

    protected static $instance;  // object instance
    public $dbName;
    public $dbHost;
    public $dbUser;
    public $dbPassword;
    public $connectLink = null;

    //Чтобы нельзя было создать через вызов new MySQLiWorker
    private function __construct() { /* ... */
    }

    //Чтобы нельзя было создать через клонирование
    private function __clone() { /* ... */
    }

    //Чтобы нельзя было создать через unserialize
    private function __wakeup() { /* ... */
    }

    //Получаем объект синглтона
    public static function getInstance($dbName, $dbHost, $dbUser, $dbPassword) {
        if (is_null(self::$instance)) {
            self::$instance = new MySQLiWorker();
            self::$instance->dbName = $dbName;
            self::$instance->dbHost = $dbHost;
            self::$instance->dbUser = $dbUser;
            self::$instance->dbPassword = $dbPassword;
            self::$instance->openConnection();
        }
        return self::$instance;
    }

    //Определяем типы параметров запроса к базе и возвращаем строку для привязки через ->bind
    function prepareParams($params) {
        $retSTMTString = '';
        foreach ($params as $value) {
            if (is_int($value) || is_double($value)) {
                $retSTMTString.='d';
            }
            if (is_string($value)) {
                $retSTMTString.='s';
            }
        }
        return $retSTMTString;
    }

    //Соединяемся с базой
    public function openConnection() {
        if (is_null($this->connectLink)) {
            $this->connectLink = new mysqli($this->dbHost, $this->dbUser, $this->dbPassword, $this->dbName);
            $this->connectLink->query("SET NAMES utf8");
            if (mysqli_connect_errno()) {
                printf("Подключение невозможно: %s\n", mysqli_connect_error());
                $this->connectLink = null;
            } else {
                mysqli_report(MYSQLI_REPORT_ERROR);
            }
        }
        return $this->connectLink;
    }

    //Закрываем соединение с базой
    public function closeConnection() {
        if (!is_null($this->connectLink)) {
            $this->connectLink->close();
        }
    }

    //Преобразуем ответ в ассоциативный массив
    public function stmt_bind_assoc(&$stmt, &$out) {
        $data = mysqli_stmt_result_metadata($stmt);
        $fields = array();
        $out = array();
        $fields[0] = $stmt;
        $count = 1;
        $currentTable = '';
        while ($field = mysqli_fetch_field($data)) {
            if (strlen($currentTable) == 0) {
                $currentTable = $field->table;
            }
            $fields[$count] = &$out[$field->name];
            $count++;
        }
        call_user_func_array('mysqli_stmt_bind_result', $fields);
    }

}

?>


apiBaseClass.php


Ну вот мы подошли к одному из самых важных классов системы — базовый класс для всех API в системе.

<?php
class apiBaseClass {
    
    public $mySQLWorker=null;//Одиночка для работы с базой
    
    //Конструктор с возможными параметрами
    function __construct($dbName=null,$dbHost=null,$dbUser=null,$dbPassword=null) {
        if (isset($dbName)){//Если имя базы передано то будет установленно соединение с базой
            $this->mySQLWorker = MySQLiWorker::getInstance($dbName,$dbHost,$dbUser,$dbPassword);
        }
    }
    
    function __destruct() {
        if (isset($this->mySQLWorker)){             //Если было установленно соединение с базой, 
            $this->mySQLWorker->closeConnection();  //то закрываем его когда наш класс больше не нужен
        }
    }
    
    //Создаем дефолтный JSON для ответов
    function createDefaultJson() {
        $retObject = json_decode('{}');
        return $retObject;
    }
    
    //Заполняем JSON объект по ответу из MySQLiWorker
    function fillJSON(&$jsonObject, &$stmt, &$mySQLWorker) {
        $row = array();
        $mySQLWorker->stmt_bind_assoc($stmt, $row);
        while ($stmt->fetch()) {
            foreach ($row as $key => $value) {
                $key = strtolower($key);
                $jsonObject->$key = $value;
            }
            break;
        }
        return $jsonObject;
    }
}

?>


Как видно данный класс содержит в себе несколько «утилитных» методов, таких как:
конструктор в котором осуществляется соединение с базой, если текущее API собирается работать с базой;
деструктор — следит за освобождением ресурсов — разрыв установленного соединения с базой
createDefaultJson — создает дефолтный JSON для ответа метода
fillJSON — если подразумевается что запрос вернет только одну запись, то данный метод заполнит JSON для ответа данными из первой строки ответа от БД

Создадим свой API



Вот собственно и весь костяк этого API. Теперь рассмотрим как же это все использовать на примере создания первого API под названием apitest. И напишем в нем пару простых функций:
одну без параметров
одну с параметрами и их же она нам и вернет, чтобы было видно, что она их прочитала
одну которая вернет нам бинарные данные

И так создаем класс apitest.php следующего содержания

<?php

class apitest extends apiBaseClass {

    //http://www.example.com/api/?apitest.helloAPI={}
    function helloAPI() {
        $retJSON = $this->createDefaultJson();
        $retJSON->withoutParams = 'It\'s method called without parameters';
        return $retJSON;
    }

    //http://www.example.com/api/?apitest.helloAPIWithParams={"TestParamOne":"Text of first parameter"}
    function helloAPIWithParams($apiMethodParams) {
        $retJSON = $this->createDefaultJson();
        if (isset($apiMethodParams->TestParamOne)){
            //Все ок параметры верные, их и вернем
            $retJSON->retParameter=$apiMethodParams->TestParamOne;
        }else{
            $retJSON->errorno=  APIConstants::$ERROR_PARAMS;
        }
        return $retJSON;
    }
    
    //http://www.example.com/api/?apitest.helloAPIResponseBinary={"responseBinary":1}
    function helloAPIResponseBinary($apiMethodParams){
        header('Content-type: image/png');
        echo file_get_contents("http://habrahabr.ru/i/error-404-monster.jpg");
    }

}

?>


Для удобства тестирования методов, я дописываю к ним адрес по которому я могу сделать быстрый запрос для тестирования.

И так у нас три метода
helloAPI


function helloAPI() {
        $retJSON = $this->createDefaultJson();
        $retJSON->withoutParams = 'It\'s method called without parameters';
        return $retJSON;
    }


Это простой метод без параметров. Его адрес для GET вызова www.example.com/api/?apitest.helloAPI={}

Результатом выполнения будет вот такая страница (в браузере)

helloAPI

helloAPIWithParams

Этот метод принимает в параметры. Обязательным является TestParamOne, для него и сделаем проверку. Его его не передать, то будет выдан JSON с ошибкой

function helloAPIWithParams($apiMethodParams) {
        $retJSON = $this->createDefaultJson();
        if (isset($apiMethodParams->TestParamOne)){
            //Все ок параметры верные, их и вернем
            $retJSON->retParameter=$apiMethodParams->TestParamOne;
        }else{
            $retJSON->errorno=  APIConstants::$ERROR_PARAMS;
        }
        return $retJSON;
    }

Результат выполнения

helloAPIWithParams

helloAPIResponseBinary


И последний метод helloAPIResponseBinary — вернет бинарные данные — картинку хабра о несуществующей странице (в качестве примера)
function helloAPIResponseBinary($apiMethodParams){
        header('Content-type: image/jpeg');
        echo file_get_contents("http://habrahabr.ru/i/error-404-monster.jpg");
    }

Как видно — здесь есть подмена заголовка для вывода графического контента.
Результат будет такой

helloAPIResponseBinary

Есть над чем работать



Для дальнейшего развития необходимо сделать авторизация пользователей, чтобы ввести разграничение прав на вызов запросов — какие-то оставить свободными, а какие-то только при авторизации пользователя.

Ссылки



Для тестирования выложил все файлы на github — simpleAPI

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 25

    –7
    Не знаю, лично мне было интересно. Никогда раньше не задумывался, как пишутся API
      +2
      Если я не ошибаюсь, то у вас в реализации существует серьезная уязвимость. При проверке существует ли метод в теле вы не проверяете входные переменные.

      Например, при попытке вызова такого URL-а, для существующего контроллера:
      www.example.com/api/?apitest.getApiEngineByName=test будет заинклуден файл test.php.
      А если еще включена настройка allow_url_fopen, то можно будет вообще загрузить и подключить какой-нибудь шелл.
        –2
        Я с вами согласен здесь совершенно отсутствует реализация защиты. Собственно это планирую внедрить в следующей доработке.
        Однако на ваш пример отвечаю: в ответ на запрос
        www.example.com/api/?apitest.getApiEngineByName=test
        Сервер вернет вот такой ответ:
        {«response»:{},«error»:«Method getApiEngineByName does not exist»}
        +4
        PDO? не, не слышал )
          +5
          В конце был удивлен этим: echo file_get_contents(«habrahabr.ru/i/error-404-monster.jpg»);. Ну и конечно «класс для констант» это нечно :D
            +2
            «класс для констант» это нечно :D
            Которые, внезапно, не константы, а статические переменные O_o
            –1
            Если вы про это ru.wikipedia.org/wiki/PHP_Data_Objects, то слышал, слышал.

            Но наверное не очень понял к чему это?

            HP Data Objects (PDO) — расширение для PHP, предоставляющее разработчику простой и универсальный интерфейс для доступа к различным базам данных.

            Я не ставил целью написать API для работы с БД. Например картинка в последнем методе не делает запросов к базе.
              0
              >>MySQLiWorker.php Класс-одиночка для работы с базой. В прочем это обычный одиночка — таких примеров в сети очень много.
              O_o? Вы серьезно собираетесь продолжать это «API»?
                –5
                Да. Для моих целей его вполне достаточно (возможно и кому-нибудь еще пригодится).
              +1
              Хотя вот все же не пойму, вот кусок из PDO Manual
              $sql = 'SELECT name, color, calories FROM fruit ORDER BY name';
              foreach ($conn->query($sql) as $row) {
              print $row['name']. "\t";
              print $row['color']. "\t";
              print $row['calories']. "\n";
              }

              так в чем же разница от MySQLi?

              MySQLi делает тоже самое.
              — все равно писать свои запросы
              — все равно делать выборки и обрабатывать их
              — все равно есть Statement
              –8
              поддерживаю, было полезненько.
                –5
                Спасибо.
                Дальше планирую написать как осуществлять взаимодействие с этим API с клиентских устройств. В таком порядке: iOS, Android, WP7.
              • UFO just landed and posted this here
                  0
                  Спасибо за ссылку кстати)
                    –2
                    Спасибо за ссылку.

                    Такой стиль был навеян системой IP.Board.
                    –1
                    А какова максимально возможная длина URL? Если надо будет передавать бинарные данные на сервер, то их придётся конвертировать в base64, а влезет ли результат в URL?
                      +2
                      ИМХО не хорошо, что такая жесткая привязка к структуре файловой системы. Лучше было бы реализовать Front Controller. А использование mod_rewrite позволило бы сделать url более красивым ;)

                      Так же было принято решение, что запросы можно будет отсылать как через GET так и через POST запросы (здесь помог $_REQUEST в PHP). Такое решение позволило проводить тестирование API через GET запросы в любом доступном браузере.


                      Абсолютно непонятная мне мотивация, т.к. для тестирования запросов есть curl. А при разных типах запросов можно было бы выполнять различные действия, что логично.
                        +1
                        В PHP5 есть классовые константы: www.php.net/manual/en/language.oop5.constants.php

                        Если их использовать, то следующий класс будет выглядеть читабельнее, и «константы» будут действительно неизменяемыми.
                        class APIConstants {
                        
                            //Результат запроса - параметр в JSON ответе
                            const RESPONSE = "response";
                        
                        // ... И обращаться дальше, в apiEngine.php, можно следующим образом:
                        
                           $response = APIConstants::RESPONSE;
                        
                        


                        Постараюсь в свободное время pull request сделать.
                          +4
                          Ещё очень важный момент: в запрос желательно включать версию API (и поддерживать несколько одновременно, естественно)
                            0
                            По большему счету, API с выводом в JSON не отличается от обычного HTML вывода, а посему можно использовать любой фреймворк, только вместо сессий использовать базу данных и передаваемый токен для аутентификации и авторизации.
                              +8
                              *facepalm.jpg* — не зря нас (похапэшников) троллят…
                                0
                                Кстати, с iOS 5 появилась нативная поддержка JSON.
                                  0
                                  На серверной стороне файл index.php — производит парсинг переданных параметров. Index.php берет всегда только первый элемент из списка переданных параметров $_REQUEST — это значит что конструкция вида www.example.com/api/?apitest.helloWorld={}&apitest.helloWorld2 — произведет вызов только метода helloWorld в apitest. Вызова же метода helloWorld2 непроизойдет


                                  Сломал мозк
                                    0
                                    Вот сижу и пытаюсь понять а в чем же разница (концептуально) между PDO и MySQLi.

                                    Тут же на хабре нашел интересную статью
                                    habrahabr.ru/post/141127/
                                    И первый же момент
                                    Выбирать нужно было между MySqli и PDO. После не очень длительного изучения решил остановиться на MySqli, так как, как мне тогда казалось, он полностью идентичен PDO, за исключением того, что нет возможности отказаться от MySQL в пользу чего-то другого. Как я напишу ниже это не совсем так, минимум одно заметное отличие есть.

                                    MySqli рекомендован к использованию самими разработчиками PHP.[1]

                                    Вот и не пойму? Это холивары такие: мы за PDO, MySQLi — г-но?

                                    Кстати полностью согласен с комментом:
                                    habrahabr.ru/post/141127/#comment_4718702
                                      0
                                      Все пишут как создавать API, а как красиво документировать и тестировать — 0. Я уже давно использую Apiary, о нем как-то писал в своем блоге.

                                      Only users with full accounts can post comments. Log in, please.