この記事は,はてなエンジニアアドベントカレンダー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にアクセスすると,いい感じのドキュメントが出ます.
これもさきほどの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とはまったく関係ない話だと思います.おたのしみに!!.