Админка магазина на vue.js. Урок 3. REST API на чистом PHP

январь 13 , 2019
Предыдущая статья Следующая статья Исходники

Чтобы развивать админку и дальше, нам нужно уметь работать с категориями товаров, брендами и товарами. То есть получать, добавлять, изменять и удалять их на сервере. В общем, серверное API. В нашем случае отлично подойдет REST API со стандартным набором операций CRUD: create, read, update, delete. Я уже писал статью Простой RESTful сервис на нативном php, на ее основе мы и напишем API для админки интернет-магазина.

Подробно расписывать именно принципы REST API на чистом php я не буду, потому что получится повторение указанной статьи. Пробегусь только по основным моментам. Но сначала кратко расскажу, о чем пойдет речь в статье.

Первое. Основной код написан на чистом php, никаких фреймворков.

Второе. Поддерживаются такие запросы:

    GET /categories
    POST /categories
    PUT /categories/{id}
    DELETE /categories/{id}
    
    GET /brands
    POST /brands
    PUT /brands/{id}
    DELETE /brands/{id}
    
    GET /products
    GET /products/?categoryId={categoryId}&offset={offset}&limit={limit}
    GET /products/{id}
    POST /products
    PUT /products/{id}
    DELETE /products/{id}

Как видим, категории и бренды мы умеем получать из базы только сразу все, а товары дополнительно фильтруем по id категории плюс прикручиваем пагинацию через offset и limit. Дополнительно можем получить данные по одному товару. Добавление, изменение и удаление у всех стандартное. Разница только в передаваемых в теле запроса параметрах, это увидим потом.

Третье. На всякий случай мы предполагаем, что это не последняя версия админки, поэтому поместим реализацию в папку /api/v1/. То есть запросы будут обращаться по адресу /admin/api/v1/categories. В - версионирование.

Четвертое. Ходить в базу MySql мы будем не через mysqli, как раньше, а через pdo и подготовленные запросы, тем самым уменьшая риски sql-инъекций.

Пятое. По сравнению с упомянутой статьей двухгодичной давности Простой RESTful сервис на нативном php, сейчас код будет немного доработан. Идея останется та же самая, но код будет более структурированным и появится вменяемая обработка ошибок.

Шестое. Я не хочу менять структуру базы для магазина, которую мы создали давным-давно, когда еще делали фильтры. Но для нового API админки мы будем использовать другие названия полей. Скептики скажут, что это моя блажь и исправление старых косяков с наименованиями. Я же оптимист и отвечу, что это мы с вами применяем паттерн Адаптер. Во как! До этого у меня в блоге таких слов не было, но уж очень хочется сойти за умного, не удержался.

И седьмое. В проекте будет подключена и использована библиотека underscore.php. Если Вы занимаете фронтендом, то возможно, уже поняли, что эта библиотека делает. Если еще не знакомы с ней, то потратьте 5 минут, бегло пробегитесь по набору функций. Будет полезно, рекомендую.

Это вкратце, дальше будут подробности. Если Вас не интересует серверная часть, можете просто скачать исходники и не забивать голову. В следующих уроках мы будем просто использовать готовое API.

Если же Вам интересно, как сделать REST API на нативном php или еще что-то из семи пунктов выше, то читайте дальше.


Подготовка, index.php, хелперы и обработка ошибок

Начинаем. В папке админ создадим папку api, а в ней папку v1. А уже в ней index.php с таким содержимым

    // Подключаем библиотеки и хелперы
    include_once 'lib/underscore.php';
    include_once 'common/helpers.php';
    
    // Получаем данные из запроса
    $data = \Helpers\getRequestData();
    $router = $data['router'];
    
    
    // Проверяем роутер на валидность
    if (\Helpers\isValidRouter($router)) {
    
        // Подключаем файл-роутер
        include_once "routers/$router.php";
    
        // Запускаем главную функцию
        route($data);
    
    } else {
        // Выбрасываем ошибку
        \Helpers\throwHttpError('invalid_router', 'router not found');
    }

Разбираем. Сначала подключаем lib/underscore.php (найдете в исходниках) и common/helpers.php (напишем руками чуть позже). Дальше в массив $data получаем все данные запроса:

1. method - http-метод (GET, POST, PUT или DELETE)
2. formData - данные из тела запроса, например, при добавлении товара будет array('title' => 'iPhone SE', 'price' => 20000, 'rating' => 5)
3. urlData - данные из урла, например, в запросе PUT /categories/5 urlData будет array('categories', '5')
4. router - какой роутер нужно запускать или с какой сущностью мы имеем дело, например, categories, brands или products.

Функция isValidRouter проверяет список доступных роутеров. Для чего это нужно? Дальше у нас подключается файл роутера с тем же названием и запускается функция route, которая все и разруливает. Чтобы не проверять файлы на существование, лучше проверим валидность роутера. И в последней строке, если роутер неверный, то выдадим ошибку с кодом и текстом ошибки.

Да, чуть не забыл. Чтобы все запросы вида admin/api/v1/... перенаправлялись на index.php, нужно еще немного. Если у Вас apache, то закиньте рядом с index.php файлик .htaccess с содержимым

    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^(.+)$ index.php?q=$1 [L,QSA]

Если nginx, то в конфигах сайта добавьте такой location

    location /admin/api/v1/ {
        try_files $uri $uri/ /admin/api/v1/index.php?q=$uri&$args;
    }

Теперь идем писать код функций-хелперов из index.php: getRequestData, isValidRouter и throwHttpError

Вот что нужно для первой функции. Файл common/helpers.php

    namespace Helpers;
    use PDO;
    
    // Получение данных из тела запроса
    function getFormData($method) {
    
        // GET или POST: данные возвращаем как есть
        if ($method === 'GET') {
            $data = $_GET;
        } else if ($method === 'POST') {
            $data = $_POST;
    
        } else {
            // PUT, PATCH или DELETE
            $data = array();
            $exploded = explode('&', file_get_contents('php://input'));
    
            foreach($exploded as $pair) {
                $item = explode('=', $pair);
                if (count($item) == 2) {
                    $data[urldecode($item[0])] = urldecode($item[1]);
                }
            }
        }
    
        // Удаляем параметр q
        unset($data['q']);
    
        return $data;
    }
    
    
    // Получаем все данные о запросе
    function getRequestData() {
        // Определяем метод запроса
        $method = $_SERVER['REQUEST_METHOD'];
    
        // Разбираем url
        $url = (isset($_GET['q'])) ? $_GET['q'] : '';
        $url = trim($url, '/');
        $urls = explode('/', $url);
    
        // Убираем из api-запросов префикс admin/api/v1
        $urlData = array_slice($urls, 3);
    
        return array(
            'method' => $method,
            'formData' => getFormData($method),
            'urlData' => $urlData,
            'router' => $urlData[0]
        );
    
    }

Подробности в той же статье Простой RESTful сервис на нативном php, не хочется повторять то же самое. Лучше посмотрим на следующие 2 функции: isValidRouter и throwHttpError.

    // Проверка роутера на валидность
    function isValidRouter($router) {
        return in_array($router, array(
            'categories',
            'brands',
            'products'
        ));
    }

Здесь тупо составляем "белый список" роутеров и проверяем на вхождение в массив. И вывод ошибки запроса

    // Выводим 400 ошибку http-запроса
    function throwHttpError($code, $message) {
        header('HTTP/1.0 400 Bad Request');
        echo json_encode(array(
            'code' => $code,
            'message' => $message
        ));
    }

В throwHttpError передаем 2 параметра. Зачем два? Первый code пригодится на клиенте, чтобы понять, что пошло не так. Забегая вперед, примеры кодов ошибок: invalid_parameters, category_exists, brand_not_exists и уже знакомый invalid_router. То есть, мы их можем использовать, чтобы показать пользователю, что он сделал не так. Использовать это или нет, дело Ваше. Я, например, в уроках вряд ли буду засорять код сообщениями на каждый отдельный код ошибки, но знайте, что такая возможность есть. Второй параметр message содержит более менее читаемый текст о причине ошибки, который уже можно вывести пользователю. Именно message мы и будем использовать дальше в уроках.

Можно было сделать метод более общим и передавать в него еще и http-код ответа, а не зашивать 400. Но мне кажется, что для админки магазина, которой пользуется ограниченный круг людей, это уже совсем лишнее, никому эти разные коды не нужны.


PDO. Подключаемся к MySql и пишем вспомогательные функции

Может, обратили внимание на строчку use PDO; в самом начале helpers.php? Она разрешит нам использовать в пространстве имен Helpers возможности pdo. Первой функцией будет подключение к базе.

    // Подключение к БД
    function connectDB() {
        $host = 'localhost';
        $user = 'root';
        $password = 'root';
        $db = 'webdevkin';
    
        $dsn = "mysql:host=$host;dbname=$db;charset=utf8";
        $options = array(
            PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8",
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
        );
    
        return new PDO($dsn, $user, $password, $options);
    }

Тут ничего хитрого, только не забудьте проставить свои доступы вместо моих секурных root/root. Дальше напишем несколько вспомогательных функций, которые пригодятся нам при работе дальше. А заодно посмотрим, как работать с этим pdo.

Сначала сделаем проверку, существует ли категория с указанным id

    // Проверка, существует ли категория с таким id
    function isExistsCategoryById($pdo, $id) {
        $query = 'select id from categories where id=:id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->execute();
    
        return count($data->fetchAll()) === 1;
    }

Это самый стандартный пример для работы с базой через pdo. Сначала мы пишем запрос, где вместо данных ставятся плейсхолдеры типа :id. Потом подставляем вместо плейсхолдеров данные с определенным типом (в нашем случае integer) и выполняем запрос.

Ниже еще 5 функций, работают они аналогично.

    // Проверка, существует ли категория с таким названием
    function isExistsCategoryByTitle($pdo, $title) {
        $query = 'select id from categories where category=:title';
        $data = $pdo->prepare($query);
        $data->bindParam(':title', $title);
        $data->execute();
    
        return count($data->fetchAll()) === 1;
    }
    
    
    // Проверка, существует ли бренд с таким id
    function isExistsBrandById($pdo, $id) {
        $query = 'select id from brands where id=:id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->execute();
    
        return count($data->fetchAll()) === 1;
    }
    
    
    // Проверка, существует ли бренд с таким названием
    function isExistsBrandByTitle($pdo, $title) {
        $query = 'select id from brands where brand=:title';
        $data = $pdo->prepare($query);
        $data->bindParam(':title', $title);
        $data->execute();
    
        return count($data->fetchAll()) === 1;
    }
    
    
    // Проверка, существует ли товар с таким id
    function isExistsProductById($pdo, $id) {
        $query = 'select id from goods where id=:id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->execute();
    
        return count($data->fetchAll()) === 1;
    }
    
    
    // Проверка, существует ли товар с таким названием
    function isExistsProductByTitle($pdo, $title) {
        $query = 'select id from goods where good=:title';
        $data = $pdo->prepare($query);
        $data->bindParam(':title', $title);
        $data->execute();
    
        return count($data->fetchAll()) === 1;
    }

Все, в helpers.php сегодня ничего добавлять не будем, пора реализовывать самое интересное - роутеры.


Работа с категориями. Роутер categories

Сначала создадим папку routers, а в ней файл categories.php. Помните, в index.php мы запускали функцию route? Еще упоминали, что она все разрулит. Давайте посмотрим, каким образом

    // Роутинг, основная функция
    function route($data) {
    
        // GET /categories
        if ($data['method'] === 'GET' && count($data['urlData']) === 1) {
            echo json_encode(getCategories());
            exit;
        }
    
        // POST /categories
        if ($data['method'] === 'POST' && count($data['urlData']) === 1 && isset($data['formData']['title'])) {
            $title = $data['formData']['title'];
    
            echo json_encode(addCategory($title));
            exit;
        }
    
        // PUT /categories/5
        if ($data['method'] === 'PUT' && count($data['urlData']) === 2 && isset($data['formData']['title'])) {
            $id = (int)$data['urlData'][1];
            $title = $data['formData']['title'];
    
            echo json_encode(updateCategory($id, $title));
            exit;
        }
    
        // DELETE /categories/5
        if ($data['method'] === 'DELETE' && count($data['urlData']) === 2) {
            $id = (int)$data['urlData'][1];
    
            echo json_encode(deleteCategory($id));
            exit;
        }
    
    
        // Если ни один роутер не отработал
        \Helpers\throwHttpError('invalid_parameters', 'invalid parameters');
    
    }

Здесь понаписана последовательная обработка четырех запросов:
GET /categories
POST /categories
PUT /categories/{id}
DELETE /categories/{id}

Обратите внимание на условия. Обязательно проверяется http-метод, количество параметров в самом урле, а еще для добавления и изменения категории параметр title из тела запроса. При выполнении условий мы просто запускаем соотвествующий метод обработки с нужными данными, а результат возвращаем через json_encode. В конце, если ни один роутер не отработал, выбрасываем исключение throwHttpError с кодом invalid_parameters.

Остается написать код для получения, добавления, редактирования и удаления категорий. Он скучный и однотипный, поэтому без разбора просто портянка кода чуть ниже. Из примечательного там только то, что названия передаваемых по API параметров и названия колонок в mysql-таблицах не совпадают. Например, в таблице название категории это category, а в API мы используем title. Это и есть тот самый страшный паттерн АДАПТЕР. Типа переходника между нужным нам интерфейсом и тем, что имеется. Боюсь, что разочаровал Вас таким описанием умного паттерна, но мне больше нравится такое определение, чем то, что в википедии. Вы только почитайте. Адаптер (англ. Adapter) — структурный шаблон проектирования, предназначенный для организации использования функций объекта, недоступного для модификации, через специально созданный интерфейс. Господи, это же жесть какая-то, как вы в этих паттернах вообще разбираетесь?

Ладно, философию в сторону, ниже код. Если будут вопросы, задавайте в комментариях.

    // Возвращаем все категории
    function getCategories() {
        $pdo = \Helpers\connectDB();
        $query = 'select id, category from categories';
        $data = $pdo->prepare($query);
        $data->execute();
    
        return array(
            'meta' => array(),
            'records' => __::map($data->fetchAll(), function($item) {
                return array(
                    'id' => (int)$item['id'],
                    'title' => $item['category']
                );
            })
        );
    }
    
    
    // Добавление категории
    function addCategory($title) {
        $pdo = \Helpers\connectDB();
    
        // Если категория существует, то выбрасываем ошибку
        if (\Helpers\isExistsCategoryByTitle($pdo, $title)) {
            \Helpers\throwHttpError('category_exists', 'category already exists');
            exit;
        }
    
        // Добавляем категорию в базу
        $query = 'insert into categories (category) values (:title)';
        $data = $pdo->prepare($query);
        $data->bindParam(':title', $title);
        $data->execute();
    
        // Новый айдишник для добавленной категории
        $newId = (int)$pdo->lastInsertId();
    
        return array(
            'id' => $newId,
            'title' => $title
        );
    }
    
    
    // Обновление категории
    function updateCategory($id, $title) {
        $pdo = \Helpers\connectDB();
    
        // Если категория не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsCategoryById($pdo, $id)) {
            \Helpers\throwHttpError('category_not_exists', 'category not exists');
            exit;
        }
    
        // Обновляем категорию в базе
        $query = 'update categories set category=:title where id=:id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->bindParam(':title', $title);
        $data->execute();
    
        return array(
            'id' => $id,
            'title' => $title
        );
    }
    
    
    // Удаление категории
    function deleteCategory($id) {
        $pdo = \Helpers\connectDB();
    
        // Если категория не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsCategoryById($pdo, $id)) {
            \Helpers\throwHttpError('category_not_exists', 'category not exists');
            exit;
        }
    
        // Удаляем категорию из базы
        $query = 'delete from categories where id=:id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->execute();
    
        return array(
            'id' => $id
        );
    }

Работа с брендами. Роутер brands

С брендами мы работаем абсолютно так же, как и с категориями. Те же операции, те же параметры, та же структура таблицы. Только название таблицы другое (brands) и поле названия бренда (brand). Я даже код копипастить не буду, возьмите в исходниках.


Работа с товарами. Роутер products

Код в products.php очень похож на предыдущие, но с тремя отличиями:

1. Добавляется запрос GET /products/{id} - вытаскиваем данные по одному товару

2. Запрос GET /products/ поддерживает пагинацию и фильтрацию по id категории. Это достигается передачей необязательных get-параметров categoryId, offset и limit

3. При добавлении и редактировании товаров в теле запроса передается больше параметров. Не один title, а такой набор: title, categoryId, brandId, price и rating. Никакой разницы нет, просто приходится делать больше проверок и sql-запросы будут длиннее.

Давайте я выложу всю портянку, а потом рассмотрим эти 3 пункта подробнее.

    // Роутинг, основная функция
    function route($data) {
    
        // GET /products
        if ($data['method'] === 'GET' && count($data['urlData']) === 1) {
            $options = $data['formData'];
    
            echo json_encode(getProducts($options));
            exit;
        }
    
        // GET /products/5
        if ($data['method'] === 'GET' && count($data['urlData']) === 2) {
            $id = (int)$data['urlData'][1];
    
            echo json_encode(getProduct($id));
            exit;
        }
    
        // POST /products
        if (
            $data['method'] === 'POST' &&
            count($data['urlData']) === 1 &&
            isset($data['formData']['title']) &&
            isset($data['formData']['categoryId']) &&
            isset($data['formData']['brandId']) &&
            isset($data['formData']['price']) &&
            isset($data['formData']['rating'])
        ) {
            $title = $data['formData']['title'];
            $categoryId = (int)$data['formData']['categoryId'];
            $brandId = (int)$data['formData']['brandId'];
            $price = (int)$data['formData']['price'];
            $rating = (int)$data['formData']['rating'];
    
            echo json_encode(addProduct($title, $categoryId, $brandId, $price, $rating));
            exit;
        }
    
        // PUT /products/5
        if (
            $data['method'] === 'PUT' &&
            count($data['urlData']) === 2 &&
            isset($data['formData']['title']) &&
            isset($data['formData']['categoryId']) &&
            isset($data['formData']['brandId']) &&
            isset($data['formData']['price']) &&
            isset($data['formData']['rating'])
        ) {
            $id = (int)$data['urlData'][1];
            $title = $data['formData']['title'];
            $categoryId = (int)$data['formData']['categoryId'];
            $brandId = (int)$data['formData']['brandId'];
            $price = (int)$data['formData']['price'];
            $rating = (int)$data['formData']['rating'];
    
            echo json_encode(updateProduct($id, $title, $categoryId, $brandId, $price, $rating));
            exit;
        }
    
        // DELETE /products/5
        if ($data['method'] === 'DELETE' && count($data['urlData']) === 2) {
            $id = (int)$data['urlData'][1];
    
            echo json_encode(deleteProduct($id));
            exit;
        }
    
    
        // Если ни один роутер не отработал
        \Helpers\throwHttpError('invalid_parameters', 'invalid parameters');
    
    }
    
    
    // Возвращаем все товары
    function getProducts($options) {
        $pdo = \Helpers\connectDB();
        $meta = array();
        $query = 'select g.id, g.good, g.category_id, g.brand_id, b.brand, g.price, g.rating from goods g, brands b where g.brand_id = b.id';
    
        // Фильтруем по категории
        if (
            isset($options['categoryId']) &&
            is_numeric($options['categoryId'])
        ) {
            $query .= ' and g.category_id = :categoryId';
            $meta['categoryId'] = (int)$options['categoryId'];
        }
    
        // Пагинация
        if (
            isset($options['offset']) &&
            is_numeric($options['offset']) &&
            isset($options['limit']) &&
            is_numeric($options['limit'])
        ) {
            $query .= ' limit :offset, :limit';
            $meta['offset'] = (int)$options['offset'];
            $meta['limit'] = (int)$options['limit'];
        }
    
        $data = $pdo->prepare($query);
        foreach ($meta as $key => $value) {
            $data->bindValue(':' . $key, $value, PDO::PARAM_INT);
        }
        $data->execute();
    
        return array(
            'meta' => $meta,
            'records' => __::map($data->fetchAll(), function($item) {
                return array(
                    'id' => (int)$item['id'],
                    'title' => $item['good'],
                    'categoryId' => (int)$item['category_id'],
                    'brandId' => (int)$item['brand_id'],
                    'brand' => $item['brand'],
                    'price' => (int)$item['price'],
                    'rating' => (int)$item['rating']
                );
            })
        );
    }
    
    
    // Возвращаем информацию по одному товару
    function getProduct($id) {
        $pdo = \Helpers\connectDB();
    
        // Если товар не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsProductById($pdo, $id)) {
            \Helpers\throwHttpError('product_not_exists', 'product not exists');
            exit;
        }
    
        $query = 'select g.id, g.good, g.category_id, g.brand_id, b.brand, g.price, g.rating from goods g, brands b where g.id=:id and g.brand_id = b.id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->execute();
    
        $item = $data->fetch();
        return array(
            'id' => $id,
            'title' => $item['good'],
            'categoryId' => (int)$item['category_id'],
            'brandId' => (int)$item['brand_id'],
            'brand' => $item['brand'],
            'price' => (int)$item['price'],
            'rating' => (int)$item['rating']
        );
    }
    
    
    // Добавление товара
    function addProduct($title, $categoryId, $brandId, $price, $rating) {
        $pdo = \Helpers\connectDB();
    
        // Если товар существует, то выбрасываем ошибку
        if (\Helpers\isExistsProductByTitle($pdo, $title)) {
            \Helpers\throwHttpError('product_exists', 'product already exists');
            exit;
        }
    
        // Если категория не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsCategoryById($pdo, $categoryId)) {
            \Helpers\throwHttpError('category_not_exists', 'category not exists');
            exit;
        }
    
        // Если бренда не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsBrandById($pdo, $brandId)) {
            \Helpers\throwHttpError('brand_not_exists', 'brand not exists');
            exit;
        }
    
        // Добавляем товар в базу
        $query = 'insert into goods (good, category_id, brand_id, price, rating, photo) values (:title, :categoryId, :brandId, :price, :rating, :photo)';
        $data = $pdo->prepare($query);
        $data->bindParam(':title', $title);
        $data->bindParam(':categoryId', $categoryId, PDO::PARAM_INT);
        $data->bindParam(':brandId', $brandId, PDO::PARAM_INT);
        $data->bindParam(':price', $price, PDO::PARAM_INT);
        $data->bindParam(':rating', $rating, PDO::PARAM_INT);
        $data->bindValue(':photo', ''); // bindParam работает только с переменными
        $data->execute();
    
        // Новый айдишник для добавленного товара
        $newId = (int)$pdo->lastInsertId();
        return getProduct($newId);
    }
    
    
    // Обновление товара
    function updateProduct($id, $title, $categoryId, $brandId, $price, $rating) {
        $pdo = \Helpers\connectDB();
    
        // Если товар не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsProductById($pdo, $id)) {
            \Helpers\throwHttpError('product_not_exists', 'product not exists');
            exit;
        }
    
        // Если категория не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsCategoryById($pdo, $categoryId)) {
            \Helpers\throwHttpError('category_not_exists', 'category not exists');
            exit;
        }
    
        // Если бренда не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsBrandById($pdo, $brandId)) {
            \Helpers\throwHttpError('brand_not_exists', 'brand not exists');
            exit;
        }
    
        // Обновляем товар в базе
        $query = 'update goods set good=:title, category_id=:categoryId, brand_id=:brandId, price=:price, rating=:rating where id=:id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->bindParam(':title', $title);
        $data->bindParam(':categoryId', $categoryId, PDO::PARAM_INT);
        $data->bindParam(':brandId', $brandId, PDO::PARAM_INT);
        $data->bindParam(':price', $price, PDO::PARAM_INT);
        $data->bindParam(':rating', $rating, PDO::PARAM_INT);
        $data->execute();
    
        return getProduct($id);
    }
    
    
    // Удаление товара
    function deleteProduct($id) {
        $pdo = \Helpers\connectDB();
    
        // Если товар не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsProductById($pdo, $id)) {
            \Helpers\throwHttpError('product_not_exists', 'product not exists');
            exit;
        }
    
        // Удаляем товар из базы
        $query = 'delete from goods where id=:id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->execute();
    
        return array(
            'id' => $id
        );
    }

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

    // Возвращаем информацию по одному товару
    function getProduct($id) {
        $pdo = \Helpers\connectDB();
    
        // Если товар не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsProductById($pdo, $id)) {
            \Helpers\throwHttpError('product_not_exists', 'product not exists');
            exit;
        }
    
        $query = 'select g.id, g.good, g.category_id, g.brand_id, b.brand, g.price, g.rating from goods g, brands b where g.id=:id and g.brand_id = b.id';
        $data = $pdo->prepare($query);
        $data->bindParam(':id', $id, PDO::PARAM_INT);
        $data->execute();
    
        $item = $data->fetch();
        return array(
            'id' => $id,
            'title' => $item['good'],
            'categoryId' => (int)$item['category_id'],
            'brandId' => (int)$item['brand_id'],
            'brand' => $item['brand'],
            'price' => (int)$item['price'],
            'rating' => (int)$item['rating']
        );
    }

Небольшая хитрость только в том, что нам понадобится сходить в две таблицы: товаров и брендов. Возвращаем и id бренда, и его название.

Дальше посмотрим, как мы работаем с кучей параметров на примере добавления товара. В route пишем

    // POST /products
    if (
        $data['method'] === 'POST' &&
        count($data['urlData']) === 1 &&
        isset($data['formData']['title']) &&
        isset($data['formData']['categoryId']) &&
        isset($data['formData']['brandId']) &&
        isset($data['formData']['price']) &&
        isset($data['formData']['rating'])
    ) {
        $title = $data['formData']['title'];
        $categoryId = (int)$data['formData']['categoryId'];
        $brandId = (int)$data['formData']['brandId'];
        $price = (int)$data['formData']['price'];
        $rating = (int)$data['formData']['rating'];

        echo json_encode(addProduct($title, $categoryId, $brandId, $price, $rating));
        exit;
    }

Ничем не отличается от добавления категории или бренда, только больше проверок isset и больше параметров передается в функцию добавления addProduct.

А вот и код самой addProduct

    // Добавление товара
    function addProduct($title, $categoryId, $brandId, $price, $rating) {
        $pdo = \Helpers\connectDB();
    
        // Если товар существует, то выбрасываем ошибку
        if (\Helpers\isExistsProductByTitle($pdo, $title)) {
            \Helpers\throwHttpError('product_exists', 'product already exists');
            exit;
        }
    
        // Если категория не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsCategoryById($pdo, $categoryId)) {
            \Helpers\throwHttpError('category_not_exists', 'category not exists');
            exit;
        }
    
        // Если бренда не существует, то выбрасываем ошибку
        if (!\Helpers\isExistsBrandById($pdo, $brandId)) {
            \Helpers\throwHttpError('brand_not_exists', 'brand not exists');
            exit;
        }
    
        // Добавляем товар в базу
        $query = 'insert into goods (good, category_id, brand_id, price, rating, photo) values (:title, :categoryId, :brandId, :price, :rating, :photo)';
        $data = $pdo->prepare($query);
        $data->bindParam(':title', $title);
        $data->bindParam(':categoryId', $categoryId, PDO::PARAM_INT);
        $data->bindParam(':brandId', $brandId, PDO::PARAM_INT);
        $data->bindParam(':price', $price, PDO::PARAM_INT);
        $data->bindParam(':rating', $rating, PDO::PARAM_INT);
        $data->bindValue(':photo', ''); // bindParam работает только с переменными
        $data->execute();
    
        // Новый айдишник для добавленного товара
        $newId = (int)$pdo->lastInsertId();
        return getProduct($newId);
    }

Думаю, тоже не удивил. Вместо одной проверки isExists целых три: на существование товара с таким названием (мы предполагаем, что названия всех товаров уникальны) и проверка существования категории и бренда. И чуть длиннее sql-запрос с привязкой параметров. Из интересного разве что привязка photo. Дело в том, что мы пока не будем грузить фото и сохранять его в базу, но база требует явно указать photo. Ну так спроектировали, извините. Вы в своей базе сделаете по-человечески, зададите дефолтное значение прямо в таблице, а нам сейчас приходится проставлять photo руками. Но так как bindParam привязывает только переменные. то пустую строку мы привяжем через bindValue

    $data->bindValue(':photo', '');

И третий пункт, фильтрация по категории и пагинация в получении товаров. Вот так это выглядит

    // Возвращаем все товары
    function getProducts($options) {
        $pdo = \Helpers\connectDB();
        $meta = array();
        $query = 'select g.id, g.good, g.category_id, g.brand_id, b.brand, g.price, g.rating from goods g, brands b where g.brand_id = b.id';
    
        // Фильтруем по категории
        if (
            isset($options['categoryId']) &&
            is_numeric($options['categoryId'])
        ) {
            $query .= ' and g.category_id = :categoryId';
            $meta['categoryId'] = (int)$options['categoryId'];
        }
    
        // Пагинация
        if (
            isset($options['offset']) &&
            is_numeric($options['offset']) &&
            isset($options['limit']) &&
            is_numeric($options['limit'])
        ) {
            $query .= ' limit :offset, :limit';
            $meta['offset'] = (int)$options['offset'];
            $meta['limit'] = (int)$options['limit'];
        }
    
        $data = $pdo->prepare($query);
        foreach ($meta as $key => $value) {
            $data->bindValue(':' . $key, $value, PDO::PARAM_INT);
        }
        $data->execute();
    
        return array(
            'meta' => $meta,
            'records' => __::map($data->fetchAll(), function($item) {
                return array(
                    'id' => (int)$item['id'],
                    'title' => $item['good'],
                    'categoryId' => (int)$item['category_id'],
                    'brandId' => (int)$item['brand_id'],
                    'brand' => $item['brand'],
                    'price' => (int)$item['price'],
                    'rating' => (int)$item['rating']
                );
            })
        );
    }

Никаких секретов, просто при наличии соответствующих параметров добавляем в sql-запрос условие "where g.category_id = :categoryId" или "limit :offset, :limit"

Я немного побаловался с привязкой параметров через массив $meta (хз, почему почему так назвал - meta) В общем, дело в том, что параметры привязываются после подготовки запроса $pdo->prepare($query). Но привязывать все возможные параметры нельзя, pdo будет ругаться. Поэтому нужно или проверять isset-ы по 2 раза, первый при формировании sql-запроса, второй - при привязке. Или вот так, короче и универсальнее. Например, если мы захотим добавить сортировку, то нужно будет дописать только

    // Сортировка
    if (
        isset($options['sortBy']) &&
        isset($options['sortDir'])
    ) {
        $query .= ' sort by :sortBy :sortDir';
        $meta['sortBy'] = $options['sortBy'];
        $meta['sortDir'] = $options['sortDir'];
    }

И все, будет чуть меньше копипасты.

Впрочем, это уже на будущее, а на этом серверный урок закончен.

Еще раз напомню, исходники скачивать здесь. Если хотите подробнее разобраться с реализацией REST API на чистом php, то статья здесь

До встречи на четвертом уроке. Там уже будет только клиентская часть и только vue.js

Все уроки админки на vue.js

Предыдущая статья Следующая статья Исходники
Заходите в группу в контакте - https://vk.com/webdevkin
Анонсы статей, обсуждения интернет-магазинов, vue, фронтенда, php, гита.
Истории из жизни айти и обсуждение кода.
Как Вам статья? Оцените!