Laravelでもhal+jsonでレスポンスを返したい
前回、SymfonyのHalJsonResponseBundleを作っている話に関連して、Laravelでもhal+json形式のレスポンスを使えるようにしたく、先にこちらをライブラリ(コンポーネント)化しました。
※LaravelではsubRequestの考え方が取り込まれてないので、他のリソースを埋め込むembedded
機能は作ってません。
※Laravel9のみサポートしてます。
メジャーバージョンとして公開していませんが、実際に使うことができます。
使い方としては至って簡単で、まずはインストールしていただくと、Laravelのパッケージディスカバリ機能でHalJsonResponseProvider
が自動登録されます。
composer require chanshige/laravel-hal-json-response
※ composer.jsonのextraにて定義しています
HalJsonResponseProvider
では、インターフェースとそれを実装した具象クラスを結合し、サービスコンテナに登録するまでをやっています。
<?php declare(strict_types=1); namespace Chanshige\Laravel\Http; use Chanshige\Laravel\Http\Contracts\HalJsonResponseInterface; use Chanshige\Laravel\Http\Contracts\HalLinkInterface; use Illuminate\Support\ServiceProvider; final class HalJsonResponseProvider extends ServiceProvider { public function register(): void { $this->app->singleton(HalLinkInterface::class, HalLink::class); $this->app->bind(HalJsonResponseInterface::class, HalJsonResponse::class); } }
続けてController(Action)を作りますが、例ではコンストラクタでHalJsonResponseInterface
をとってます。
併せて、HalLinkアトリビュートで usersリソースのリンクを(存在するテイで)貼ってみます。
final class IndexController { public function __construct( private HalJsonResponseInterface $response ) { } #[Get(uri: '/', name: 'index')] #[HalLink(rel: 'user', href: '/users/{user_id}')] public function __invoke(): Responsable { return $this->response->withContent( [ 'greeting' => 'Hi!', 'user_id' => 1989, ] ); } }
Laravelのサービスコンテナ(DI)は自動依存注入(Auto Wiring)機能があるので便利ですね。コンストラクタインジェクション! (Facadeは使わない派です....)
これだけでindexリソースにリクエストすると、以下のようなbodyが返ってきます。
< Content-Type: application/hal+json { "greeting": "Hi!", "user_id": 1989, "_links": { "self": { "href": "/" }, "user": { "href": "/users/1989" } } }
お気づきかと思いますが、userリソースのリンク"href": "/users/1989"
は、bodyの'user_id' => 1989'
に置き換えられるようになってます。 こちらはHalLinkアトリビュートで指定している href:'/users/{user_id}'
を基にURIを生成して反映しています。
リソース同士を紐づける際、より関連づいていることを証明できそうな気がしたので実装に含めています。
※この辺りはBEAR.Sundayを超参考にしました。
もうちょっと調整して、メジャーリリースしようと思います!
HalJsonResponseBundleを作ってみている話
Symfony Advent Calendar 2021 - 22日目の記事です。
昨日は @ttskch さんの symfony/consoleを使えばCLIツールが超簡単に作れる! | blog.ttskch でした !
はじめまして!主に九州・福岡/宮崎で、食品宅配や飲食関連のEC/システム開発を行いつつ、現職の新拠点開設に向けて色々やっているちゃんしげです。アドベントカレンダー初参戦となります。
はじめに
普段の業務では、Laminas(ZendFramework)やLaravel、社内の一部システムでBEAR.Sundayをつかって開発をしていますが、さまざまなところで恩恵を受けているSymfonyへの愛が芽生えてきたことと、中・大規模の新規プロジェクトでも利用したく理解を深めていく一つの方法としてResponseに関わるBundleを作ってみることにしました。
なぜ application/hal+json
なのか
最近では、プロジェクトの大小問わずAPI開発をすることが多くなってきていて、REST APIを素直に取り組むには、BEAR.Sundayでも採用されている「ハイパーメディアAPI」の考え方で開発をしたいと思ったことから HALを選択しました。ハイパーテキストが、複数のテキストを相互に関連づけ、結びつける仕組みであるように、HALはリソース同士を関連づけ、リンクを辿って情報を取得・操作する一連の制約を持たせることができるため、クライアント(利用者)側がAPIの使い方を事前にしらなくても、API自身で使い方(情報の関連性などetc...)を表現できることが利点だと思います。
作りたいもの
- レスポンスのContent-typeを
application/hal+json
としたい - リソースに関連するLinkをAnnotationで指定したい
- リソースのステータスコードもAnnotationで指定したい
※関連するリソースを埋め込むEmbedについては今後考えます
Annotation周りは、PHP8から使えるAttributeで実装しようと考えました。
例として、リソースURIを /greeting
、パラメーターは ?username=example_name
のみ受け付けるGreetingControllerを、以下のように実装した場合...
declare(strict_types=1); namespace App\Controller; use App\Domain\Greeting\Input; use Chanshige\HalJsonResponseBundle\Annotation\Link; use Chanshige\HalJsonResponseBundle\Annotation\Response; use Symfony\Component\Routing\Annotation\Route; use function sprintf; class GreetingController { #[Route(path: '/greeting', methods: ['HEAD', 'GET'])] #[Response(statusCode: 200)] #[Link(rel: 'user', href: '/user/{username}')] public function index(Input $input): array { return [ 'greeting' => sprintf('Hello %s', $input->name()), 'username' => $input->name() ]; } }
※上記の Inputみたいな実装は1つ前の記事に書いています。ぜひこちらもご覧ください!
こんなレスポンスが帰ってくるようにしたい。
< Content-Type: application/hal+json { "greeting": "Hello chanshige", "_links": { "self": { "href": "/greeting?username=chanshige" }, "user": { "href": "/user/chanshige" } }
では、作ってみる!
どう作っていくのが良いのだろう?とGoogle先生をつかって調べてたんですがよくわからなかったので、困ったときの公式The Bundle Systemをみつつ、symfony/skeletonでSymfony6.0を立ち上げて、bundle開発用のディレクトリをきって、namespaceを設定しました。
※ namespaceについてもなんでも良いわけではなく、命名規則があります。
composer.json
"autoload": { "psr-4": { "App\\": "src/", "Chanshige\\": "bundles/" } },
次にBundleのディレクトリ構造は、バンドル間でコードの一貫性を保つために規則があるようなので、すでに公開されているBundleライブラリをみながら、このような感じですすめています。(めっちゃ途中です...)
bundles └── HalJsonResponseBundle # bundle本体 ├── Annotation │ ├── Link.php │ └── Response.php ├── DependencyInjection │ └── HalJsonResponseExtension.php ├── EventListener │ └── AnnotationSubscriber.php ├── Extend │ └── UriTemplate.php ├── HalJsonResponseBundle.php ├── Handler │ └── JsonResponseHandler.php └── Resources └── config └── services.yml
流れとして、Annotation(Attribute)とコンテンツ(Body)は、kernel.controller
イベントで拾いまとめて、kernel.view
イベントでそれらを合成したresponseをセットしにいく形で考えています。
※EmbedはsubRequestとかを使えばいけそうかな?と想像...
進めてはみたものの...
今日までに完成させるつもりでしたが、色々とありで間に合いませんでした...!!!!! 🙇♂️
が、実際にBundleライブラリとして公開できる形まで持っていこうと思いますので、実装過程はchanshige/symfony_app
リポジトリにて随時コミットしていきつつ、次回のブログで実装について書いていく予定にしました!(秒で気持ち切り替えました)
初回からとても中途半端になってしまいましたが、Symfony本体のコードリーディングを行っていくうち、”ほぉ〜こうなってるのか”みたいな納得感や、仕組みとして上手く出来てるなあ〜とめちゃくちゃ勉強になることが多く感じました。色々理解を深めていこうと思います。
最後まで読んでいただき、ありがとうございました! 明日は@ippey_s さんです!お楽しみにー!
SymfonyでのRequestをDTOにする
久しぶりの投稿です。業務内外問わずPHPばかり書いておりますちゃんしげです。
SymfonyやLaravelなどのフレームワークでHttpRequestからクエリ(パラメタ)を受け取る際、自前のDTOで受け取りたいと思うことが多いと思います。そうする事で、このController/Actionでは、こういった値を取り扱うと明示的に表現でき、使う時も安全に値を取り出せるメリットがあります。
Requestから直接値を取るとこうですが...
use Symfony\Component\HttpFoundation\Request; class GreetingController { #[Route(path: '/greeting', methods: ['GET'])] public function __invoke(Request $request): JsonResponse { $name = $request->get('name', ''); return new JsonResponse(['greeting' => sprintf('Hello %s', $name)]); } }
DTOで値を取り出すようにしたい!
use App\Domain\Greeting\Input; class GreetingController { #[Route(path: '/greeting', methods: ['GET'])] public function __invoke(Input $input): JsonResponse { return new JsonResponse(['greeting' => sprintf('Hello %s', $input->name())]); } }
Requestから実装で値を詰め替えることなく、呼吸をするように進められそう...
Symfonyの場合は、Formという超便利なコンポーネントがあるのでそれを使えばいいじゃないの!って思いますが、よりライトに実装できないものか?と色々探っていると、公式にアクション引数をカスタムできる方法が載っていました。
ArgumentValueResolverInterface
を実装していけばやれそう!ということで実装してみます。
まず、自前のDTOだよ!と認識させるためにInterfaceを用意します。
interface RequestObjectInterface
{
}
続けて、上記例のURI/greeting
で取り扱う値'name'
のDTOを作ります。
use App\Service\RequestObjectInterface; use Symfony\Component\Validator\Constraints as Assert; final class Input implements RequestObjectInterface { public function __construct( #[Assert\Length(min: 8, max: 40)] private ?string $name ) { } public function name(): ?string { return $this->name; } }
この段階で /greeting
で受け取るパラメタは?name=chanshige
だけとわかり、かつ8-40文字か!ックーーーッとなります。
次が本命 ArgumentValueResolverInterface
を実装していきますが、値のバリデートも併せていれちゃいます。
※ バリデートに Symfony Validatorを利用します
$ composer require symfony/validator
※ クエリの配列を、指定したオブジェクトに詰め替える Hydratorも使います
$ composer require chanshige/object-hydrator
実装!
use Chanshige\Hydrator\ObjectHydratorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; use function is_subclass_of; class RequestObjectResolver implements ArgumentValueResolverInterface { public function __construct( private ValidatorInterface $validator, private ObjectHydratorInterface $hydrator ) { } public function supports(Request $request, ArgumentMetadata $argument): bool { return is_subclass_of($argument->getType(), RequestObjectInterface::class); } public function resolve(Request $request, ArgumentMetadata $argument): iterable { $dto = $this->hydrator->hydrate( $request->query->all(), sprintf('\\%s', $argument->getType()) ); $errors = $this->validator->validate($dto); if ($errors->count() > 0) { throw new HttpException(404, (string) $errors); } yield $dto; } }
supports()
メソッドはRequestObjectInterface
を実装したDTOであることを判定し、resolve()
ではその場合に処理したい内容を書いています。
これらを有効にするため config/services.yaml
に追記すれば完成です。
# config/services.yaml App\Service\RequestObjectResolver: tags: - { name: controller.argument_value_resolver, priority: 50 }
controller.argument_value_resolver
というタグをつけ、期待する注入が行われるようにpriorityを設定します。
※ 優先度の追加はオプションとのこと
こちらは ObjectHydratorInterface
の解決
# config/services.yaml Chanshige\Hydrator\ObjectHydratorFactory: ~ Chanshige\Hydrator\ObjectHydratorInterface: factory: ['@Chanshige\Hydrator\ObjectHydratorFactory', newInstance]
実際にリクエストしてみると...
# /greeting?name=chanshige { "greeting": "Hello chanshige" }
※ エラーはこうなります。
{ "status": 404, "detail": "Object(App\\Domain\\Greeting\\Input).name:\n This value is too short. It should have 8 characters or more. (code xxx)\n" }
素晴らしい!
こうすることで、コントローラーでのリクエスト処理を簡素化することができ、冒頭でも記載した通り非常に仕様把握もしやすく、そして安全に実装ができるようになるなとおもいます。
最後に
このような思いつきを、自前でトリッキーな実装をせずとも公式かつ合理的な方法で表現できるSymfonyはマジですごいです。結果的には素直にForm使えばいいなって思いましたが、今後も色々なアプローチを試していきたいと思います。
ActiveRecord単体でMigration機能を使いたい
Railsを使わずにActiveRecordとその機能であるMigrationを使いたい。どうやって使うんだ?となり色々調べて、欲しいのができたのでメモ程度に残します。
最初に
- ruby 2.6.5
- MySQL 5.7
- ActiveRecord 6.x
- Rake 13.x
- Dotenv 2.7
Gemfileを用意する
./Gemfile
source 'https://rubygems.org' git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } gem 'dotenv' gem 'activerecord' gem 'mysql2' gem 'rake'
=> 適宜インストールしていただく
秘密っぽい接続情報を.envファイルに持たせる
./.env
DATABASE_HOST=localhost DATABASE_PORT=3306 DATABASE_NAME=db_name DATABASE_USER=user_name DATABASE_PASSWORD=password
そして.envファイルから読んだ値をymlで読みたい
production: adapter: mysql2 encoding: utf8 charset: utf8mb4 host: <%= ENV['DATABASE_HOST'] %> port: <%= ENV['DATABASE_PORT'] %> database: <%= ENV['DATABASE_NAME'] %> username: <%= ENV['DATABASE_USER'] %> password: <%= ENV['DATABASE_PASSWORD'] %>
migrationファイルたち
※ samples テーブルを作ってみます
% mkdir -p db/migration % touch db/migration/20200127000000_create_sample_table.rb
内容を書いていく
db/migration/20200127000000_create_sample_table.rb ※ファイル名とクラス名は合わせる必要あり(snake_case to PascalCase)
class CreateSampleTable < ActiveRecord::Migration[6.0] def change create_table :samples do |t| t.string :name t.text :description t.timestamps end end end
Database接続周りを書く
./chanshige/database.rb
require 'active_record' module Chanshige class Database # establish connection def self.connection(config, env = 'production') ActiveRecord::Base.configurations = config ActiveRecord::Base.establish_connection env.to_sym ActiveRecord::Base.time_zone_aware_attributes = true end end end
最初に大体呼び出しちゃうファイルを作る
./bootstrap.rb
BASE_DIR = __dir__ $LOAD_PATH.unshift File.expand_path('./lib', BASE_DIR) require 'dotenv/load' require 'erb' require 'chanshige/database' # db connection config = YAML.safe_load(ERB.new(IO.read(File.expand_path('./config/database.yml', BASE_DIR))).result) Chanshige::Database.connection config
Rakefileを書く
./Rakefile
require_relative 'bootstrap' namespace :db do migration_context = ActiveRecord::MigrationContext.new( File.expand_path('./db/migration', BASE_DIR), ActiveRecord::SchemaMigration ) desc "Migrate the database (option: VERSION=x)" task :migrate do migration_context.migrate ENV['VERSION'] ? ENV['VERSION'] : nil end desc "Rolls the schema back to the previous version (specify steps w/ STEP=n)" task :rollback do migration_context.rollback ENV['STEPS'] ? ENV['STEPS'] : 1 end desc "Retrieves the current schema version number" task :version do p ActiveRecord::Migration.current_version end end
実行!
% bundle exec rake --tasks rake db:migrate # Migrate the database (option: VERSION=x) rake db:rollback # Rolls the schema back to the previous version (specify steps w/ STEP=n) rake db:version # Retrieves the current schema version number
普段、お仕事などでrubyを書く機会があまりないので、違和感などあったら教えてください!
2020年ももっと面白く、楽しんでまいりましょう!また次回!
最近作ったやつ
もう年末ですね。皆さん忘年してますでしょうか!僕はまだです※2019/12/17現在
2019年がおわりますので、今まであるものをライブラリ化するとか、欲しかったやつを作る勢いを加速させているこの頃でございます。
まず初めにドメイン関係で、whoisに続くdigもライブラリ化しました。
しかしながら中途半端なので、近いうちにアップデートします。
github.com
※composerは composer require chanshige/dig
次に、Slackへのメッセージ投稿のためにIncomingWebhookを使うことがあるんですが、そのときのメッセージ整形やポストのためのリクエストを簡単にするやつを用意しました。Laravelプラグインにインスパイアされて作ってます。
github.com
※composerは composer require chanshige/slack-notifier
最後ですが、これはちょっと前に作ったやつですがNulabのBacklogサービスから課題を取得したり登録したり、、用意されているAPIをええ感じに操作できるライブラリも作っています、AuraDIを使いたくてつくりました。APIの種類が豊富なので、実装が追いついてないところがあります。
github.com
※composerは composer require chanshige/backlog-client
このような感じで、簡単ではありますが”あーこんなのあったらいいな簡単なの”みたいな気持ちで作っているので、僕が使わなくなった&皆さんにもあまり利用されていなさそうなものはどんどん削除し、逆につかわれてそうだああ!はどんどんアップデートしていきたいと思いますので、是非PR、Bugのご連絡などいただけるとうれしいです。
また、ちょいちょい利用いただいている whoisproxy.info もそろそろ、、リニューアルします。
引き続きご利用いただければと思いますので、こちらもよろしくお願いします。
まだ今年を締めるのは早いですが、良いお年をお迎えください!!!!!!!!1 (年内にまた書けたら書きます)
whoisproxyのAPI仕様変更しました
ちゃんしげです。ちょくちょくご利用いただいている api.whoisproxy.infoですが、以下を変更しました。
APIバージョニングについては様々な意見があって、よく議論されている部分かなと思いますが、そもそも不要なのでは?というところから、本APIの利用者様に提供するレスポンスの内容(価値)に大きな変更が無いので、もっと気軽にリクエストできるように廃止しました。
例として、https://api.whoisproxy.info
にリクエストした場合は、特別なリソースを設定していないので404ですが、'_link'プロパティにリクエスト可能な形式を記載しています。
Content-Type: application/problem+json;charset=utf-8
{ "code": 404, "state": "fail", "_links": { "self": { "href": "/" }, "doc:whois": { "href": "/whois/{domain}", "title": "Lookup find out the registered domain holder." }, "doc:dig": { "href": "/dig/{domain}[/{q-type}]", "title": "domain information groper." }, "reference": { "href": "" } }, "results": "Welcome to a whoisproxy api." }
※上記 /whois
や /dig
でのレスポンス形式はこれまで通りです。
このような感じで、APIへのリクエスト方法を使い手側が知らなくても、とりあえず投げてみればわかるほうが便利じゃんと思いました。
奥が深いRESTの世界に興味があるので、引き続き学びつつアップデートしていきます。是非ご意見お待ちしております!!!!1
whoisproxyのAPIをアップデートした
前回、僕が運用しているwhoisproxy.infoのAPI版を作成した話をしましたが、その中の一つであるdig(DNSレコードを検索するやつ)で、クエリタイプも指定できるようになりました。
許可しているクエリタイプは、以下です。
a any aaaa mx ns soa txt srv cname
リクエストとしては、https://api.whoisproxy.info/dig/{ドメイン名}/{クエリタイプ}
の形式となります。
以下、例としてicloud.com
のAレコードとMXレコードをリクエストしてみます。
/dig/icloud.com/a
{ "code": 200, "state": "success", "_links": { "self": { "href": "/dig/icloud.com/a" } }, "results": [ "icloud.com. 2246 IN A 17.253.144.10" ] }
/dig/icloud.com/mx
{ "code": 200, "state": "success", "_links": { "self": { "href": "/dig/icloud.com/mx" } }, "results": [ "icloud.com. 271 IN MX 10 mx1.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx2.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx3.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx4.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx5.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx6.mail.icloud.com." ] }
クエリタイプを渡さない場合は、これまで通り"ANY"でのリクエストととなり、仮に許可しないクエリタイプを指定された場合はエラーを返します。
/dig/icloud.com
{ "code": 200, "state": "success", "_links": { "self": { "href": "/dig/icloud.com" } }, "results": [ "icloud.com. 21571 IN SOA adns1.apple.com. hostmaster.apple.com. 2011093772 1800 900 2592000 1800", "icloud.com. 21571 IN NS b.ns.apple.com.", "icloud.com. 21571 IN NS e.ns.apple.com.", "icloud.com. 21571 IN NS c.ns.apple.com.", "icloud.com. 21571 IN NS f.ns.apple.com.", "icloud.com. 21571 IN NS a.ns.apple.com.", "icloud.com. 21571 IN NS d.ns.apple.com.", "icloud.com. 3571 IN A 17.253.144.10", "icloud.com. 271 IN MX 10 mx5.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx6.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx4.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx3.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx1.mail.icloud.com.", "icloud.com. 271 IN MX 10 mx2.mail.icloud.com.", "icloud.com. 3571 IN TXT google-site-verification=knAEOH4QxR29I4gjRkpkvmUmP2AA7WrDk8Kq0wu9g9o", "icloud.com. 3571 IN TXT v=spf1 ip4:17.36.0.0/16 ip4:17.41.0.0/16 ip4:17.58.0.0/16 ip4:17.110.0.0/15 ip4:17.111.110.0/23 ip4:17.120.0.0/16 ip4:17.133.0.0/16 ip4:17.139.0.0/16 ip4:17.142.0.0/15 ip4:17.151.1.0/24 ip4:17.158.0.0/15 ip4:17.162.0.0/15 ip4:17.164.0.0/16 ip4:17.171.37.0/24 ip4:17.172.0.0/16 ip4:17.179.168.0/23 ~all" ] }
/dig/icloud.com/query (Error)
{ "code": 403, "state": "fail", "_links": { "self": { "href": "/dig/icloud.com/query" } }, "results": "query-type:query is not supported." }
※digはGoogle Public DNS(@8.8.8.8)で参照した結果を返しています
地味に便利だったりするので、ぜひご活用ください!
前回の記事 chanshige.hatenablog.com