Генератор кода для Laravel — на ввод OAS, на вывод JSON-API

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

    Изучил типизацию и тонкости спецификации OpenAPI, мне он понравился линейностью и возможностью описывать структуру и типы любой сущности на 1-3 уровня глубиной. Так как на тот момент уже был знаком с Laravel (до него юзал Yii2, CI но они были менее популярны), а так же с json-api форматом вывода данных — вся архитектура улеглась в голове связным графом.



    Давайте перейдем к примерам.

    Предположим у нас есть следующая сущность описанная в OAS:

      ArticleAttributes:
        description: Article attributes description
        type: object
        properties:
          title:
            required: true
            type: string
            minLength: 16
            maxLength: 256
            facets:
              index:
                idx_title: index
          description:
            required: true
            type: string
            minLength: 32
            maxLength: 1024
            facets:
              spell_check: true
              spell_language: en
          url:
            required: false
            type: string
            minLength: 16
            maxLength: 255
            facets:
              index:
                idx_url: unique
          show_in_top:
            description: Show at the top of main page
            required: false
            type: boolean
          status:
            description: The state of an article
            enum: ["draft", "published", "postponed", "archived"]
            facets:
              state_machine:
                initial: ['draft']
                draft: ['published']
                published: ['archived', 'postponed']
                postponed: ['published', 'archived']
                archived: []
          topic_id:
            description: ManyToOne Topic relationship
            required: true
            type: integer
            minimum: 1
            maximum: 6
            facets:
              index:
                idx_fk_topic_id: foreign
                references: id
                on: topic
                onDelete: cascade
                onUpdate: cascade
          rate:
            type: number
            minimum: 3
            maximum: 9
            format: double
          date_posted:
            type: date-only
          time_to_live:
            type: time-only
          deleted_at:
            type: datetime
    

    Если мы запустим команду

    php artisan api:generate oas/openapi.yaml --migrations  
    

    то получим следующие сгенеренные объекты:

    1) Контроллер сущности

    <?php
    namespace Modules\V1\Http\Controllers;
    
    class ArticleController extends DefaultController 
    {
    }
    

    Он уже умеет GET/POST/PATCH/DELETE, за которыми будет ходить в таблицу через модель, миграция для которой так же будет сгенерена. DefaultController всегда доступен разработчику, чтобы была возможность внедрить функционал для всех контроллеров.

    2) Модель сущности Article

    <?php
    namespace Modules\V2\Entities;
    
    use Illuminate\Database\Eloquent\SoftDeletes;
    use rjapi\extension\BaseModel;
    
    class Article extends BaseModel 
    {
        use SoftDeletes;
    
        // >>>props>>>
        protected $dates = ['deleted_at'];
        protected $primaryKey = 'id';
        protected $table = 'article';
        public $timestamps = false;
        public $incrementing = false;
        // <<<props<<<
        // >>>methods>>>
    
        public function tag() 
        {
            return $this->belongsToMany(Tag::class, 'tag_article');
        }
        public function topic() 
        {
            return $this->belongsTo(Topic::class);
        }
        // <<<methods<<<
    }
    


    Как видите здесь появились комментарии // >>>props>>> и // >>>methods>>> — они нужны для того, чтобы разделять code-space от user code-space. Есть еще отношения tag/topic — belognsToMany/belongsTo соответственно, которые будут связывать сущность Article с тэгами/топиками, предоставляя возможность получить к ним доступ в relations json-api одним запросом GET или изменять их обновляя статью.

    3) Миграция сущности, с поддержкой rollback (рефлексия/атомарность):

    <?php
    use Illuminate\Support\Facades\Schema;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Database\Migrations\Migration;
    
    class CreateArticleTable extends Migration 
    {
        public function up() 
        {
            Schema::create('article', function(Blueprint $table) {
                $table->bigIncrements('id');
                $table->string('title', 256);
                $table->index('title', 'idx_title');
                $table->string('description', 1024);
                $table->string('url', 255);
                $table->unique('url', 'idx_url');
                // Show at the top of main page
                $table->unsignedTinyInteger('show_in_top');
                $table->enum('status', ["draft","published","postponed","archived"]);
                // ManyToOne Topic relationship
                $table->unsignedInteger('topic_id');
                $table->foreign('topic_id', 'idx_fk_topic_id')->references('id')->on('topic')->onDelete('cascade')->onUpdate('cascade');
                $table->timestamps();
            });
        }
    
        public function down() 
        {
            Schema::dropIfExists('article');
        }
    
    }
    

    Генератор миграций поддерживает все типы индексов (композитные в том числе).

    4) Роутер для прокидывания запросов:

    // >>>routes>>>
    // Article routes
    Route::group(['prefix' => 'v2', 'namespace' => 'Modules\\V2\\Http\\Controllers'], function()
    {
        // bulk routes
        Route::post('/article/bulk', 'ArticleController@createBulk');
        Route::patch('/article/bulk', 'ArticleController@updateBulk');
        Route::delete('/article/bulk', 'ArticleController@deleteBulk');
        // basic routes
        Route::get('/article', 'ArticleController@index');
        Route::get('/article/{id}', 'ArticleController@view');
        Route::post('/article', 'ArticleController@create');
        Route::patch('/article/{id}', 'ArticleController@update');
        Route::delete('/article/{id}', 'ArticleController@delete');
        // relation routes
        Route::get('/article/{id}/relationships/{relations}', 'ArticleController@relations');
        Route::post('/article/{id}/relationships/{relations}', 'ArticleController@createRelations');
        Route::patch('/article/{id}/relationships/{relations}', 'ArticleController@updateRelations');
        Route::delete('/article/{id}/relationships/{relations}', 'ArticleController@deleteRelations');
    });
    // <<<routes<<<
    

    Созданы роуты не только для базовых запросов, но и отношений (relations) с другими сущностями, которые будут вытягиваться 1 запросом и расширения в качестве bulk операций для возможности создавать/обновлять/удалять данные «пачками».

    5) FormRequest для пред-обработки/валидации запросов:

    <?php
    namespace Modules\V1\Http\Requests;
    
    use rjapi\extension\BaseFormRequest;
    
    class ArticleFormRequest extends BaseFormRequest 
    {
        // >>>props>>>
        public $id = null;
        // Attributes
        public $title = null;
        public $description = null;
        public $url = null;
        public $show_in_top = null;
        public $status = null;
        public $topic_id = null;
        public $rate = null;
        public $date_posted = null;
        public $time_to_live = null;
        public $deleted_at = null;
        // <<<props<<<
    
        // >>>methods>>>
        public function authorize(): bool 
        {
            return true;
        }
    
        public function rules(): array 
        {
            return [
                'title' => 'required|string|min:16|max:256|',
                'description' => 'required|string|min:32|max:1024|',
                'url' => 'string|min:16|max:255|',
                    // Show at the top of main page
                'show_in_top' => 'boolean',
                    // The state of an article
                'status' => 'in:draft,published,postponed,archived|',
                    // ManyToOne Topic relationship
                'topic_id' => 'required|integer|min:1|max:6|',
                'rate' => '|min:3|max:9|',
                'date_posted' => '',
                'time_to_live' => '',
                'deleted_at' => '',
            ];
        }
    
        public function relations(): array 
        {
            return [
                'tag',
                'topic',
            ];
        }
        // <<<methods<<<
    }
    

    Здесь все просто — сгенерены правила валидации свойств и отношения для связки основной сущности с сущностями в relations методе.

    Наконец самое приятное — примеры запросов:

    http://example.com/v1/article?include=tag&page=2&limit=10&sort=asc
    

    Выведи статью, подтяни в relations все ее тэги, пагинация на страницу 2, с лимитом 10 и отсортируй по-возрастранию.

    Если нам не нужно выводить все поля статьи:

    http://example.com/v1/article/1?include=tag&data=["title", "description"]
    

    Соритировка по нескольким полям:

    http://example.com/v1/article/1?include=tag&order_by={"title":"asc", "created_at":"desc"}
    

    Фильтрация (или то, что попадает в условие WHERE):

    http://example.com/v1/article?include=tag&filter=[["updated_at", ">", "2018-01-03 12:13:13"], ["updated_at", "<", "2018-09-03 12:13:15"]]
    

    Пример создания сущности (в данном случае статьи):

    POST http://laravel.loc/v1/article
    {
      "data": {
        "type":"article",
        "attributes": {
          "title":"Foo bar Foo bar Foo bar Foo bar",
          "description":"description description description description description",
          "fake_attr": "attr",
          "url":"title title bla bla bla",
          "show_in_top":1
        }
      }
    }
    

    Ответ:

    {
        "data": {
            "type": "article",
            "id": "1",
            "attributes": {
                "title": "Foo bar Foo bar Foo bar Foo bar",
                "description": "description description description description description",
                "url": "title title bla bla bla",
                "show_in_top": 1
            },
            "links": {
                "self": "laravel.loc/article/1"
            }
        }
    }
    

    Видите ссылку в links->self? вы сразу же можете
    GET http://laravel.loc/article/1
    или сохранить ее для использования в дальнейшем.

    GET http://laravel.loc/v1/article?include=tag&filter=[["updated_at", ">", "2017-01-03 12:13:13"], ["updated_at", "<", "2019-01-03 12:13:15"]]
    {
        "data": [
            {
                "type": "article",
                "id": "1",
                "attributes": {
                    "title": "Foo bar Foo bar Foo bar Foo bar 1",
                    "description": "The quick brovn fox jumped ower the lazy dogg",
                    "url": "http://example.com/articles_feed 1",
                    "show_in_top": 0,
                    "status": "draft",
                    "topic_id": 1,
                    "rate": 5,
                    "date_posted": "2018-02-12",
                    "time_to_live": "10:11:12"
                },
                "links": {
                    "self": "laravel.loc/article/1"
                },
                "relationships": {
                    "tag": {
                        "links": {
                            "self": "laravel.loc/article/1/relationships/tag",
                            "related": "laravel.loc/article/1/tag"
                        },
                        "data": [
                            {
                                "type": "tag",
                                "id": "1"
                            }
                        ]
                    }
                }
            }
        ],
        "included": [
            {
                "type": "tag",
                "id": "1",
                "attributes": {
                    "title": "Tag 1"
                },
                "links": {
                    "self": "laravel.loc/tag/1"
                }
            }
        ]
    }
    

    Вернул список из объектов, в каждом из котрых тип этого объекта, его id, весь набор атрибутов, далее ссылка на себя, relationships запрошенные в url через include=tag по спецификации нет ограничений на включения отношений, то есть можно так например include=tag,topic,city и все они войдут в блок relationships, а их объекты будут храниться в included.

    Если мы хотим получить 1 статью и все ее отношения/связи:

    GET http://laravel.loc/v1/article/1?include=tag&data=["title", "description"]
    {
        "data": {
            "type": "article",
            "id": "1",
            "attributes": {
                "title": "Foo bar Foo bar Foo bar Foo bar 123456",
                "description": "description description description description description 123456",
            },
            "links": {
                "self": "laravel.loc/article/1"
            },
            "relationships": {
                "tag": {
                    "links": {
                        "self": "laravel.loc/article/1/relationships/tag",
                        "related": "laravel.loc/article/1/tag"
                    },
                    "data": [
                        {
                            "type": "tag",
                            "id": "3"
                        },
                        {
                            "type": "tag",
                            "id": "1"
                        },
                        {
                            "type": "tag",
                            "id": "2"
                        }
                    ]
                }
            }
        },
        "included": [
            {
                "type": "tag",
                "id": "3",
                "attributes": {
                    "title": "Tag 4"
                },
                "links": {
                    "self": "laravel.loc/tag/3"
                }
            },
            {
                "type": "tag",
                "id": "1",
                "attributes": {
                    "title": "Tag 2"
                },
                "links": {
                    "self": "laravel.loc/tag/1"
                }
            },
            {
                "type": "tag",
                "id": "2",
                "attributes": {
                    "title": "Tag 3"
                },
                "links": {
                    "self": "laravel.loc/tag/2"
                }
            }
        ]
    }
    


    А вот и пример добавления отношений к уже существующей сущности — запрос:

    PATCH http://laravel.loc/v1/article/1/relationships/tag
    {
      "data": {
        "type":"article",
        "id":"1",
        "relationships": {
          "tag": {
              "data": [{ "type": "tag", "id": "2" },{ "type": "tag", "id": "3" }]
          }
        }    
      }
    }
    

    Ответ:

    {
        "data": {
            "type": "article",
            "id": "1",
            "attributes": {
                "title": "Foo bar Foo bar Foo bar Foo bar 1",
                "description": "The quick brovn fox jumped ower the lazy dogg",
                "url": "http://example.com/articles_feed 1",
                "show_in_top": 0,
                "status": "draft",
                "topic_id": 1,
                "rate": 5,
                "date_posted": "2018-02-12",
                "time_to_live": "10:11:12"
            },
            "links": {
                "self": "laravel.loc/article/1"
            },
            "relationships": {
                "tag": {
                    "links": {
                        "self": "laravel.loc/article/1/relationships/tag",
                        "related": "laravel.loc/article/1/tag"
                    },
                    "data": [
                        {
                            "type": "tag",
                            "id": "2"
                        },
                        {
                            "type": "tag",
                            "id": "3"
                        }
                    ]
                }
            }
        },
        "included": [
            {
                "type": "tag",
                "id": "2",
                "attributes": {
                    "title": "Tag 2"
                },
                "links": {
                    "self": "laravel.loc/tag/2"
                }
            },
            {
                "type": "tag",
                "id": "3",
                "attributes": {
                    "title": "Tag 3"
                },
                "links": {
                    "self": "laravel.loc/tag/3"
                }
            }
        ]
    }
    

    Консольному генератору можно передать доп опции:

    php artisan api:generate oas/openapi.yaml --migrations --regenerate --merge=last
    

    Таким образом, вы сообщаете генератору — создай код с миграциями (это вы уже видели) и перегенери код, смерджив его с последними изменениями из сохраненной истории, не затрагивая кастомных участков кода, а только те, что были сгенерены автоматом (то есть как раз те, которые выделены спец-блоками комментариями в коде). Есть возможность указывать шаги назад, напрмер: --merge=9 (откати генерацию на 9 шагов назад), даты генерации кода в прошлом --merge=«2017-07-29 11:35:32».

    Один из пользователей библиотеки предложил генерить функциональные тесты для запросов — добавив опцию --tests вы можете нагенерить тесты, чтобы быть уверенными в том, что ваш API работает без ошибок.

    Дополнительно можно воспользоваться множеством опций (все они гибко настраиваются через конфигуратор, который лежит в генерируемом модуле — пример: /Modules/V2/Config/config.php):

    <?php
    return [
        'name' => 'V2',
        'query_params'=> [ // параметры запросов используемые по-умолчанию
            'limit' => 15,
            'sort' => 'desc',
            'access_token' => 'db7329d5a3f381875ea6ce7e28fe1ea536d0acaf',
        ],
        'trees'=> [ // сущности выводимые в виде деревьев 
            'menu' => true,
        ],
        'jwt'=> [ // jwt авторизация
            'enabled' => true,
            'table' => 'user',
            'activate' => 30,
            'expires' => 3600,
        ],
        'state_machine'=> [ // finite state machine
            'article'=> [
                'status'=> [
                    'enabled' => true,
                    'states'=> [
                        'initial' => ['draft'],
                        'draft' => ['published'],
                        'published' => ['archived', 'postponed'],
                        'postponed' => ['published', 'archived'],
                        'archived' => [''],
                    ],
                ],
            ],
        ],
        'spell_check'=> [ // обработка спелчекером орфографии текста конкретных полей
            'article'=> [
                'description'=> [
                    'enabled' => true,
                    'language' => 'en',
                ],
            ],
        ],
        'bit_mask'=> [ // битовая маска для поля permissions (выводит true/false вкл/выкл для каждого)
            'user'=> [
                'permissions'=> [
                    'enabled' => true,
                    'flags'=> [
                        'publisher' => 1,
                        'editor' => 2,
                        'manager' => 4,
                        'photo_reporter' => 8,
                        'admin' => 16,
                    ],
                ],
            ],
        ],
        'cache'=> [ // какие сущности кэшировать
            'tag'=> [
                'enabled' => false, // кэш для сущности tag отключен
                'stampede_xfetch' => true,
                'stampede_beta' => 1.1,
                'ttl' => 3600,
            ],
            'article'=> [
                'enabled' => true, // кэш для сущности article включен
                'stampede_xfetch' => true,
                'stampede_beta' => 1.5,
                'ttl' => 300,
            ],
        ],
    ];
    

    Естественно все конфигурации можно точечно вкл/выкл при необходимости. Более подробную информацию о дополнительных возможностях генератора кода можете посмотреть по ссылкам ниже. Контрибьюты всегда приветствуются.

    Благодарю за внимание, творческих успехов.

    Ресурсы статьи:

    Поделиться публикацией

    Похожие публикации

    Комментарии 7
      0

      Raml классный, но почему не swagger?

        0
        Откровенно говоря, думал заюзать swagger, но как мне показалось у них упор был больше сделан все-таки на схемы, а мне нужен был именно yaml. Более того, описание типов и facets в raml показались мне простыми и понятными для восприятия. Учитывая популярность swagger есть возможность его конвертнуть в raml с помощью утилит, которые указаны в readme.
          0
          Доброго времени суток, благодарю за напоминание о swagger, собственно теперь OAS = swagger + raml, т.е. 2 группы разработчиков спецификаций объединились и организовали работу над OpenAPI Specification, она и вошла в мою разработку как новый и основной формат ввода данных.
          0

          ИМХО, вы мидлварей назвали совершенно не ту вещь. Или наоборот — ваш валидатор обозвали мидлварей.

            0
            Довольно странное явление, возможно когда-то на уровне модулей метод rules отработал в Middleware, на офф сайте laravel пишут, что правила должны быть размещены в Form Request — laravel.com/docs/5.7/validation#form-request-validation, возможно (а может и обязательно) мне придется рефакторить правила, спасибо за анализ.
              0

              Я имею в виду, что у Вас по сути же Form Request (п. 5), а вы его называете Middleware. :)

            0
            Да вы правы, придется переименовать и генерить их в —
            Modules\V1\Http\Requests
            , спасибо.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое