Skip to content

Instantly share code, notes, and snippets.

@fomvasss
Last active August 7, 2024 12:32
Show Gist options
  • Save fomvasss/792001644b31b64add866bd5a1610a4e to your computer and use it in GitHub Desktop.
Save fomvasss/792001644b31b64add866bd5a1610a4e to your computer and use it in GitHub Desktop.
Best practices Laravel Rest API

Best practices написание REST-API

  • Имена полей в ответе задавать в snake_case (prr_page, created_at, system_name,...)
  • Для времени использовать ISO 8601 (формат: YYYY-MM-DDTHH:MM:SSZ)
  • Отдавать данные (сам контент, поля сущностей, массивы сущностей), помещая их в data

Использование REST методов и примеры url'ов

  • GET: /api/users — получить список пользователей;
  • GET: /api/users/123 — получить указанного пользователя;
  • POST: /api/users — создать нового пользователя;
  • PATCH: /api/users/123 — обновить данные пользователя;
  • PUT: /api/users/123 — обновить все данные указанного пользователя (используется не часто);
  • DELETE: /api/users/123 — удалить пользователя.

Коды ответов для некоторых действий:

  • get - 200 (HTTP_OK)
  • create - 201 (HTTP_CREATED)
  • update - 202 (HTTP_ACCEPTED)
  • destroy - 200 (HTTP_OK) / 204 (HTTP_NO_CONTENT)

Список сущностей:

Контент отдавать в data!!!

{
    "data": [
        {
            "name": "Первая страница сайта",
            "created_at": "2017-09-01T11:54:22+03:00",
        },
        {
            "name": "Вторая страница сайта",
            "created_at": "2017-05-01T11:54:23+03:00",
        }
    ]
}

Одна сущность:

{
    "data":
        {
            "name": "Первая статья на сайте",
            "created_at": "2017-09-01T11:54:22+03:00",
            "images": [
                {"name":"d32d23.png"},
                {"name":"ew432f.png"}
            ]
        }
}

Данные пагинации отдавать в links (как по умолчанию в Laravel):

{
     "links": {
        "first": "http://laravel55.dev/api/v1/post?page=1",
        "last": "http://laravel55.dev/api/v1/post?page=8",
        "prev": null,
        "next": "http://laravel55.dev/api/v1/post?page=2"
      }
}

Дополнительные поля - в meta:

{
     "meta": {
        "total":34,
        "per_page":15
      }
}

Одно уведомления отдавать в message

Например, для вывода по центру в модалке: "Данные успошно сохранены!":

{
    "message": "Данные успошно сохранены!"
}

Список сущностей + пагинация + уведомление + meta:

{
    "data": [
        {
            "name": "Первая страница сайта",
            "created_at": "2017-09-01T11:54:22+03:00",
        },
        {
            "name": "Вторая страница сайта",
            "created_at": "2017-05-01T11:54:23+03:00",
        }
    ],
     "links": {
        "first": "http://site.test/api/v1/post?page=1",
        "last": "http://site.test/api/v1/post?page=8",
        "prev": null,
        "next": "http://site.test/api/v1/post?page=2"
      },
      "meta": {
        "total":34,
        "per_page":15
      },
      "message": "Это список сущностей!"
}

Ошибки валидации отдавать в массиве errors (например для вывода ошибок в углу экрана):

ответ должен быть с кодом 422 - ошибка валидности

{
    "errors": {
        "name": [
            "Поле Название обязательно для заполнения.",
            "Поле Название должно иметь больше 6 символов."
        ]
    },
    "message": "The given data was invalid."
}

Ошибки приложения (400, 401, 402, 403, 404) отдавать в массиве error (как по умолчанию в Laravel):

{
    "error":"Incorect route"
}

Загрузка файла:

TODO ???

 {
     "data": {
         "file": 81
     },
     "message": "Файл успешно загружен!"
 }

Некоторые HTTP коды (\Symfony\Component\HttpFoundation\Response:)

    const HTTP_OK = 200;
    const HTTP_CREATED = 201;
    const HTTP_ACCEPTED = 202;
    const HTTP_NO_CONTENT = 204;

    const HTTP_BAD_REQUEST = 400;
    const HTTP_UNAUTHORIZED = 401;
    const HTTP_PAYMENT_REQUIRED = 402;
    const HTTP_FORBIDDEN = 403;
    const HTTP_NOT_FOUND = 404;
    const HTTP_METHOD_NOT_ALLOWED = 405;
    const HTTP_UNPROCESSABLE_ENTITY = 422;  // RFC4918

GET-параметры для фильтров и сортировки

GET-параметры для фильтров формировать по примеру:

- http://site.test/posts?filter[terms][regions][]=dnepr&filter[terms][regions][]=kiev&order['created_at']=asc&filter[price_to]=123&filter[price_from]=123&filter[user.status]=1&coll[]=user&coll[]=name&per_page=15&page=2
- http://site.test/posts?q=фраза+для+поиска&sort=stars&order=desc&page=2&per_page=100

GET-параметры для сортировки формировать по примеру:

- https://api.github.test/search/repositories?sort=created_at&order=desc

GET-параметры для поиска формировать по примеру:

- https://api.github.test/search/repositories?q=tetris+language

Общий пример

- https://api.github.test/search/repositories?q=tetris+language:assembly&sort=stars&order=desc&filter[price_from]=123

Пример реализации API CRUD на Laravel для управление страницамы сайта + комментарии для документации (http://apidocjs.com/):

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Http\Requests\Api\Admin\PageRequest;
use App\Models\Page;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Resources\Page as PageResource;

class PageController extends Controller
{
    protected $pageModel;

    /**
     * PageController constructor.
     * @param $pageModel
     */
    public function __construct(Page $pageModel)
    {
        $this->pageModel = $pageModel;
    }

    /**
     * @api {get} /api/v1/admin/page Получить страницы
     * @apiVersion 1.0.0
     * @apiName GetPages
     * @apiGroup Page
     *
     * @apiParam {int} [page=1] Номер страницы
     * @apiParam {int} [per_page=15] Количество элементов для вывода
     * @apiParam {string} [q] Строка для поиска
     *
     * @apiExample Пример запроса:
     *     /api/v1/admin/page?page=3&q=your+search+keywords
     *
     */
    public function index(Request $request)
    {
        $pages = $this->pageModel->sortable('id')->likeSearchable()->paginate($request->per_page);

        return PageResource::collection($pages);
    }

    /**
     * @api {post} /api/v1/admin/page Сохранить страницу
     * @apiVersion 1.0.0
     * @apiName PostPage
     * @apiGroup Page
     *
     * @apiParam {string} title Заголовок страницы
     * @apiParam {string} [slug] URL-slug
     * @apiParam {string} [body] Контент
     * @apiParam {integer{0,1}} [publish=1] Статус публикации
     *
     */
    public function store(PageRequest $request)
    {
        $page = $this->pageModel->create($request->validated());

        return (new PageResource($page))
            ->additional(['message' => trans('notifications.store.success')])
            ->response()
            ->setStatusCode(\Illuminate\Http\Response::HTTP_CREATED);
    }

    /**
     * @api {get} /api/v1/admin/page/:id Получить страницу
     * @apiVersion 1.0.0
     * @apiName GetPage
     * @apiGroup Page
     *
     */
    public function show($id)
    {
        $page = $this->pageModel->with('metaTag')->findOrFail($id);

        return new PageResource($page);
    }

    /**
     * @api {put} /api/v1/admin/page Обновить страницу
     * @apiVersion 1.0.0
     * @apiName PutPage
     * @apiGroup Page
     *
     * @apiParam {string} title Заголовок страницы
     * @apiParam {string} [slug] Slug для URL
     * @apiParam {string} [body] Контент
     *
     */
    public function update(PageRequest $request, $id)
    {
        $page = $this->pageModel->findOrFail($id);
        $page->update($request->validated());

        return (new PageResource($page))
            ->additional(['message' => trans('notifications.update.success')])
            ->response()
            ->setStatusCode(\Illuminate\Http\Response::HTTP_ACCEPTED);
    }

    /**
     * @api {delete} /api/v1/admin/page/:id Удалить страницу
     * @apiVersion 1.0.0
     * @apiName DeletePage
     * @apiGroup Page
     *
     */
    public function destroy($id)
    {
        $this->pageModel->findOrFail($id)->delete();

        return response()
            ->json(['message' => trans('notifications.destroy.success'),])
            ->setStatusCode(\Illuminate\Http\Response::HTTP_OK);
    }
}

Laravel PASSPORT

AuthServiceProvider:

public function boot()
{
    $this->registerPolicies();

    //Passport::routes();
    Passport::routes(
        function ($router) {
            $router->forAuthorization();
            //$router->forAccessTokens();
            //$router->forTransientTokens();
            //$router->forClients();
            //$router->forPersonalAccessTokens();
        }
    );
}

Form Request

<?php

namespace App\Http\FormRequests;

use Illuminate\Http\JsonResponse;

/**
 * Class FormRequest
 *
 * @package \App\Http\FormRequests
 */
class FormRequest extends \Illuminate\Foundation\Http\FormRequest
{

    /**
     * Get the proper failed validation response for the request.
     *
     * @param  array  $errors
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function response(array $errors)
    {
        if ($this->expectsJson()) {
            return new JsonResponse([
                'errors' => $errors
            ], 422);
        }
        parent::response($errors);
    }
}

Exceptions

<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;

class Handler extends ExceptionHandler
{
    use ExceptionTrait;

    /**
     * A list of the exception types that are not reported.
     *
     * @var array
     */
    protected $dontReport = [
        //
    ];

    /**
     * A list of the inputs that are never flashed for validation exceptions.
     *
     * @var array
     */
    protected $dontFlash = [
        'password',
        'password_confirmation',
    ];

    /**
     * Report or log an exception.
     *
     * This is a great spot to send exceptions to Sentry, Bugsnag, etc.
     *
     * @param  \Exception  $exception
     * @return void
     */
    public function report(Exception $exception)
    {
        parent::report($exception);
    }

    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Exception  $exception
     * @return \Illuminate\Http\Response
     */
    public function render($request, Exception $exception)
    {
        if ($request->expectsJson()) {
            return $this->apiException($request, $exception);
        }

        return parent::render($request, $exception);
    }
}
<?php

namespace App\Exceptions;

use Symfony\Component\HttpFoundation\Response;

trait ExceptionTrait
{

    public function apiException($request, $e)
    {
        if ($this->isModel($e)) {
            return $this->modelResponse($e);
        }

        if ($this->isHttp($e)) {
            return $this->httpResponse($e);
        }

        return parent::render($request, $e);
    }


    protected function isModel($e)
    {
        return $e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException;
    }

    protected function isHttp($e)
    {
        return $e instanceof \Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
    }


    protected function modelResponse($e)
    {
        $modelClass = explode('\\', $e->getModel());

        return response([
            'error' => 'Model '.end($modelClass).' not found',
        ], Response::HTTP_NOT_FOUND);
    }

    protected function httpResponse($e)
    {
        return response([
            'error' => 'Incorrect route name',
        ], Response::HTTP_NOT_FOUND);
    }
}
<?php

namespace App\Exceptions;

use Exception;

class RepositoryException extends Exception
{
    public function render($request, Exception $exception)
    {
        return 'Error model';
    }
}
@axcherednikov
Copy link

@Barmunksu Такой же вопрос...

@sidigi
Copy link

sidigi commented Jan 7, 2022

Ну может быть открыть документацию? прочитать про exception handler прочитать про виды exceptions и про методы render / report

@axcherednikov
Copy link

По мне так проще разделить обработку ошибок на проекте где создаётся API для разных нужд. Будь то это мобильное приложение, бэкофис, вебсайт приложения или что-то другое.

Пришлось столкнуться с тем, что для мобильного приложения и бэкофиса была разная API, так вот в данном примере хорошо видно как в зависимости от типа запроса можно менять обработку ошибок в ответе.
Мне же приходилось менять ответ в зависимости от роута, т.к. изначально API была разная. А если придётся создавать API V2 и т.д. с различным форматом ответа в зависимости от требования и договоренности и я не увидел как делегировать обработку разным участкам маршрута и требованиям бизнес -логики средствами Laravel. В документации про это не рассказывается.

Здесь же автор показывает как в зависимости от запроса, будет выглядеть ответ. И это очень удобно.

Буду признателен узнать секреты Laravel, которые обрабатывают ошибки в зависимости от типа запроса, роута и требований бизнес логики различные ошибки включая стандартные такие как:

<?php

//

throw ModelNotFoundException();
throw ValidationException();
throw MethodNotAllowedHttpException;
throw NotFoundHttpException;
// и т.д.

и приведение их к необходимому ответу, чтобы не изобретать велосипеды.

@sidigi
Copy link

sidigi commented Jan 8, 2022

  1. У тебя есть реквест, у реквеста есть роут, в чём причина не знать какая это версия API если доступен реквест?
  2. В хенделере ловишь нужные ошибки выбрасываешь другие (rethrow) или используя композицию работать с рендер в котором тоже можно смотреть реквест и т.д. и .п. вариантов масса просто выбирай что больше нравится
  3. Есть HttpException - чем не вариант наследоваться от него если что то нужно?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment