Pull to refresh

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

Reading time9 min
Views6.8K
Возможность создать генератор кода для 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,
        ],
    ],
];

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

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

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

Tags:
Hubs:
+5
Comments7

Articles

Change theme settings