Возможность создать генератор кода для API, чтобы избавить будущее поколение от необходимости постоянно создавать одни и те же контроллеры, модели, роутеры, мидлвары, миграции, скелетоны, фильтры, валидации и т.д. вручную (пусть даже в контексте всем привычных и удобных фреймворков), показалась мне интересной.
Изучил типизацию и тонкости спецификации OpenAPI, мне он понравился линейностью и возможностью описывать структуру и типы любой сущности на 1-3 уровня глубиной. Так как на тот момент уже был знаком с Laravel (до него юзал Yii2, CI но они были менее популярны), а так же с json-api форматом вывода данных — вся архитектура улеглась в голове связным графом.
Давайте перейдем к примерам.
Предположим у нас есть следующая сущность описанная в OAS:
Если мы запустим команду
то получим следующие сгенеренные объекты:
1) Контроллер сущности
Он уже умеет GET/POST/PATCH/DELETE, за которыми будет ходить в таблицу через модель, миграция для которой так же будет сгенерена. DefaultController всегда доступен разработчику, чтобы была возможность внедрить функционал для всех контроллеров.
2) Модель сущности Article
Как видите здесь появились комментарии // >>>props>>> и // >>>methods>>> — они нужны для того, чтобы разделять code-space от user code-space. Есть еще отношения tag/topic — belognsToMany/belongsTo соответственно, которые будут связывать сущность Article с тэгами/топиками, предоставляя возможность получить к ним доступ в relations json-api одним запросом GET или изменять их обновляя статью.
3) Миграция сущности, с поддержкой rollback (рефлексия/атомарность):
Генератор миграций поддерживает все типы индексов (композитные в том числе).
4) Роутер для прокидывания запросов:
Созданы роуты не только для базовых запросов, но и отношений (relations) с другими сущностями, которые будут вытягиваться 1 запросом и расширения в качестве bulk операций для возможности создавать/обновлять/удалять данные «пачками».
5) FormRequest для пред-обработки/валидации запросов:
Здесь все просто — сгенерены правила валидации свойств и отношения для связки основной сущности с сущностями в relations методе.
Наконец самое приятное — примеры запросов:
Выведи статью, подтяни в relations все ее тэги, пагинация на страницу 2, с лимитом 10 и отсортируй по-возрастранию.
Если нам не нужно выводить все поля статьи:
Соритировка по нескольким полям:
Фильтрация (или то, что попадает в условие WHERE):
Пример создания сущности (в данном случае статьи):
Ответ:
Видите ссылку в links->self? вы сразу же можете
Вернул список из объектов, в каждом из котрых тип этого объекта, его id, весь набор атрибутов, далее ссылка на себя, relationships запрошенные в url через include=tag по спецификации нет ограничений на включения отношений, то есть можно так например include=tag,topic,city и все они войдут в блок relationships, а их объекты будут храниться в included.
Если мы хотим получить 1 статью и все ее отношения/связи:
А вот и пример добавления отношений к уже существующей сущности — запрос:
Ответ:
Консольному генератору можно передать доп опции:
Таким образом, вы сообщаете генератору — создай код с миграциями (это вы уже видели) и перегенери код, смерджив его с последними изменениями из сохраненной истории, не затрагивая кастомных участков кода, а только те, что были сгенерены автоматом (то есть как раз те, которые выделены спец-блоками комментариями в коде). Есть возможность указывать шаги назад, напрмер: --merge=9 (откати генерацию на 9 шагов назад), даты генерации кода в прошлом --merge=«2017-07-29 11:35:32».
Один из пользователей библиотеки предложил генерить функциональные тесты для запросов — добавив опцию --tests вы можете нагенерить тесты, чтобы быть уверенными в том, что ваш API работает без ошибок.
Дополнительно можно воспользоваться множеством опций (все они гибко настраиваются через конфигуратор, который лежит в генерируемом модуле — пример: /Modules/V2/Config/config.php):
Естественно все конфигурации можно точечно вкл/выкл при необходимости. Более подробную информацию о дополнительных возможностях генератора кода можете посмотреть по ссылкам ниже. Контрибьюты всегда приветствуются.
Благодарю за внимание, творческих успехов.
Ресурсы статьи:
Изучил типизацию и тонкости спецификации 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,
],
],
];
Естественно все конфигурации можно точечно вкл/выкл при необходимости. Более подробную информацию о дополнительных возможностях генератора кода можете посмотреть по ссылкам ниже. Контрибьюты всегда приветствуются.
Благодарю за внимание, творческих успехов.
Ресурсы статьи: