REST 服务器 + OpenAPI:在 Delphi 中实现契约优先的 API | eSeGeCe 博客

REST 服务器 + OpenAPI:在 Delphi 中实现契约优先的 API

· 组件
集成 OpenAPI 的 sgcWebSockets REST 服务器

前两篇文章是手工搭建 REST 服务器:你比较 ARequestInfo.Document,你按动词分支,你自己解析参数。这样做没问题,而且对少量端点来说是最短的路径。但一旦超过某个规模,你要维护的就不再是 API,而是那张路由表。

TsgcWSAPIServer_OpenAPI 走的是另一条路。你编写一份 OpenAPI 3 文档,把插件挂到服务器上,规范文件本身就成了路由器:它匹配路径、提取路径参数、校验请求、强制执行声明的安全方案,并同时提供文档本身和一个 Swagger UI 页面。留给你的代码只剩下真正属于你的部分,每个操作一个处理器。

接线只需一次赋值

插件位于 sgcWebSocket_Server_API_OpenAPI 单元中。设置它的 Server 属性即可把它注册到服务器上,从那时起,每一个 HTTP 请求都会在 OnCommandGet 运行之前先交给它。

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

加载规范,以及一个陷阱

加载文档有三种方式,而它们的行为并不相同:

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 页面的请求到来时才加载。而这两者是在加载发生之前就被应答的,所以如果只设置了 SpecFile,最开始那个 GET /openapi/openapi.json 会返回空的响应体。当你希望文档从第一个请求起就是完整的时候(几乎总是如此),请使用 LoadFromFileLoadFromString

规范免费为你提供的东西

一份包含两个操作的最小文档:

{
  "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:

URL由谁提供
/openapi/openapi.json规范文档
/openapi/docsSwagger UI
/openapi/status操作 getStatus
/openapi/users/alice操作 getUser

处理各个操作

分发是按 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;

Handled 留为 False 是有意义的:引擎随后会应答 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 问题文档,因此整个 API 的错误格式保持一致,而无需你自己去排版。

从 schema 生成的请求校验

校验默认是关闭的。打开总开关而不设置任何范围,就会校验规范中声明的一切:

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 响应和一份列出所有错误的问题文档:

{"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、bearer 令牌、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';

bearer 令牌会用 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。还有对应的 OnValidateAPIKeyOnValidateBasic 事件。

在代码存在之前先返回模拟响应

没有处理器的操作可以用规范自身的示例和 schema 来作答,这让前端团队在实现还在编写时就能开始高效工作:

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

已实现的操作仍然由你的处理器作答;只有未处理的那些才会落到模拟响应上。

Swagger UI,包括离线使用

UI 页面提供在 <BasePath>/docs 上,默认从一个公共 CDN 拉取它的 CSS 和 JavaScript。在一台与网络隔离的机器上这行不通,此时可以把它指向一个存放 swagger-ui.cssswagger-ui-bundle.js 的本地文件夹,页面就会自行提供这些文件:

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

如果你想改为固定使用 CDN 上的某个特定版本,请设置 SwaggerUIBaseURL。把 ServeSwaggerUI 关闭会完全移除该页面,对生产环境部署来说这是一个合理的选择。

CORS:两套策略都要配置,而且取值要相同

这是最容易让人踩坑的部分,所以值得说得精确一些。服务器和 OpenAPI 引擎各自拥有一套 CORS 策略,它们分别负责一次跨域调用的不同一半:

正是这种分工使得响应头绝不会被输出两次,而浏览器会拒绝一个携带多个 Access-Control-Allow-Origin 的响应。但这也意味着,只启用两者之一才是真正会出问题的做法:

两者都要启用,而且取值要完全一致。预检批准了某个源,随后的响应却允许另一个源,同样会被拒绝:

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;

两种风格混用

插件并不会把服务器整个接管过去。它会先拿到每个请求,但只应答其规范所声明的路径;其余的请求会像以前一样到达 OnCommandGet。因此,一段契约优先的部分可以与手写路由、来自 DocumentRoot 的静态内容,以及上一篇文章中的 /health/metrics 端点共存于同一个端口上。

由于插件运行在认证关卡之后,服务器自身的认证依然有效;而多租户是在操作处理器运行之前解析的,因此在 OnRequestFServer.Tenant 同样有效。

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 插件全部放在一台服务器上,作为 REST Server 演示随产品一起提供,位于 Demos\20.HTTP_Protocol\15.REST_Server

请从 sgcWebSockets 下载页面下载最新构建版本。