REST サーバー + OpenAPI: Delphi でのコントラクトファースト API | eSeGeCe ブログ

REST サーバー + OpenAPI: Delphi でのコントラクトファースト API

· コンポーネント
sgcWebSockets REST server with OpenAPI integration

最初の 2 つの記事では、REST サーバーを手作業で組み立てました。ARequestInfo.Document を比較し、メソッドで分岐し、パラメーターを自分で解析します。これでも動作しますし、エンドポイントが数個であればいちばんの近道です。ただし一定の規模を超えると、API ではなくルーティングテーブルの保守が仕事になってしまいます。

TsgcWSAPIServer_OpenAPI は別のアプローチを取ります。OpenAPI 3 のドキュメントを書き、プラグインをサーバーに接続すると、その仕様がルーターになります。パスを照合し、パスパラメーターを取り出し、リクエストを検証し、宣言されたセキュリティスキームを適用し、ドキュメントと Swagger UI ページの両方を配信します。コード側に残るのは本当に自分が担当すべき部分、つまり操作ごとに 1 つのハンドラーだけです。

配線は代入 1 行

プラグインは sgcWebSocket_Server_API_OpenAPI にあります。Server プロパティを設定するとサーバーに登録され、以降は OnCommandGet が実行される前に、すべての HTTP リクエストがプラグインへ渡されます。

uses
  sgcHTTP_REST_Server, sgcHTTP_OpenAPI_Server,
  sgcWebSocket_Server_API_OpenAPI;

FOpenAPI := TsgcWSAPIServer_OpenAPI.Create(self);
FOpenAPI.OpenAPIOptions.Endpoint.BasePath := '/openapi';
FOpenAPI.OpenAPIOptions.Endpoint.ServeSpec := True;
FOpenAPI.OpenAPIOptions.Endpoint.ServeSwaggerUI := True;
FOpenAPI.OnRequest := OpenAPIRequest;
FOpenAPI.LoadFromFile('C:\api\petstore.json');
FOpenAPI.Server := FServer;

Active プロパティはありません。スイッチの役割を果たすのは Server です。割り当てればプラグインが接続され、nil にすれば切り離されます。どちらもサーバーを動かしたまま行えます。切り離すと、仕様が管理していたパスは通常のハンドラーへそのまま流れます。

FOpenAPI.Server := nil;   // detach, server keeps running

仕様の読み込みと 1 つの落とし穴

ドキュメントを読み込む方法は 3 つあり、動作は同じではありません。

FOpenAPI.LoadFromFile('C:\api\petstore.json');   // parses immediately
FOpenAPI.LoadFromString(CS_SPEC);                // parses immediately
FOpenAPI.OpenAPIOptions.Endpoint.SpecFile := 'C:\api\petstore.json';  // lazy

SpecFile は遅延読み込みされ、仕様のエンドポイントでも Swagger UI ページでもない最初のリクエストで読み込まれます。この 2 つは読み込みの前に応答されるため、SpecFile だけを設定していると、いちばん最初の GET /openapi/openapi.json は空のボディを返します。最初のリクエストからドキュメントを完全な状態にしたい場合、つまりほとんどの場合は、LoadFromFileLoadFromString を使ってください。

仕様だけで得られるもの

操作が 2 つだけの最小限のドキュメントです。

{
  "openapi": "3.0.3",
  "info": { "title": "demo", "version": "1.0.0" },
  "servers": [ { "url": "/openapi" } ],
  "paths": {
    "/status": {
      "get": { "operationId": "getStatus",
        "responses": { "200": { "description": "server status" } } }
    },
    "/users/{username}": {
      "get": { "operationId": "getUser",
        "parameters": [ { "name": "username", "in": "path",
          "required": true, "schema": { "type": "string" } } ],
        "responses": { "200": { "description": "the account" },
                       "404": { "description": "no such account" } } }
    }
  }
}

BasePath/openapi に設定すると、このドキュメントだけで動作する URL が 4 つできます。

URL応答する主体
/openapi/openapi.json仕様ドキュメント
/openapi/docsSwagger UI
/openapi/statusgetStatus 操作
/openapi/users/alicegetUser 操作

操作を処理する

ディスパッチはパスやメソッドではなく operationId で行われます。OnRequest が発生する時点で、エンジンはすでにルートを照合してパスパラメーターを埋めているため、ハンドラーは名前でそれらを読み取ります。

procedure TForm1.OpenAPIRequest(Sender: TObject;
  const aOperationId: string; const aContext: TsgcOpenAPIServerContext;
  var Handled: Boolean);
var
  vName: string;
  oInfo: TsgcUserInfo;
begin
  if SameText(aOperationId, 'getStatus') then
  begin
    aContext.RespondJSON(200, '{"status":"running"}');
    Handled := True;
  end
  else if SameText(aOperationId, 'getUser') then
  begin
    vName := aContext.PathParamAsString('username');
    if FUsers.FindUser(vName, oInfo) then
      aContext.RespondJSON(200, '{"username":"' + oInfo.Username + '"}')
    else
      aContext.RespondError(404, 'Not Found', 'no such account');
    Handled := True;
  end;
end;

HandledFalse のままにすることには意味があります。その場合、エンジンは操作名を示しながら 501 Not Implemented を返します。仕様には宣言されているがまだ実装されていない操作は、紛らわしい 404 ではなく、まさにその状態を報告します。

コンテキストオブジェクトはリクエスト全体とレスポンス用のヘルパーを備えています。

vPage := aContext.QueryParamAsInteger('page', 1);
vDebug := aContext.QueryParamAsBoolean('debug', False);
vAuth := aContext.HeaderValue('Authorization');
oJSON := aContext.BodyAsJSON;

aContext.RespondJSON(201, '{"created":true}');
aContext.RespondError(422, 'Unprocessable', 'quantity must be positive');

RespondError は RFC 7807 の problem ドキュメントを出力するため、自分で整形しなくても API 全体でエラーの形式が統一されます。

スキーマにもとづくリクエスト検証

検証はデフォルトで無効です。範囲を指定せずにマスターフラグを有効にすると、仕様が宣言しているすべてが検証されます。

FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;

あるいは、チェックしたい部分だけに絞り込むこともできます。

FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
FOpenAPI.OpenAPIOptions.Validation.ValidatePathParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateQueryParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateRequestBody := False;

検証に失敗したリクエストには、ハンドラーが実行される前に 400 と、すべてのエラーを列挙した problem ドキュメントが返されます。

{"type":"about:blank","title":"Bad Request","status":400,
 "detail":"Request validation failed",
 "errors":["parameter 'limit' must be integer"]}

OnValidationError を使うと、失敗の内容を確認して判断を上書きできます。Continue パラメーターは False の状態で渡されるため、True に設定するのは意図的な行為になります。

procedure TForm1.OpenAPIValidationError(Sender: TObject;
  const aOperationId: string; const aErrors: TStringList;
  const aContext: TsgcOpenAPIServerContext; var Continue: Boolean);
begin
  DoLog(aOperationId + ': ' + aErrors.Text);
  Continue := False;   // answer 400
end;

仕様で宣言するセキュリティ

EnforceSecurity を有効にすると、ドキュメントの securitySchemes が受信リクエストに適用されます。ヘッダー、クエリ、Cookie に載せた API キー、HTTP Basic、ベアラートークン、OAuth2、OpenID Connect に対応します。

FOpenAPI.OpenAPIOptions.Security.EnforceSecurity := True;
FOpenAPI.OpenAPIOptions.Security.JWTSecret := GetSecretFromEnvironment;
FOpenAPI.OpenAPIOptions.Security.ValidateExpiration := True;
FOpenAPI.OpenAPIOptions.Security.Issuer := 'https://auth.example.com';
FOpenAPI.OpenAPIOptions.Security.Audience := 'my-api';

ベアラートークンは JWTSecret で検証されます。HMAC のシークレットはそのまま使われ、-----BEGIN を含む値は PEM の公開鍵として扱われて RSA と ECDSA のアルゴリズムが有効になります。JWTSecret を空のままにすると、トークンは存在するかどうかだけがチェックされます。これは OnValidateBearer で自分で検証したい場合に適した設定です。

procedure TForm1.OpenAPIValidateBearer(Sender: TObject;
  const aToken: string; const aContext: TsgcOpenAPIServerContext;
  var Valid: Boolean);
begin
  Valid := MyTokenService.Verify(aToken);
end;

失敗時は 401 を返し、認証は通ったがスコープだけが足りない場合は 403 を返します。これに対応する OnValidateAPIKey イベントと OnValidateBasic イベントも用意されています。

コードができる前のモックレスポンス

ハンドラーのない操作は、仕様自身の例やスキーマから応答できます。これにより、実装がまだ書かれている最中でもフロントエンドチームは作業を進められます。

FOpenAPI.OpenAPIOptions.Mock.Enabled := True;
FOpenAPI.OpenAPIOptions.Mock.StatusCode := 200;

実装済みの操作は引き続きハンドラーから応答します。モックに流れるのは処理されなかった操作だけです。

オフラインでも使える Swagger UI

UI ページは <BasePath>/docs で配信され、デフォルトでは CSS と JavaScript を公開の CDN から取得します。ネットワークから切り離されたマシンではそれでは不十分なので、swagger-ui.cssswagger-ui-bundle.js を置いたローカルフォルダーを指定すれば、ページ自身がそれらを配信します。

FOpenAPI.OpenAPIOptions.Endpoint.SwaggerUIAssetsPath := 'C:\www\swagger';

代わりに CDN 上の特定のバージョンに固定したい場合は、SwaggerUIBaseURL を設定します。ServeSwaggerUI をオフにするとページ自体がなくなります。これは本番環境への配置では妥当な選択です。

CORS: 2 つのポリシーを同じ値で両方設定する

ここは多くの人がつまずく箇所なので、正確に説明しておきます。サーバーと OpenAPI エンジンはそれぞれ独自の CORS ポリシーを持ち、クロスオリジン呼び出しの異なる半分ずつに応答します。

この分担があるおかげで、ヘッダーが二重に出力されることはありません。ブラウザーは Access-Control-Allow-Origin を複数回含むレスポンスを拒否します。ただしそのぶん、2 つのうち片方だけを有効にすると実際に動かなくなります。

両方を、同じ値で有効にしてください。あるオリジンを許可したプリフライトの後に、別のオリジンを許可するレスポンスが続けば、やはり拒否されます。

FServer.CORSOptions.Enabled := True;
FServer.CORSOptions.AllowOrigins := 'https://app.example.com';
FServer.CORSOptions.AllowHeaders := 'Content-Type, Authorization';
FServer.CORSOptions.AllowMethods := 'GET, POST, PUT, DELETE, OPTIONS';

FOpenAPI.OpenAPIOptions.CORS.Enabled := FServer.CORSOptions.Enabled;
FOpenAPI.OpenAPIOptions.CORS.AllowOrigins := FServer.CORSOptions.AllowOrigins;
FOpenAPI.OpenAPIOptions.CORS.AllowHeaders := FServer.CORSOptions.AllowHeaders;
FOpenAPI.OpenAPIOptions.CORS.AllowMethods := FServer.CORSOptions.AllowMethods;

2 つのスタイルを混在させる

プラグインがサーバーを乗っ取ることはありません。各リクエストはまずプラグインへ渡されますが、応答するのは仕様が宣言しているパスだけで、それ以外はこれまでどおり OnCommandGet に届きます。そのため、コントラクトファーストの部分と、手書きのルート、DocumentRoot からの静的コンテンツ、前回の記事で扱った /health/metrics のエンドポイントを、すべて 1 つのポート上で共存させられます。

プラグインは認証ゲートの後で実行されるため、サーバー自身の認証はそのまま適用されます。またマルチテナンシーは操作ハンドラーが実行される前に解決されるため、FServer.TenantOnRequest の中でも有効です。

procedure TForm1.OpenAPIRequest(Sender: TObject;
  const aOperationId: string; const aContext: TsgcOpenAPIServerContext;
  var Handled: Boolean);
begin
  DoLog(aOperationId + ' tenant=' + FServer.Tenant);
  ...
end;

完成したサーバー

FServer := TsgcHTTPRESTServer.Create(self);
FServer.Port := 5876;
FServer.OnCommandGet := ServerCommandGet;

FOpenAPI := TsgcWSAPIServer_OpenAPI.Create(self);
FOpenAPI.OpenAPIOptions.Endpoint.BasePath := '/openapi';
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
FOpenAPI.OnRequest := OpenAPIRequest;
FOpenAPI.LoadFromFile('C:\api\petstore.json');
FOpenAPI.Server := FServer;

FServer.Active := True;

ユーザーストア、テナンシー、メトリクス、OpenAPI プラグインをすべて 1 台のサーバーにまとめた完全な動作例は、REST Server デモとして Demos\20.HTTP_Protocol\15.REST_Server に収録されています。

最新のビルドは sgcWebSockets のダウンロードページから入手してください。