読者です 読者をやめる 読者になる 読者になる

hitode909の日記

趣味はマリンスポーツですの日記です

DSLでAPIを書きたい!!APISchemaでらくらくAPI生活をはじめよう

この記事は,はてなエンジニアアドベントカレンダー2015の5日目です.
前日はこの記事でした.スクリーンショットで振り返る・はてなブログ記事編集画面デザインの歴史 - Hatena Developer Blog

最近作った(といっても去年から作っている…),APISchemaというライブラリをご紹介します.

APISchemaとは

APISchemaは,DSLでHTTP APIの定義を書けるものです.以下のような機能を持っています.

  • APIを記述するためのDSL
  • ルーターの生成
  • リクエスト/レスポンスのバリデーションをおこなうミドルウェア
  • ドキュメントをMarkdownで生成するスクリプト
  • ドキュメントをHTMLで配信するサーバー
  • モックサーバー

CPANに置いてます.
metacpan.org

BMIを計算しよう

DSLを導入するまえに,まずはBMIを計算するウェブアプリケーションを作ってみましょう.
以下のように,weightとheightをキーに持つJSONをPOSTすると,valueを持つJSONが返ってくるものとします.便利ですね.

% curl \
  -X POST \
  -H "Content-type: application/json" \
  -d '{"weight": 60, "height": 1.7}' \
  http://localhost:5000/bmi
{"value":20.7612456747405}

素朴なPSGIアプリケーションとして実装すると,こんなかんじでしょうか.

use strict;
use warnings;

use Plack::Request;

use JSON qw(decode_json encode_json);

my $app = sub {
    my $env = shift;

    my $req = Plack::Request->new($env);

    my $payload = decode_json($req->content);

    my $bmi = $payload->{weight} / ($payload->{height} * $payload->{height});

    return [200, ['Content-Type' => 'application/json'], [encode_json({value => $bmi})]];
};

$app;

いちおう動くものの,いけてない点もあります.

  • リクエストのバリデーションを行っていない.JSONのパースに失敗する可能性がある
  • heightとweightのキーが存在するか確認していない
  • heightとweightに数値以外が入っている可能性がある
  • リクエストのcontent typeに関係なくJSONとして解釈している
  • /bmi以外のURIにアクセスが来ても,無視してBMIを返している
  • レスポンスが仕様を満たしているかは,テストを書くか,目視で確認する必要がある
  • ドキュメントを手で記述する必要があり,ドキュメントと実装が乖離する可能性がある


APISchemaは,これら全ての問題を解決します.

スキーマを書こう

BMIを計測するスキーマを書いてみましょう.DSLは,Perlの言語内DSLになっています.

少し長くなるので,小分けにしてすこしずつ見ていきましょう.完成品は以下のURLにあります.

メタデータ

titleとdescriptionは,そのファイルの定義するAPIのタイトルと説明に使われます.ドキュメントを生成するさいなどに利用されます.このAPIはBMI APIで,BMiを計算するためのAPIです.

title 'BMI API';
description 'The API to calculate BMI';

リソースの定義

resource リソース名 => リソース定義 という形式で,リソースを表現できます.
リソースは,APIへの入力や出力にあらわれるオブジェクトで,以下の要素を持ちます.

  • リソースの名前
    • リソース相互の参照や,エンドポイントからの参照に使う
  • リソースの説明
    • 人間が読む
  • リソースの定義
    • JSON Schemaで書ける

下のDSLでは,figureは,体重と身長を持つオブジェクトで,weightとheightは必須,bmiは,値を持つオブジェクトで,valueは必須,というようなことを書いています.

resource figure => {
    type => 'object',
    description => 'Figure, which includes weight and height',
    properties => {
        weight  => {
            type => 'number',
            description => 'Weight(kg)',
            example => 50,
        },
        height  => {
            type => 'number',
            description => 'Height(m)',
            example => 1.6,
        },
    },
    required => ['weight', 'height'],
};

resource bmi => {
    type => 'object',
    description => 'Body mass index',
    properties => {
        value  => {
            type => 'number',
            description => 'bmi value',
            example => 19.5,
        },
    },
    required => ['value'],
};

エンドポイントの定義

エンドポイントは,ユーザーがどこにどのようなリクエストを送れて,どのようなレスポンスが返るか,という情報を持ちます.

POST '/bmi' => {
    title           => 'BMI API',
    description     => 'This API calculates your BMI.',
    destination     => {
        controller  => 'BMI',
        action      => 'calculate',
    },
    request         => 'figure',
    response        => 'bmi',
};

ここでは,

  • /bmi にPOSTしたときのエンドポイントを定義する
  • BMi APIという名前のエンドポイントで,
  • controller: BMI, action: calculate にルーティングする
  • リクエストはfigure
  • レスポンスはbmi

という定義がなされています.
destinationのこの設定は,エンドポイントが複数登場するアプリケーションで必要になってきます.今回はエンドポイントは1つだけなのでとくに意味はありません.

これでDSLの記述が完了しました.

スキーマを使う

さきほどのしょぼい実装に,APISchemaを使って,便利機能を搭載していきます.

  • ルーターを生成して,ルーティングをおこなう
  • リクエストのバリデーションをおこなう
  • レスポンスのバリデーションをおこなう
  • APIのドキュメントを配信する

スキーマのパース

さきほど書いたスキーマをパースするには,APISchema::DSLクラスを使います.スキーマは独立したファイルに書いておいて,ファイル名を指定してincludeします.

my $schema = APISchema::DSL::process {
    include '../t/fixtures/bmi.def';
};

ルーターを生成して,ルーティングをおこなう

通常のウェブアプリケーションでは,ルーターを手で書きますが(Router::Simpleのようなものを想定しています),APISchemaを使うと,さきほどのDSLから自動でルーターを生成できます.これもまた,ルーターを生成するクラスを呼び出すだけです.

my $generator = APISchema::Generator::Router::Simple->new;
my $router = $generator->generate_router($schema);
  # => Router:Simpleのインスタンスが返る

BMIアプリケーションでは,POST /bmiのみルーティングすればよいので,$router->match($env)できれば処理を続行し,matchしなければ404を返す,という風に記述できます.
より複雑なアプリケーションでは,match結果を見て適切なコントローラを呼び出すことになります.

my $match = $router->match($env);
return [404, [], ['not found']] unless $match;

リクエストのバリデーションをおこなう

バリデーションをおこなうミドルウェアが用意されているので,有効にします.
これが便利なのは,クライアントサイドの実装がおかしいとき,変なリクエストが来るので,そういうのをはじきたい,というときです.

builder {
    enable "APISchema::RequestValidator",  schema => $schema;

    mount '/' => $app;
}

これだけで,リクエストのキーをtypoしたり,値の型が変だったり,content-typeが変だったり,といったバリデーションが有効になります.
コントローラにリクエストが来た時点では,仕様通りのリクエストが来ていることが保証されます.
ためしにweight: []というリクエストを送ると,ちゃんとエラーが返ってくるようになりました.

% curl --silent -X POST -H "Content-type: application/json" -d '{"weight": [], "height": 1.7}' http://localhost:5000/bmi | jq .
{
  "body": {
    "message": "Contents do not match resource 'bmi'",
    "encoding": "json",
    "position": "/$ref/required",
    "expected": {
      "properties": {
        "value": {
          "description": "bmi value",
          "type": "number",
          "example": 19.5
        }
      },
      "description": "Body mass index",
      "type": "object",
      "required": [
        "value"
      ]
    },
    "actual": {
      "body": {
        "encoding": "json",
        "message": "Contents do not match resource 'figure'",
        "position": "/$ref/properties/weight/type",
        "expected": {
          "description": "Weight(kg)",
          "type": "number",
          "example": 50
        },
        "attribute": "Valiemon::Attributes::Type",
        "actual": []
      }
    },
    "attribute": "Valiemon::Attributes::Required"
  }
}

レスポンスのバリデーションをおこなう

レスポンスのバリデーションが通らないとは,どういうことでしょう.サーバー側の実装がバグってるときです.これも,ミドルウェアを有効にするだけです.

builder {
    enable "APISchema::ResponseValidator", schema => $schema;
    enable "APISchema::RequestValidator",  schema => $schema;

    mount '/' => $app;
}

ためしに誤ってapplication/jsonnというcontent-typeでレスポンスを返すと,ちゃんとエラーが返ります.
アプリケーションからレスポンを返して,クライアントに届く時点で,仕様通りのレスポンスを返せていることが保証されます.

    return [200, ['Content-Type' => 'application/jsonn'], [encode_json({value => $bmi})]];
% curl -X POST -H "Content-type: application/json" -d '{"weight": 70, "height": 1.7}' http://localhost:5000/bmi
{"body":{"message":"Wrong content-type: application/jsonn"}}

APIのドキュメントを配信する

ドキュメントを配信するPlack Appも用意されているので,さきほどのDSLからドキュメントを配信できます.
ついでにBootstrapのCSSを適用するPlack::Middleware::Bootstrapも有効化します.

builder {
    enable "APISchema::ResponseValidator", schema => $schema;
    enable "APISchema::RequestValidator",  schema => $schema;
    mount '/doc/' => builder {
        enable 'Bootstrap';
        Plack::App::APISchema::Document->new(
            schema => $schema,
        )->to_app;
    };
    mount '/' => $app;
}

ブラウザから/docにアクセスすると,いい感じのドキュメントが出ます.

f:id:hitode909:20151205184820p:plain

これもさきほどのDSLから生成していて,かつ,サーバー側の実装と一致しているので,ドキュメントと実装の乖離も避けられます.
その他,リポジトリに置いておくために,Markdownを生成するスクリプトも用意しています.

採用実績

社内のAPIサーバーでは利用していて,大チェッカーの裏側でフィードのクロールをおこなうサブシステムなどで利用しています.
社内用のAPIなど,品質を保証するのが手間なので,このようなミドルウェアが自動でバリデーションしてくれるのは便利です.
また,ドキュメントも生成されるので,謎のコードだけがあって,どう呼ぶか分からない,みたいな事態を避けられています.

関連

JSON Schema

JSON Schemaは,JSONの構造をJSONで表現して,バリデーションするための仕組みです.
json-schema.org

CPANに上がっていて利用できる,手頃なバリデータが見当らなかったので,同僚のid:pokutunaが作っていたバリデータをCPANに上げて利用しています.Valiemonという変な名前だけどちゃんとバリデーションできます.
metacpan.org

便利グッズ

JSON SchemaやHyper-Schemaでなんでもやるのはなかむらくん(id:r7kamura)がいろいろRubyでの実装を作っていました.以下のエントリに詳しい.
r7kamura.hatenablog.com


なかむらくんシリーズだとJSON Schemaをそのまま使っていたのに対し,APISchemaでは,DSLをもとになんでもやる形になっています.
JSON SchemaやHyper-SchemaはJSONで記述するのですが,直接書くのが大変だったのと,使えるライブラリもあまりなかったので,必要なところは自作することにして,独自路線を歩むことにしました.DSLはPerlで書けるので,変数や関数をそのまま書くことができDSL内DSLみたいなのを書くことができます.

まとめ

APISchemaについて紹介しました.
はてなでは,APIを実装したり,APIを作る仕組みを作ったり,APIを叩いたりするスタッフを募集しています.
hatenacorp.jp

また,APIを実装したり,APIを作る仕組みを作ったり,APIを叩いたりする新卒スタッフも募集しています.
developer.hatenastaff.com


この記事は,はてなエンジニアアドベントカレンダー2015の5日目でした.明日はid:mazcoです.APIとはまったく関係ない話だと思います.おたのしみに!!.