Генератор коду для Laravel — на введення RAML, на висновок JSON-API
Можливість створити генератор коду для API, щоб позбавити майбутнє покоління від необхідності постійно створювати одні і ті ж контролери, моделі, роутери, мидлвары, міграції, скелетоны, фільтри, валідації і т. д. вручну (нехай навіть в контексті всіх звичних і зручних фреймворків), здалася мені цікавою.
Вивчив типізацію і тонкощі специфікації raml, мені він сподобався лінійністю і можливістю описувати структуру і типи будь сутності на 1-3 рівня глибиною. Так як на той момент вже був знайомий з Laravel (до нього юзал Yii2, CI але вони були менш популярні), а так само з json-api форматом виводу даних — вся архітектура вляглася в голові зв’язним графом.
Давайте перейдемо до прикладів.
Припустимо, у нас є наступна сутність описана в raml:
ArticleAttributes:
description: Article attributes description
type: object
властивості:
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 raml:generate raml/articles.raml --migrations
то отримаємо наступні сгенеренние об’єкти:
1) Контролер сутності
<?php
namespace ModulesV1HttpControllers;
class ArticleController extends DefaultController
{
}
Він вже вміє GET/POST/PATCH/DELETE, за якими буде ходити в таблицю через модель, міграція для якої так само буде сгенерена. DefaultController завжди доступний розробнику, щоб була можливість впровадити функціонал для всіх контролерів.
2) Модель сутності Article
<?php
namespace ModulesV2Entities;
use IlluminateDatabaseEloquentSoftDeletes;
use rjapiextensionBaseModel;
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 IlluminateSupportFacadesSchema;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateDatabaseMigrationsMigration;
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\2\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/{relation}', 'ArticleController@relations');
Route::post('/article/{id}/relationships/{relation}', 'ArticleController@createRelations');
Route::patch('/article/{id}/relationships/{relation}', 'ArticleController@updateRelations');
Route::delete('/article/{id}/relationships/{relation}', 'ArticleController@deleteRelations');
});
// <<<routes<<<
Створені роуты не тільки для базових запитів, але і відносин (relations) з іншими сутностями, які будуть витягатися 1 запитом і розширення в якості bulk операцій для можливості створювати/обновляти/видаляти дані «пачками».
5) Middleware для перед-обробки/валідації запитів:
<?php
namespace ModulesV2HttpMiddleware;
use rjapiextensionBaseFormRequest;
class ArticleMiddleware 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 raml:generate raml/articles.raml --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',
],
'дерева'=> [ // сутності виводяться у вигляді дерев
'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,
],
],
];
Природно всі конфігурації можна точково вкл/викл при необхідності. Більш детальну інформацію про додаткові можливості генератора коду можете подивитися за посиланнями нижче. Контрибьюты завжди вітаються.
Дякую за увагу, творчих успіхів.
Ресурси статті:
генератор — github.com/RJAPI/raml-json-api
специфікація json-api — jsonapi.org/format
специфікація raml — github.com/raml-org/raml-spec/blob/master/versions/raml-10/raml-10.md