CHANSHIGELOG

いろんなこと

Laravelでもhal+jsonでレスポンスを返したい

前回、SymfonyのHalJsonResponseBundleを作っている話に関連して、Laravelでもhal+json形式のレスポンスを使えるようにしたく、先にこちらをライブラリ(コンポーネント)化しました。

packagist.org

※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リポジトリにて随時コミットしていきつつ、次回のブログで実装について書いていく予定にしました!(秒で気持ち切り替えました)

github.com

初回からとても中途半端になってしまいましたが、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という超便利なコンポーネントがあるのでそれを使えばいいじゃないの!って思いますが、よりライトに実装できないものか?と色々探っていると、公式にアクション引数をカスタムできる方法が載っていました。

symfony.com

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を使いたい。どうやって使うんだ?となり色々調べて、欲しいのができたのでメモ程度に残します。

最初に

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ですが、以下を変更しました。

  • URIに含まれていた"APIバージョニング"の廃止 '/v1'
  • 404 NotFound時に、リクエスト可能なAPI形式をlinkで提供

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

※ 2019/05/29変更
リクエスURIの変更をしました