チュートリアル2

このチュートリアルでは、以下のツールを用いて標準に基づいた高品質なREST(ハイパーメディア)アプリケーション開発を学びます。

  • JSONのスキーマを定義し、バリデーションやドキュメンテーションに利用する JSON Schema
  • ハイパーメディアタイプ HAL (Hypertext Application Language)
  • CakePHPが開発しているDBマイグレーションツール Phinx
  • PHPのインターフェイスとSQL文実行を束縛する Ray.MediaQuery

tutorial2のコミットを参考にして進めましょう。

プロジェクト作成

プロジェクトスケルトンを作成します。

composer create-project bear/skeleton MyVendor.Ticket

vendor名をMyVendorに、project名をTicketとして入力します。

マイグレーション

Phinxをインストールします。

composer require --dev robmorgan/phinx

プロジェクトルートフォルダの.env.distファイルにDB接続情報を記述します。

TKT_DB_HOST=127.0.0.1:3306
TKT_DB_NAME=ticket
TKT_DB_USER=root
TKT_DB_PASS=''
TKT_DB_SLAVE=''
TKT_DB_DSN=mysql:host=${TKT_DB_HOST}

.env.distファイルはこのようにして、実際の接続情報は.envに記述しましょう。1

次にPhinxが利用するフォルダを作成します。

mkdir -p var/phinx/migrations
mkdir var/phinx/seeds

.envの接続情報をPhinxで利用するためにvar/phinx/phinx.phpを設置します。

<?php
use BEAR\Dotenv\Dotenv;

require_once dirname(__DIR__, 2) . '/vendor/autoload.php';

(new Dotenv())->load(dirname(__DIR__, 2));

$development = new PDO(getenv('TKT_DB_DSN'), getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
$test = new PDO(getenv('TKT_DB_DSN') . '_test', getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
return [
    'paths' => [
        'migrations' => __DIR__ . '/migrations',
    ],
    'environments' => [
        'development' => [
            'name' => $development->query("SELECT DATABASE()")->fetchColumn(),
            'connection' => $development
        ],
        'test' => [
            'name' => $test->query("SELECT DATABASE()")->fetchColumn(),
            'connection' => $test
        ]
    ]
];

setupスクリプト

データベース作成やマイグレーションを簡単に実行できるように、bin/setup.phpを編集します。

<?php
use BEAR\Dotenv\Dotenv;

require_once dirname(__DIR__) . '/vendor/autoload.php';

(new Dotenv())->load(dirname(__DIR__));

chdir(dirname(__DIR__));
passthru('rm -rf var/tmp/*');
passthru('chmod 775 var/tmp');
passthru('chmod 775 var/log');
// db
$pdo = new \PDO('mysql:host=' . getenv('TKT_DB_HOST'), getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
$pdo->exec('CREATE DATABASE IF NOT EXISTS ' . getenv('TKT_DB_NAME'));
$pdo->exec('DROP DATABASE IF EXISTS ' . getenv('TKT_DB_NAME') . '_test');
$pdo->exec('CREATE DATABASE ' . getenv('TKT_DB_NAME') . '_test');
passthru('./vendor/bin/phinx migrate -c var/phinx/phinx.php -e development');
passthru('./vendor/bin/phinx migrate -c var/phinx/phinx.php -e test');

次にticketテーブルを作成するためにマイグレーションクラスを作成します。

./vendor/bin/phinx create Ticket -c var/phinx/phinx.php
Phinx by CakePHP - https://phinx.org.

...
created var/phinx/migrations/20210520124501_ticket.php

var/phinx/migrations/{current_date}_ticket.phpを編集してchange()メソッドを実装します。

<?php
use Phinx\Migration\AbstractMigration;

final class Ticket extends AbstractMigration
{
    public function change(): void
    {
        $table = $this->table('ticket', ['id' => false, 'primary_key' => ['id']]);
        $table->addColumn('id', 'uuid', ['null' => false])
            ->addColumn('title', 'string')
            ->addColumn('date_created', 'datetime')
            ->create();
    }
}

.env.distファイルを以下のように変更します。

 TKT_DB_USER=root
 TKT_DB_PASS=
 TKT_DB_SLAVE=
-TKT_DB_DSN=mysql:host=${TKT_DB_HOST}
+TKT_DB_DSN=mysql:host=${TKT_DB_HOST};dbname=${TKT_DB_NAME}

準備が完了したので、セットアップコマンドを実行してテーブルを作成します。

composer setup
> php bin/setup.php
...
All Done. Took 0.0248s

テーブルが作成されました。次回からこのプロジェクトのデータベース環境を整えるにはcomposer setupを実行するだけで行えます。

マイグレーションクラスの記述について詳しくはPhinxのマニュアル:マイグレーションを書くをご覧ください。

モジュール

モジュールをComposerでインストールします。

composer require ray/identity-value-module ray/media-query -w

AppModuleでパッケージをインストールします。

src/Module/AppModule.php

<?php
namespace MyVendor\Ticket\Module;

use BEAR\Dotenv\Dotenv;
use BEAR\Package\AbstractAppModule;
use BEAR\Package\PackageModule;

use BEAR\Resource\Module\JsonSchemaModule;
use Ray\AuraSqlModule\AuraSqlModule;
use Ray\IdentityValueModule\IdentityValueModule;
use Ray\MediaQuery\DbQueryConfig;
use Ray\MediaQuery\MediaQueryModule;
use Ray\MediaQuery\Queries;
use function dirname;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
        $this->install(
            new AuraSqlModule(
                (string) getenv('TKT_DB_DSN'),
                (string) getenv('TKT_DB_USER'),
                (string) getenv('TKT_DB_PASS'),
                (string) getenv('TKT_DB_SLAVE')
            )
        );
        $this->install(
            new MediaQueryModule(
                Queries::fromDir($this->appMeta->appDir . '/src/Query'), [
                    new DbQueryConfig($this->appMeta->appDir . '/var/sql'),
                ]
            )
        );
        $this->install(new IdentityValueModule());
        $this->install(
            new JsonSchemaModule(
                $this->appMeta->appDir . '/var/schema/response',
                $this->appMeta->appDir . '/var/schema/request'
            )
        );
        $this->install(new PackageModule());
    }
}

SQL

チケット用の3つのSQLをvar/sqlに保存します。2

var/sql/ticket_add.sql

/* ticket add */
INSERT INTO ticket (id, title, date_created)
VALUES (:id, :title, :dateCreated);

var/sql/ticket_list.sql

/* ticket list */
SELECT id, title, date_created
  FROM ticket
 LIMIT 3;

var/sql/ticket_item.sql

/* ticket item */
SELECT id, title, date_created
  FROM ticket
 WHERE id = :id;

作成時に単体でそのSQLが動作するか確認しましょう。

例えば、PHPStormにはデータベースツールのDataGripが含まれていて、コード補完やSQLのリファクタリングなどSQL開発に必要な機能が揃っています。 DB接続などのセットアップを行えば、SQLファイルをIDEで直接実行できます。34

JSON Schema

Ticket(チケットアイテム)、Tickets(チケットアイテムリスト)のリソース表現をJSON Schemaで定義し、それぞれ保存します。

var/schema/response/ticket.json

{
  "$id": "ticket.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Ticket",
  "type": "object",
  "required": ["id", "title", "date_created"],
  "properties": {
    "id": {
      "description": "The unique identifier for a ticket.",
      "type": "string",
      "maxLength": 64
    },
    "title": {
      "description": "The title of the ticket.",
      "type": "string",
      "maxLength": 255
    },
    "date_created": {
      "description": "The date and time that the ticket was created.",
      "type": "string",
      "format": "date-time"
    }
  }
}

var/schema/response/tickets.json

Ticketsはticketの配列です。

{
  "$id": "tickets.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Tickets",
  "type": "object",
  "required": ["tickets"],
  "properties": {
    "tickets": {
      "type": "array",
      "items": { "$ref": "./ticket.json" }
    }
  }
}
  • $id - ファイル名を指定しますが、公開する場合はURLを記述します。
  • title - オブジェクト名としてAPIドキュメントで扱われます。
  • examples - 適宜、例を指定しましょう。オブジェクト全体のものも指定できます。

PHPStormではエディタの右上に緑色のチェックが出ていて問題がないことが分かります。スキーマ作成時にスキーマ自身もバリデートしましょう。

クエリーインターフェイス

インフラストラクチャへのアクセスを抽象化したPHPのインターフェイスを作成します。

  • Ticketリソースを読み出す TicketQueryInterface
  • Ticketリソースを作成する TicketCommandInterface

src/Query/TicketQueryInterface.php

<?php

namespace MyVendor\Ticket\Query;

use MyVendor\Ticket\Entity\Ticket;
use Ray\MediaQuery\Annotation\DbQuery;

interface TicketQueryInterface
{
    #[DbQuery('ticket_item')]
    public function item(string $id): Ticket|null;

    /** @return array<Ticket> */
    #[DbQuery('ticket_list')]
    public function list(): array;
}

src/Query/TicketCommandInterface.php

<?php

namespace MyVendor\Ticket\Query;

use DateTimeInterface;
use Ray\MediaQuery\Annotation\DbQuery;

interface TicketCommandInterface
{
    #[DbQuery('ticket_add')]
    public function add(string $id, string $title, DateTimeInterface $dateCreated = null): void;
}

#[DbQuery]アトリビュートでSQL文を指定します。

このインターフェイスに対する実装を用意する必要はありません。指定されたSQLのクエリを行うオブジェクトが自動生成されます。

インターフェイスを副作用が発生するコマンドまたは値を返すクエリーという2つの関心に分けていますが、リポジトリパターンのように1つにまとめたり、ADRパターンのように1インターフェイス1メソッドにしても構いません。アプリケーション設計者が方針を決定します。

エンティティ

メソッドの返り値にarrayを指定すると、データベースの結果はそのまま連想配列として得られますが、メソッドの返り値にエンティティの型を指定すると、その型にハイドレーションされます。

#[DbQuery('ticket_item')]
public function item(string $id): array // 配列が得られる
#[DbQuery('ticket_item')]
public function item(string $id): Ticket|null; // Ticketエンティティが得られる

複数行(row_list)の時は/** @return array<Ticket>*/とPHPDocでTicketが配列で返ることを指定します。

/** @return array<Ticket> */
#[DbQuery('ticket_list')]
public function list(): array; // Ticketエンティティの配列が得られる

各行の値は名前引数でコンストラクタに渡されます。5

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Entity;

class Ticket
{
    public function __construct(
        public readonly string $id,
        public readonly string $title,
        public readonly string $dateCreated
    ) {}
}

リソース

リソースクラスはクエリーインターフェイスに依存します。

ticketリソース

ticketリソースをsrc/Resource/App/Ticket.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\JsonSchema;
use BEAR\Resource\ResourceObject;
use MyVendor\Ticket\Query\TicketQueryInterface;

class Ticket extends ResourceObject
{
    public function __construct(
        private TicketQueryInterface $query
    ){}
    
    #[JsonSchema("ticket.json")]
    public function onGet(string $id = ''): static
    {
        $this->body = (array) $this->query->item($id);

        return $this;
    }
}

アトリビュート#[JsonSchema]onGet()で出力される値がticket.jsonのスキーマで定義されていることを表します。 AOPによってリクエスト毎にバリデートされます。

シードを入力してリソースをリクエストしてみましょう。6

% mysql -u root -e "INSERT INTO ticket (id, title, date_created) VALUES ('1', 'foo', '1970-01-01 00:00:00')" ticket
% php bin/app.php get '/ticket?id=1'
200 OK
Content-Type: application/hal+json

{
    "id": "1",
    "title": "foo",
    "date_created": "1970-01-01 00:00:00",
    "_links": {
        "self": {
            "href": "/ticket?id=1"
        }
    }
}

Ray.MediaQuery

Ray.MediaQueryを使えば、ボイラープレートとなりやすい実装クラスをコーディングすることなく、インターフェイスから自動生成されたSQL実行オブジェクトがインジェクトされます。7

SQL文には;で区切った複数のSQL文を記述することができ、複数のSQLに同じパラメータが名前でバインドされます。SELECT以外のクエリではトランザクションも実行されます。

利用クラスはインターフェイスにしか依存していないので、動的にSQLを生成したい場合にはRay.MediaQueryの代わりにクエリービルダーをインジェクトしたSQL実行クラスで組み立てたSQLを実行すれば良いでしょう。 詳しくはマニュアルのデータベースをご覧ください。

埋め込みリンク

通常Webサイトのページは複数のリソースを内包します。例えばブログの記事ページであれば、記事以外にもおすすめや広告、カテゴリーリンクなどが含まれるかもしれません。 それらをクライアントがバラバラに取得する代わりに、独立したリソースとして埋め込みリンクで1つのリソースに束ねることができます。

HTMLとそこに記述される<img>タグをイメージしてください。どちらも独立したURLを持ちますが、画像リソースがHTMLリソースに埋め込まれていてHTMLを取得するとHTML内に画像が表示されます。 これらはハイパーメディアタイプのEmbedding links (LE)と呼ばれるもので、埋め込まれるリソースがリンクされています。

ticketリソースにprojectリソースを埋め込んでみましょう。Projectクラスを用意します。

src/Resource/App/Project.php

<?php

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\ResourceObject;

class Project extends ResourceObject
{
    public function onGet(): static
    {
        $this->body = ['title' => 'Project A'];

        return $this;
    }
}

Ticketリソースにアトリビュート#[Embed]を追加します。

+use BEAR\Resource\Annotation\Embed;
+use BEAR\Resource\Request;
+
+   #[Embed(src: '/project', rel: 'project')]
    #[JsonSchema("ticket.json")]
    public function onGet(string $id = ''): static
    {
+        assert($this->body['project'] instanceof Request);
-        $this->body = (array) $this->query->item($id);
+        $this->body += (array) $this->query->item($id);

#[Embed]アトリビュートのsrcで指定されたリソースのリクエストがbodyプロパティのrelキーにインジェクトされ、レンダリング時に遅延評価され文字列表現になります。

例を簡単にするためにこの例ではパラメータを渡していませんが、メソッド引数が受け取った値をURIテンプレートを使って渡すこともできますし、インジェクトされたリクエストのパラメータを修正、追加することもできます。 詳しくはリソースをご覧ください。

もう一度リクエストすると_embeddedというプロパティにprojectリソースの状態が追加されているのが分かります。

% php bin/app.php get '/ticket?id=1'
{
    "id": "1",
    "title": "foo",
    "date_created": "1970-01-01 00:00:00",
+    "_embedded": {
+        "project": {
+            "title": "Project A"
+        }
    }
}

埋め込みリソースはREST APIの重要な機能です。コンテンツにツリー構造を与えHTTPリクエストコストを削減します。 情報が他の何の情報を含んでいるかはドメインの関心事です。クライアントで都度取得するのではなく、その関心事はサーバーサイドのLE(埋め込みリンク)でうまく表すことができます。8

ticketsリソース

POSTで作成、GETでチケットリストが取得できるticketsリソースをsrc/Resource/App/Tickets.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\JsonSchema;
use BEAR\Resource\Annotation\Link;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\ResponseHeader;
use Koriym\HttpConstants\StatusCode;
use MyVendor\Ticket\Query\TicketCommandInterface;
use MyVendor\Ticket\Query\TicketQueryInterface;
use Ray\IdentityValueModule\UuidInterface;
use function uri_template;

class Tickets extends ResourceObject
{
    public function __construct(
        private TicketQueryInterface $query,
        private TicketCommandInterface $command,
        private UuidInterface $uuid
    ){}

    #[Link(rel: "doPost", href: '/tickets')]
    #[Link(rel: "goTicket", href: '/ticket{?id}')]
    #[JsonSchema("tickets.json")]
    public function onGet(): static
    {
        $this->body = [
            'tickets' => $this->query->list()
        ];
        
        return $this;
    }

    #[Link(rel: "goTickets", href: '/tickets')]
    public function onPost(string $title): static
    {
        $id = (string) $this->uuid;
        $this->command->add($id, $title);

        $this->code = StatusCode::CREATED;
        $this->headers[ResponseHeader::LOCATION] = uri_template('/ticket{?id}', ['id' => $id]);

        return $this;
    }
}

インジェクトされた$uuidを文字列にキャストすることでUUIDの文字列表現が得られます。また#[Link]は他のリソース(アプリケーション状態)へのリンクを表します。

add()メソッドで現在時刻を渡していないことに注目してください。 値が渡されない場合、nullではなく、MySQLの現在時刻文字列がSQLにバインドされます。 なぜならDateTimeInterfaceに束縛された現在時刻DateTimeオブジェクトの文字列表現(現在時刻文字列)がSQLに束縛されているからです。

public function add(string $id, string $title, DateTimeInterface $dateCreated = null): void;

SQL内部でNOW()とハードコーディングすることや、メソッドに毎回現在時刻を渡す手間を省きます。 DateTimeオブジェクトを渡すこともできますし、テストのコンテキストでは固定のテスト用時刻を束縛することもできます。

このようにクエリーの引数にインターフェイスを指定するとそのオブジェクトをDIを使って取得し、その文字列表現がSQLに束縛されます。 例えばログインユーザーIDなどを束縛してアプリケーションで横断的に利用できます。9

ハイパーメディアAPIテスト

REST(Representational State Transfer)という用語は、2000年にRoy Fieldingが博士論文の中で紹介、定義したもので「適切に設計されたWebアプリケーションの動作」をイメージさせることを目的としています。 それはWebリソースのネットワーク(仮想ステートマシン)であり、ユーザーはリソース識別子(URL)と、GETやPOSTなどのリソース操作(アプリケーションステートの遷移)を選択することで、アプリケーションを進行させ、その結果、次のリソースの表現(次のアプリケーションステート)がエンドユーザーに転送されて使用されるというものです。

Wikipedia (REST)

RESTアプリケーションでは次のアクションがURLとしてサービスから提供され、クライアントはそれを選択します。

HTML Webアプリケーションは完全にRESTfulです。その操作は「(aタグなどで)提供されたURLに遷移する」または「提供されたフォームを埋めて送信する」のいずれかでしかありません。

REST APIのテストも同様に記述します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Hypermedia;

use BEAR\Resource\ResourceInterface;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\ResponseHeader;
use MyVendor\Ticket\Injector;
use MyVendor\Ticket\Query\TicketQueryInterface;
use PHPUnit\Framework\TestCase;
use Ray\Di\InjectorInterface;
use function json_decode;

class WorkflowTest extends TestCase
{
    protected ResourceInterface $resource;
    protected InjectorInterface $injector;

    protected function setUp(): void
    {
        $this->injector = Injector::getInstance('hal-api-app');
        $this->resource = $this->injector->getInstance(ResourceInterface::class);
    }

    public function testIndex(): static
    {
        $index = $this->resource->get('/');
        $this->assertSame(200, $index->code);

        return $index;
    }

    /**
     * @depends testIndex
     */
    public function testGoTickets(ResourceObject $response): static
    {
        $json = (string) $response;
        $href = json_decode($json)->_links->{'goTickets'}->href;
        $ro = $this->resource->get($href);
        $this->assertSame(200, $ro->code);

        return $ro;
    }

    /**
     * @depends testGoTickets
     */
    public function testDoPost(ResourceObject $response): static
    {
        $json = (string) $response;
        $href = json_decode($json)->_links->{'doPost'}->href;
        $ro = $this->resource->post($href, ['title' => 'title1']);
        $this->assertSame(201, $ro->code);

        return $ro;
    }

    /**
     * @depends testDoPost
     */
    public function testGoTicket(ResourceObject $response): static
    {
        $href = $response->headers[ResponseHeader::LOCATION];
        $ro = $this->resource->get($href);
        $this->assertSame(200, $ro->code);

        return $ro;
    }
}

起点となるルートページも必要です。

src/Resource/App/Index.php

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\Link;
use BEAR\Resource\ResourceObject;

class Index extends ResourceObject
{
    #[Link(rel: 'goTickets', href: '/tickets')]
    public function onGet(): static
    {
        return $this;
    }
}
  • setUpではリソースクライアントを生成し、testIndex()でルートページにアクセスしています。
  • レスポンスを受け取ったtestGoTickets()メソッドではそのレスポンスオブジェクトをJSON表現にして、次のチケット一覧を取得するリンクgoTicketsを取得しています。
  • リソースボディのテストを記述する必要はありません。レスポンスのJSON Schemaバリデーションが通ったという保証がされているので、ステータスコードの確認だけでOKです。
  • RESTの統一インターフェイスに従い、次にアクセスするリクエストURLは常にレスポンスに含まれます。それを次々に検査します。

RESTの統一インターフェイス

1) リソースの識別、2) 表現によるリソースの操作、3) 自己記述メッセージ、 4) アプリケーション状態のエンジンとしてのハイパーメディア(HATEOAS)の4つのインターフェイス制約です。10

実行してみましょう。

./vendor/bin/phpunit --testsuite hypermedia

ハイパーメディアAPIテスト(RESTアプリケーションテスト)はRESTアプリケーションがステートマシンであるということをよく表し、ワークフローをユースケースとして記述することができます。 REST APIテストを見ればそのアプリケーションがどのように使われるか網羅されているのが理想です。

HTTPテスト

HTTPでREST APIのテストを行うためにはテスト全体を継承して、setUpでクライアントをHTTPテストクライアントにします。

class WorkflowTest extends Workflow
{
    protected function setUp(): void
    {
        $this->resource = new HttpResource('127.0.0.1:8080', __DIR__ . '/index.php', __DIR__ . '/log/workflow.log');
    }
}

このクライアントはリソースクライアントと同じインターフェイスを持ちますが、実際のリクエストはビルトインサーバーに対してHTTPリクエストで行われ、サーバーからのレスポンスを受け取ります。 1つ目の引数はビルトインサーバーのURLです。newされると2番目の引数で指定されたブートストラップスクリプトでビルトインサーバーが起動します。

テストサーバー用のブートストラップスクリプトもAPIコンテキストに変更します。

tests/Http/index.php

-exit((new Bootstrap())('hal-app', $GLOBALS, $_SERVER));
+exit((new Bootstrap())('hal-api-app', $GLOBALS, $_SERVER));

実行してみましょう。

./vendor/bin/phpunit --testsuite http

HTTPアクセスログ

curlで行われた実際のHTTPリクエスト/レスポンスログが3番目の引数のリソースログに記録されます。

curl -s -i 'http://127.0.0.1:8080/'

HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Date: Fri, 21 May 2021 22:41:02 GMT
Connection: close
X-Powered-By: PHP/8.0.6
Content-Type: application/hal+json

{
    "_links": {
        "self": {
            "href": "/index"
        },
        "goTickets": {
            "href": "/tickets"
        }
    }
}
curl -s -i -H 'Content-Type:application/json' -X POST -d '{"title":"title1"}' http://127.0.0.1:8080/tickets

HTTP/1.1 201 Created
Host: 127.0.0.1:8080
Date: Fri, 21 May 2021 22:41:02 GMT
Connection: close
X-Powered-By: PHP/8.0.6
Location: /ticket?id=421d997c-9a0e-4018-a6c2-9b8758cac6d6

実際に記録されたJSONは、特に複雑な構造を持つ場合に確認するのに役に立ちます。APIドキュメントと併せて確認するのにもいいでしょう。 HTTPクライアントはE2Eテストにも利用することができます。

APIドキュメント

ResourceObjectではメソッドシグネチャがAPIの入力パラメータになっていて、レスポンスがスキーマ定義されています。 その自己記述性の高さからAPIドキュメントが自動生成することができます。

作成してみましょう。docsフォルダにドキュメントが出力されます。

composer doc

IDL(インターフェイス定義言語)を記述する労力を削減しますが、より価値があるのはドキュメントが最新のPHPコードに追従し常に正確なことです。 CIに組み込み常にコードとAPIドキュメントが同期している状態にするのがいいでしょう。

関連ドキュメントをリンクすることもできます。設定について詳しくはApiDocをご覧ください。

コード例

以下のコード例も用意しています。

  • Testコンテキストを追加してテスト毎にDBをクリアするTestModule 4e9704d
  • DBクエリで連想配列を返す代わりにハイドレートされたエンティティクラスを返すRay.MediaQueryentityオプション 29f0a1f
  • 静的なSQLと動的なSQLを合成したクエリービルダー 9d095ac

RESTフレームワーク

Web APIには以下の3つのスタイルがあります。

  • トンネル(SOAP、GraphQL)
  • URI(オブジェクト、CRUD)
  • ハイパーメディア(REST)

リソースを単なるRPCとして扱うURIスタイル11に対して、このチュートリアルで学んだのはリソースがリンクされているRESTです。12 リソースは#[Link]のLO(アウトバウンドリンク)で結ばれワークフローを表し、#[Embed]のLE(埋め込みリンク)でツリー構造を表しています。

BEAR.Sundayは標準に基づいたクリーンなコードであることを重視します。

フレームワーク固有のバリデータよりJSON Schema。独自ORMより標準SQL。独自構造JSONよりIANA標準メディアタイプ13JSON。

アプリケーション設計は「実装が自由である」ことではなく「制約の選択が自由である」ということが重要です。 アプリケーションはその制約に基づき、開発効率やパフォーマンス、後方互換性を壊さない進化可能性を目指すと良いでしょう。


コメントは説明になるだけでなく、スロークエリーログ等からもSQLを特定しやすくなります。

※ 以前のPHP 7対応のチュートリアルはtutorial2_v1にあります。

  1. .envはgit commitされないようにしておきます。 

  2. このSQLはSQLスタイルガイドに準拠しています。PhpStormからはJoe Celkoとして設定できます。 

  3. PHPStorm データベースツールおよび SQL 

  4. データベース図などでクエリプランや実行計画を確認し、作成するSQLの質を高めます。 

  5. PHP 8.0+ 名前付き引数 ¶。PHP 7.xの場合にはカラムの順番になります。 

  6. ここでは例としてMySQLから直接実行していますが、マイグレーションツールでシードを入力したりIDEのDBツールの利用方法も学びましょう。 

  7. Ray.MediaQueryはHTTP APIリクエストにも対応しています。 

  8. このようなコンテンツの階層構造のことを、IA(インフォメーションアーキテクチャ)ではタクソノミーと呼びます。Understanding Information Architecture参照 

  9. Ray.MediaQuery README 

  10. 広く誤解されていますが、統一インターフェイスはHTTPメソッドのことではありません。Uniform Interface参照 

  11. いわゆる”Restish API”。REST APIと紹介されている多くのAPIはこのURI/オブジェクトスタイルで、RESTが誤用されています。 

  12. チュートリアルからリンクを取り除けばURIスタイルになります。 

  13. https://www.iana.org/assignments/media-types/media-types.xhtml