REST仕様でこれまでに行ったことのない5つのこと

公開: 2022-03-11

ほとんどのフロントエンドおよびバックエンド開発者は、以前にREST仕様とRESTfulAPIを扱ってきました。 ただし、すべてのRESTfulAPIが同じように作成されているわけではありません。 実際、RESTfulになることはめったにありません…

RESTful APIとは何ですか

それは神話です。

プロジェクトにRESTfulAPIがあると思われる場合は、間違いである可能性があります。 RESTful APIの背後にある考え方は、REST仕様で説明されているすべてのアーキテクチャー規則と制限に従う方法で開発することです。 ただし、現実的には、これは実際にはほとんど不可能です。

一方では、RESTにはあいまいであいまいな定義が多すぎます。 たとえば、実際には、HTTPメソッドおよびステータスコード辞書の一部の用語は、意図した目的に反して使用されているか、まったく使用されていません。

一方、REST開発では制限が多すぎます。 たとえば、アトミックリソースの使用は、モバイルアプリケーションで使用される実際のAPIには最適ではありません。 リクエスト間のデータストレージを完全に拒否すると、基本的に、ほぼすべての場所で見られる「ユーザーセッション」メカニズムが禁止されます。

しかし、待ってください、それはそれほど悪くはありません!

何のためにRESTAPI仕様が必要ですか?

これらの欠点にもかかわらず、賢明なアプローチでは、RESTは本当に優れたAPIを作成するための素晴らしいコンセプトです。 これらのAPIは一貫性があり、明確な構造、優れたドキュメント、および高いユニットテストカバレッジを備えています。 これらすべてを高品質のAPI仕様で実現できます。

通常、RESTAPI仕様はそのドキュメントに関連付けられています。 仕様(APIの正式な説明)とは異なり、ドキュメントは人間が読める形式になっています。たとえば、APIを使用するモバイルアプリケーションやWebアプリケーションの開発者が読むことができます。

正しいAPIの説明は、APIドキュメントをうまく書くことだけではありません。 この記事では、次の方法の例を紹介します。

  • ユニットテストをよりシンプルで信頼性の高いものにします。
  • ユーザー入力の前処理と検証を設定します。
  • シリアル化を自動化し、応答の一貫性を確保します。 そしてさえ
  • 静的型付けの利点をお楽しみください。

しかし、最初に、API仕様の世界の紹介から始めましょう。

OpenAPI

OpenAPIは、現在、RESTAPI仕様で最も広く受け入れられている形式です。 仕様は、次の3つのセクションで構成されるJSONまたはYAML形式の単一ファイルで記述されます。

  1. API名、説明、バージョン、および追加情報を含むヘッダー。
  2. 識別子、HTTPメソッド、すべての入力パラメーター、応答コード、本文のデータ型など、すべてのリソースの説明と、定義へのリンク。
  3. JSONスキーマ形式での入力または出力に使用できるすべての定義(はい、YAMLで表すこともできます)。

OpenAPIの構造には、2つの重大な欠点があります。それは、複雑すぎて、場合によっては冗長です。 小さなプロジェクトには、数千行のJSON仕様を含めることができます。 このファイルを手動で管理することは不可能になります。 これは、APIの開発中に仕様を最新の状態に保つという考えに対する重大な脅威です。

APIを記述し、OpenAPI出力を生成できるようにする複数のエディターがあります。 追加のサービスとそれらに基づくクラウドソリューションには、Swagger、Apiary、Stoplight、Restletなどがあります。

ただし、これらのサービスは、仕様の迅速な編集とコード変更への調整が複雑なため、私にとっては不便でした。 さらに、機能のリストは特定のサービスに依存していました。 たとえば、クラウドサービスのツールに基づいて本格的な単体テストを作成することはほぼ不可能です。 コード生成とモックエンドポイントは実用的であるように見えますが、実際にはほとんど役に立たないことがわかります。 これは主に、エンドポイントの動作がユーザー権限や入力パラメーターなどのさまざまなものに依存するためです。これらはAPIアーキテクトには明らかですが、OpenAPI仕様から自動的に生成するのは簡単ではありません。

Tinyspec

この記事では、私自身のRESTAPI定義フォーマットであるtinyspecに基づく例を使用します。 定義は、直感的な構文の小さなファイルで構成されています。 プロジェクトで使用されるエンドポイントとデータモデルについて説明します。 ファイルはコードの横に保存され、クイックリファレンスとコード作成中に編集できる機能を提供します。 Tinyspecは、プロジェクトですぐに使用できる本格的なOpenAPI形式に自動的にコンパイルされます。

Node.js(Koa、Express)とRuby on Railsの例も使用しますが、ここで説明するプラクティスは、Python、PHP、Javaを含むほとんどのテクノロジーに適用できます。

API仕様が重要な場所

ある程度の背景がわかったので、適切に指定されたAPIを最大限に活用する方法を探ることができます。

1.エンドポイントユニットテスト

ビヘイビア駆動開発(BDD)は、RESTAPIの開発に最適です。 個別のクラス、モデル、またはコントローラーではなく、特定のエンドポイントに対して単体テストを作成することをお勧めします。 各テストでは、実際のHTTPリクエストをエミュレートし、サーバーの応答を確認します。 Node.jsには、リクエストをエミュレートするためのsupertestパッケージとchai-httpパッケージがあり、RubyonRailsには空中浮遊パッケージがあります。

Userスキーマと、すべてのユーザーを返すGET /usersエンドポイントがあるとします。 これを説明するtinyspec構文は次のとおりです。

 # user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}

そして、これが対応するテストを書く方法です:

Node.js

 describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); expect(users[0].name).to.be('string'); expect(users[0].isAdmin).to.be('boolean'); expect(users[0].age).to.be.oneOf(['boolean', null]); }); });

Ruby on Rails

 describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect_json_types('users.*', { name: :string, isAdmin: :boolean, age: :integer_or_null, }) end end

サーバーの応答を説明する仕様がすでにある場合は、テストを簡略化して、応答が仕様に従っているかどうかを確認できます。 tinyspecモデルを使用できます。各モデルは、JSONスキーマ形式に従うOpenAPI仕様に変換できます。

JSのリテラルオブジェクト(またはRubyのHash 、Pythonのdict 、PHPの連想配列、さらにはJavaのMap )は、JSONスキーマに準拠していることを検証できます。 フレームワークをテストするための適切なプラグインもあります。たとえば、jest-ajv(npm)、chai-ajv-json-schema(npm)、RSpec(rubygem)のjson_matchersなどです。

スキーマを使用する前に、スキーマをプロジェクトにインポートしましょう。 まず、tinyspec仕様に基づいてopenapi.jsonファイルを生成します(これは、各テスト実行の前に自動的に実行できます)。

 tinyspec -j -o openapi.json

Node.js

これで、プロジェクトで生成されたJSONを使用して、そこからdefinitionsキーを取得できます。 このキーには、すべてのJSONスキーマが含まれています。 スキーマには相互参照( $ref )が含まれている場合があるため、埋め込みスキーマ( Blog {posts: Post[]}など)がある場合は、検証で使用するためにそれらをアンラップする必要があります。 このために、json-schema-deref-sync(npm)を使用します。

 import deref from 'json-schema-deref-sync'; const spec = require('./openapi.json'); const schemas = deref(spec).definitions; describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); // Chai expect(users[0]).to.be.validWithSchema(schemas.User); // Jest expect(users[0]).toMatchSchema(schemas.User); }); });

Ruby on Rails

json_matchersモジュール$ref参照の処理方法を知っていますが、指定された場所に個別のスキーマファイルが必要なため、最初にswagger.jsonファイルを複数の小さなファイルに分割する必要があります。

 # ./spec/support/json_schemas.rb require 'json' require 'json_matchers/rspec' JsonMatchers.schema_root = 'spec/schemas' # Fix for json_matchers single-file restriction file = File.read 'spec/schemas/openapi.json' swagger = JSON.parse(file, symbolize_names: true) swagger[:definitions].keys.each do |key| File.open("spec/schemas/#{key}.json", 'w') do |f| f.write(JSON.pretty_generate({ '$ref': "swagger.json#/definitions/#{key}" })) end end

テストは次のようになります。

 describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect(result[:users][0]).to match_json_schema('User') end end

この方法でテストを書くことは非常に便利です。 特に、IDEがテストの実行とデバッグをサポートしている場合(WebStorm、RubyMine、Visual Studioなど)。 このようにして、他のソフトウェアの使用を回避でき、API開発サイクル全体が3つのステップに制限されます。

  1. tinyspecファイルでの仕様の設計。
  2. 追加/編集されたエンドポイントのテストのフルセットを作成します。
  3. テストを満たすコードを実装します。

2.入力データの検証

OpenAPIは、応答形式だけでなく、入力データも記述します。 これにより、実行時にユーザーが送信したデータを検証し、一貫性のある安全なデータベース更新を保証できます。

次の仕様があるとしましょう。これは、ユーザーレコードのパッチと、更新が許可されているすべての使用可能なフィールドについて説明しています。

 # user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}

以前は、テスト内検証用のプラグインを検討しましたが、より一般的なケースでは、ajv(npm)およびjson-schema(rubygem)検証モジュールがあります。 それらを使用して、検証付きのコントローラーを作成しましょう。

Node.js(コア)

これは、Expressの後継であるKoaの例ですが、同等のExpressコードは似ています。

 import Router from 'koa-router'; import Ajv from 'ajv'; import { schemas } from './schemas'; const router = new Router(); // Standard resource update action in Koa. router.patch('/:id', async (ctx) => { const updateData = ctx.body.user; // Validation using JSON schema from API specification. await validate(schemas.UserUpdate, updateData); const user = await User.findById(ctx.params.id); await user.update(updateData); ctx.body = { success: true }; }); async function validate(schema, data) { const ajv = new Ajv(); if (!ajv.validate(schema, data)) { const err = new Error(); err.errors = ajv.errors; throw err; } }

この例では、入力が仕様と一致しない場合、サーバーは500 Internal Server Error応答を返します。 これを回避するために、バリデーターエラーをキャッチし、検証に失敗した特定のフィールドに関するより詳細な情報を含む独自の回答を作成し、仕様に従うことができます。

FieldsValidationErrorの定義を追加しましょう:

 # error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}

次に、可能なエンドポイント応答の1つとしてリストします。

 # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError

このアプローチにより、クライアントから無効なデータが送信された場合のエラーシナリオの正確性をテストする単体テストを作成できます。

3.モデルのシリアル化

最近のほとんどすべてのサーバーフレームワークは、何らかの方法でオブジェクトリレーショナルマッピング(ORM)を使用しています。 これは、APIが使用するリソースの大部分が、モデルとそのインスタンスおよびコレクションによって表されることを意味します。

応答で送信されるこれらのエンティティのJSON表現を形成するプロセスは、シリアル化と呼ばれます。

シリアル化を行うためのプラグインは多数あります。たとえば、sequelize-to-json(npm)、acts_as_api(rubygem)、jsonapi-rails(rubygem)などです。 基本的に、これらのプラグインを使用すると、JSONオブジェクトに含める必要のある特定のモデルのフィールドのリストと追加のルールを提供できます。 たとえば、フィールドの名前を変更して、その値を動的に計算できます。

1つのモデルに複数の異なるJSON表現が必要な場合、またはオブジェクトにネストされたエンティティ(関連付け)が含まれている場合は、さらに難しくなります。 次に、継承、再利用、シリアライザーのリンクなどの機能が必要になります。

異なるモジュールは異なるソリューションを提供しますが、これを考えてみましょう:仕様は再び役立つことができますか? 基本的に、JSON表現の要件に関するすべての情報、埋め込みエンティティを含むすべての可能なフィールドの組み合わせは、すでに含まれています。 これは、単一の自動シリアライザーを作成できることを意味します。

Sequelizeモデルでこれを行うことをサポートする小さなsequelize-serialize(npm)モジュールを紹介します。 モデルインスタンスまたは配列、および必要なスキーマを受け入れ、それを反復処理してシリアル化されたオブジェクトを構築します。 また、すべての必須フィールドを考慮し、関連するエンティティにネストされたスキーマを使用します。

したがって、ブログに投稿があるすべてのユーザーを、これらの投稿へのコメントを含めてAPIから返す必要があるとします。 次の仕様で説明しましょう。

 # models.tinyspec Comment {authorId: i, message} Post {topic, message, comments?: Comment[]} User {name, isAdmin: b, age?: i} UserWithPosts < User {posts: Post[]} # blogUsers.endpoints.tinyspec GET /blog/users => {users: UserWithPosts[]}

これで、Sequelizeを使用してリクエストを作成し、上記の仕様に正確に対応するシリアル化されたオブジェクトを返すことができます。

 import Router from 'koa-router'; import serialize from 'sequelize-serialize'; import { schemas } from './schemas'; const router = new Router(); router.get('/blog/users', async (ctx) => { const users = await User.findAll({ include: [{ association: User.posts, required: true, include: [Post.comments] }] }); ctx.body = serialize(users, schemas.UserWithPosts); });

これはほとんど魔法のようですね。

4.静的な入力

TypeScriptまたはFlowを使用するのに十分な能力がある場合は、「私の貴重な静的型は何ですか?」とすでに質問しているかもしれません。 sw2dtsまたはswagger-to-flowtypeモジュールを使用すると、JSONスキーマに基づいて必要なすべての静的型を生成し、それらをテスト、コントローラー、およびシリアライザーで使用できます。

 tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api

これで、コントローラーで型を使用できるようになりました。

 router.patch('/users/:id', async (ctx) => { // Specify type for request data object const userData: Api.UserUpdate = ctx.request.body.user; // Run spec validation await validate(schemas.UserUpdate, userData); // Query the database const user = await User.findById(ctx.params.id); await user.update(userData); // Return serialized result const serialized: Api.User = serialize(user, schemas.User); ctx.body = { user: serialized }; });

そしてテスト:

 it('Update user', async () => { // Static check for test input data. const updateData: Api.UserUpdate = { name: MODIFIED }; const res = await request.patch('/users/1', { user: updateData }); // Type helper for request response: const user: Api.User = res.body.user; expect(user).to.be.validWithSchema(schemas.User); expect(user).to.containSubset(updateData); });

生成された型定義は、APIプロジェクトだけでなく、クライアントアプリケーションプロジェクトでも使用して、APIで動作する関数の型を記述することができることに注意してください。 (Angular開発者はこれに特に満足します。)

5.クエリ文字列タイプのキャスト

APIが何らかの理由でapplication/jsonではなくapplication/x-www-form-urlencoded MIMEタイプのリクエストを消費する場合、リクエストの本文は次のようになります。

 param1=value&param2=777&param3=false

同じことがクエリパラメータにも当てはまります(たとえば、 GETリクエストの場合)。 この場合、Webサーバーはタイプを自動的に認識できません。すべてのデータは文字列形式になるため、解析後に次のオブジェクトを取得します。

 { param1: 'value', param2: '777', param3: 'false' }

この場合、リクエストはスキーマの検証に失敗するため、正しいパラメータの形式を手動で確認し、それらを正しいタイプにキャストする必要があります。

ご想像のとおり、仕様にある古き良きスキーマを使用してそれを行うことができます。 このエンドポイントと次のスキーマがあるとします。

 # posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }

このエンドポイントへのリクエストは次のようになります。

 GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true

すべてのパラメーターを必要なタイプにキャストするcastQuery関数を書いてみましょう。

 function castQuery(query, schema) { _.mapValues(query, (value, key) => { const { type } = schema.properties[key] || {}; if (!value || !type) { return value; } switch (type) { case 'integer': return parseInt(value, 10); case 'number': return parseFloat(value); case 'boolean': return value !== 'false'; default: return value; } }); }

ネストされたスキーマ、配列、およびnull型をサポートするより完全な実装は、cast-with-schema(npm)モジュールで利用できます。 それでは、コードで使用してみましょう。

 router.get('/posts', async (ctx) => { // Cast parameters to expected types const query = castQuery(ctx.query, schemas.PostsQuery); // Run spec validation await validate(schemas.PostsQuery, query); // Query the database const posts = await Post.search(query); // Return serialized result ctx.body = { posts: serialize(posts, schemas.Post) }; });

4行のコードのうち3行が仕様スキーマを使用していることに注意してください。

ベストプラクティス

ここで従うことができるベストプラクティスがいくつかあります。

スキーマの作成と編集を別々に使用する

通常、サーバーの応答を記述するスキーマは、入力を記述するスキーマとは異なり、モデルの作成と編集に使用されます。 たとえば、 POSTおよびPATCHリクエストで使用可能なフィールドのリストは厳密に制限する必要があり、 PATCHには通常すべてのフィールドがオプションとしてマークされています。 応答を記述するスキーマは、より自由形式にすることができます。

CRUDLエンドポイントを自動的に生成する場合、tinyspecはNewUpdateの接尾辞を使用します。 User*スキーマは、次の方法で定義できます。

 User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}

古いスキーマの再利用または継承による偶発的なセキュリティの問題を回避するために、異なるアクションタイプに同じスキーマを使用しないようにしてください。

スキーマの命名規則に従う

同じモデルのコンテンツは、エンドポイントによって異なる場合があります。 スキーマ名にWith*およびFor*の接尾辞を使用して、違いと目的を示します。 tinyspecでは、モデルは相互に継承することもできます。 例えば:

 User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}

接尾辞は変更して組み合わせることができます。 それらの名前は、本質を反映し、ドキュメントを読みやすくする必要があります。

クライアントタイプに基づいてエンドポイントを分離する

多くの場合、同じエンドポイントは、クライアントタイプ、またはリクエストを送信したユーザーの役割に基づいて異なるデータを返します。 たとえば、 GET /usersエンドポイントとGET /messagesエンドポイントは、モバイルアプリケーションユーザーとバックオフィスマネージャーで大幅に異なる場合があります。 エンドポイント名の変更はオーバーヘッドになる可能性があります。

同じエンドポイントを複数回説明するには、パスの後に括弧で囲んでそのタイプを追加できます。 これにより、タグの使用も簡単になります。エンドポイントのドキュメントをグループに分割します。各グループは、特定のAPIクライアントグループを対象としています。 例えば:

 Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]

RESTAPIドキュメントツール

仕様をtinyspecまたはOpenAPI形式で取得したら、見栄えの良いドキュメントをHTML形式で生成して公開できます。 これにより、APIを使用する開発者は満足し、RESTAPIドキュメントテンプレートを手作業で入力するよりも確実に優れています。

前述のクラウドサービスとは別に、OpenAPI 2.0をHTMLおよびPDFに変換するCLIツールがあり、静的ホスティングにデプロイできます。 ここではいくつかの例を示します。

  • bootprint-openapi(npm、tinyspecでデフォルトで使用)
  • swagger2markup-cli(jar、使用例があり、tinyspec Cloudで使用されます)
  • redoc-cli(npm)
  • widdershins(npm)

他に例はありますか? コメントでそれらを共有します。

残念ながら、1年前にリリースされたにもかかわらず、OpenAPI 3.0はまだ十分にサポートされておらず、クラウドソリューションとCLIツールの両方でそれに基づくドキュメントの適切な例を見つけることができませんでした。 同じ理由で、tinyspecはまだOpenAPI3.0をサポートしていません。

GitHubでの公開

ドキュメントを公開する最も簡単な方法の1つは、GitHubPagesです。 リポジトリ設定で/docsフォルダの静的ページのサポートを有効にし、このフォルダにHTMLドキュメントを保存するだけです。

GitHubPagesを介して/docsフォルダーからREST仕様のHTMLドキュメントをホストします。

tinyspecまたは別のCLIツールを介してscripts/package.jsonファイルにドキュメントを生成するコマンドを追加して、コミットするたびにドキュメントを自動的に更新できます。

 "scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }

継続的インテグレーション

ドキュメント生成をCIサイクルに追加して、たとえば、環境またはAPIバージョン(/ docs / 2.0、 /docs/stable/docs/stagingなど)に応じて異なるアドレスで/docs/2.0に公開できます。

Tinyspecクラウド

tinyspec構文が気に入った場合は、tinyspec.cloudの早期採用者になることができます。 幅広いテンプレートの選択肢とパーソナライズされたテンプレートを開発する機能を備えたドキュメントの自動展開のために、それとCLIに基づいてクラウドサービスを構築することを計画しています。

REST仕様:素晴らしい神話

REST API開発は、おそらく最新のWebおよびモバイルサービス開発で最も快適なプロセスの1つです。 ブラウザ、オペレーティングシステム、画面サイズの動物園はなく、すべてを指先で完全に制御できます。

このプロセスは、自動化と最新の仕様のサポートによってさらに簡単になります。 私が説明したアプローチを使用するAPIは、適切に構造化され、透過的で、信頼性が高くなります。

肝心なのは、私たちが神話を作っているのなら、それを素晴らしい神話にしてみませんか?

関連: ActiveResource.js ORM:JSONAPI用の強力なJavaScriptSDKを高速で構築