Os dois primeiros artigos construíram um servidor REST à mão: você compara ARequestInfo.Document, ramifica pelo verbo e analisa os parâmetros por conta própria. Isso funciona, e para um punhado de endpoints é o caminho mais curto. Acima de certo tamanho, a tabela de rotas passa a ser aquilo que você mantém, em vez da API.
O TsgcWSAPIServer_OpenAPI adota a outra abordagem. Você escreve um documento OpenAPI 3, anexa o plugin ao servidor, e a especificação vira o roteador: ela casa os caminhos, extrai os parâmetros de caminho, valida a requisição, aplica os esquemas de segurança declarados e serve tanto o documento quanto uma página Swagger UI. Ao seu código sobra a parte que é de fato sua, um manipulador por operação.
A ligação é uma única atribuição
O plugin está na unit sgcWebSocket_Server_API_OpenAPI. Definir a sua propriedade Server o registra no servidor e, a partir daí, toda requisição HTTP é oferecida a ele antes que OnCommandGet execute.
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;
Não existe propriedade Active. O Server é o interruptor: atribuí-lo anexa o plugin, defini-lo como nil o desanexa, ambos com o servidor continuando em execução. Desanexado, os caminhos que a especificação controla simplesmente caem no seu manipulador comum.
FOpenAPI.Server := nil; // detach, server keeps running
Carregando a especificação, e uma armadilha
Há três formas de carregar um documento, e elas não se comportam de maneira idêntica:
FOpenAPI.LoadFromFile('C:\api\petstore.json'); // parses immediately
FOpenAPI.LoadFromString(CS_SPEC); // parses immediately
FOpenAPI.OpenAPIOptions.Endpoint.SpecFile := 'C:\api\petstore.json'; // lazy
SpecFile é carregado de forma preguiçosa, na primeira requisição que não seja nem o endpoint da especificação nem a página do Swagger UI. Essas duas são respondidas antes de o carregamento acontecer, então, com apenas SpecFile definido, o primeiro GET /openapi/openapi.json retorna um corpo vazio. Use LoadFromFile ou LoadFromString quando quiser o documento completo desde a primeira requisição, o que é quase sempre o caso.
O que a especificação lhe dá de graça
Um documento mínimo com duas operações:
{
"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" } } }
}
}
}
Com BasePath definido como /openapi, esse documento sozinho produz quatro URLs funcionais:
| URL | Servida por |
|---|---|
/openapi/openapi.json | o documento da especificação |
/openapi/docs | Swagger UI |
/openapi/status | operação getStatus |
/openapi/users/alice | operação getUser |
Tratando operações
O despacho é por operationId, não por caminho ou verbo. Quando OnRequest dispara, o motor já casou a rota e preencheu os parâmetros de caminho, então o manipulador os lê pelo nome:
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;
Deixar Handled como False tem significado: o motor então responde 501 Not Implemented, nomeando a operação. Uma operação declarada na especificação mas ainda não escrita informa exatamente isso, em vez de um 404 confuso.
O objeto de contexto carrega toda a requisição e os auxiliares de resposta:
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 emite um documento de problema RFC 7807, de modo que os formatos de erro ficam consistentes em toda a API sem que você precise formatá-los.
Validação de requisições a partir do schema
A validação está desligada por padrão. Ligar o sinalizador principal sem definir escopo valida tudo o que a especificação declara:
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
Ou restrinja-a às partes que você quer verificar:
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
FOpenAPI.OpenAPIOptions.Validation.ValidatePathParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateQueryParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateRequestBody := False;
Uma requisição que falha é respondida com 400 e um documento de problema listando todos os erros, antes que o seu manipulador execute:
{"type":"about:blank","title":"Bad Request","status":400,
"detail":"Request validation failed",
"errors":["parameter 'limit' must be integer"]}
OnValidationError permite inspecionar as falhas e sobrepor a decisão. O seu parâmetro Continue chega como False, então defini-lo como True é um ato deliberado:
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;
Segurança declarada na especificação
Com EnforceSecurity ligado, os securitySchemes do documento são aplicados às requisições que chegam: chaves de API em um cabeçalho, na query ou em um cookie, HTTP Basic, tokens bearer, OAuth2 e 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';
Os tokens bearer são verificados contra JWTSecret. Um segredo HMAC é usado como está; um valor que contenha -----BEGIN é tratado como uma chave pública PEM e habilita os algoritmos RSA e ECDSA. Deixe JWTSecret vazio e o token só é verificado quanto à presença, que é a configuração correta quando você quer validá-lo por conta própria em OnValidateBearer:
procedure TForm1.OpenAPIValidateBearer(Sender: TObject;
const aToken: string; const aContext: TsgcOpenAPIServerContext;
var Valid: Boolean);
begin
Valid := MyTokenService.Verify(aToken);
end;
As falhas respondem 401, ou 403 quando a requisição se autenticou mas ficou aquém apenas no escopo. Existem eventos equivalentes OnValidateAPIKey e OnValidateBasic.
Respostas simuladas antes de o código existir
Uma operação sem manipulador pode ser respondida a partir dos próprios exemplos e schemas da especificação, o que deixa uma equipe de front end produtiva enquanto a implementação ainda está sendo escrita:
FOpenAPI.OpenAPIOptions.Mock.Enabled := True;
FOpenAPI.OpenAPIOptions.Mock.StatusCode := 200;
As operações implementadas continuam respondendo pelo seu manipulador; apenas as não tratadas caem na simulação.
Swagger UI, inclusive offline
A página da UI é servida em <BasePath>/docs e busca o seu CSS e JavaScript em uma CDN pública por padrão. Em uma máquina isolada da rede isso não serve, então aponte-a para uma pasta local contendo swagger-ui.css e swagger-ui-bundle.js e a página passa a servi-los ela mesma:
FOpenAPI.OpenAPIOptions.Endpoint.SwaggerUIAssetsPath := 'C:\www\swagger';
Para fixar uma versão específica da CDN em vez disso, defina SwaggerUIBaseURL. Desligar ServeSwaggerUI remove a página por completo, o que é uma escolha razoável para uma implantação em produção.
CORS: configure as duas políticas, com os mesmos valores
Essa é a parte que mais confunde as pessoas, então vale ser preciso. O servidor e o motor OpenAPI têm cada um a sua política de CORS, e eles respondem metades diferentes de uma chamada cross-origin:
- O preflight OPTIONS é sempre respondido pelo servidor, mesmo para um caminho que pertença ao motor. O servidor o atende antes de a requisição sequer ser oferecida ao plugin.
- A resposta real em um caminho que pertence ao motor é carimbada pelo motor. O servidor adiciona os seus próprios cabeçalhos apenas depois que os plugins recusaram a requisição.
Essa divisão também é o motivo pelo qual os cabeçalhos nunca são emitidos duas vezes, e um navegador rejeita uma resposta que traga Access-Control-Allow-Origin mais de uma vez. Mas isso significa que habilitar apenas um dos dois é o que de fato quebra:
- Apenas o servidor: o preflight tem sucesso, e então a resposta real não traz nenhum cabeçalho CORS e o navegador a bloqueia.
- Apenas o motor: o motor responde ao preflight de todos os caminhos do servidor, inclusive os que não lhe pertencem, enquanto as suas rotas escritas à mão,
/healthe/metricsrespondem sem cabeçalho.
Habilite os dois, com valores idênticos. Um preflight que aprova uma origem seguido de uma resposta que permite outra é recusado do mesmo jeito:
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;
Misturando os dois estilos
O plugin não toma conta do servidor. Cada requisição é oferecida a ele primeiro e ele responde apenas os caminhos que a sua especificação declara; todo o resto chega a OnCommandGet como antes. Assim, uma seção contract-first pode conviver com rotas escritas à mão, conteúdo estático vindo de DocumentRoot e os endpoints /health e /metrics do artigo anterior, tudo em uma única porta.
Como o plugin executa depois do controle de autenticação, a autenticação do próprio servidor continua valendo, e a multitenancy é resolvida antes de o manipulador da operação executar, então FServer.Tenant também é válido dentro de OnRequest.
procedure TForm1.OpenAPIRequest(Sender: TObject;
const aOperationId: string; const aContext: TsgcOpenAPIServerContext;
var Handled: Boolean);
begin
DoLog(aOperationId + ' tenant=' + FServer.Tenant);
...
end;
Um servidor completo
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;
Um exemplo completo e funcional, com o repositório de usuários, tenancy, métricas e o plugin OpenAPI todos em um único servidor, é distribuído como a demo REST Server em Demos\20.HTTP_Protocol\15.REST_Server.
Baixe a versão mais recente na página de download do sgcWebSockets.
