前两篇文章是手工搭建 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 会返回空的响应体。当你希望文档从第一个请求起就是完整的时候(几乎总是如此),请使用 LoadFromFile 或 LoadFromString。
规范免费为你提供的东西
一份包含两个操作的最小文档:
{
"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/docs | Swagger 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。还有对应的 OnValidateAPIKey 和 OnValidateBasic 事件。
在代码存在之前先返回模拟响应
没有处理器的操作可以用规范自身的示例和 schema 来作答,这让前端团队在实现还在编写时就能开始高效工作:
FOpenAPI.OpenAPIOptions.Mock.Enabled := True;
FOpenAPI.OpenAPIOptions.Mock.StatusCode := 200;
已实现的操作仍然由你的处理器作答;只有未处理的那些才会落到模拟响应上。
Swagger UI,包括离线使用
UI 页面提供在 <BasePath>/docs 上,默认从一个公共 CDN 拉取它的 CSS 和 JavaScript。在一台与网络隔离的机器上这行不通,此时可以把它指向一个存放 swagger-ui.css 和 swagger-ui-bundle.js 的本地文件夹,页面就会自行提供这些文件:
FOpenAPI.OpenAPIOptions.Endpoint.SwaggerUIAssetsPath := 'C:\www\swagger';
如果你想改为固定使用 CDN 上的某个特定版本,请设置 SwaggerUIBaseURL。把 ServeSwaggerUI 关闭会完全移除该页面,对生产环境部署来说这是一个合理的选择。
CORS:两套策略都要配置,而且取值要相同
这是最容易让人踩坑的部分,所以值得说得精确一些。服务器和 OpenAPI 引擎各自拥有一套 CORS 策略,它们分别负责一次跨域调用的不同一半:
- OPTIONS 预检始终由服务器作答,即使该路径是由引擎拥有的也一样。服务器会在插件被交予该请求之前就先应答它。
- 由引擎拥有的路径上,真正的响应由引擎打上响应头。只有当所有插件都拒绝了该请求之后,服务器才会加上自己的响应头。
正是这种分工使得响应头绝不会被输出两次,而浏览器会拒绝一个携带多个 Access-Control-Allow-Origin 的响应。但这也意味着,只启用两者之一才是真正会出问题的做法:
- 只启用服务器端:预检成功,但真正的响应不带任何 CORS 响应头,于是浏览器把它拦下。
- 只启用引擎端:引擎会为服务器的每一个路径应答预检,包括那些并不属于它的路径,而与此同时你手写的路由、
/health和/metrics的响应都不带响应头。
两者都要启用,而且取值要完全一致。预检批准了某个源,随后的响应却允许另一个源,同样会被拒绝:
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 端点共存于同一个端口上。
由于插件运行在认证关卡之后,服务器自身的认证依然有效;而多租户是在操作处理器运行之前解析的,因此在 OnRequest 中 FServer.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 下载页面下载最新构建版本。
