Los dos primeros artículos construyeron un servidor REST a mano: se compara ARequestInfo.Document, se bifurca según el verbo, se analizan los parámetros uno mismo. Eso funciona, y para un puñado de endpoints es el camino más corto. A partir de cierto tamaño, la tabla de rutas se convierte en lo que uno mantiene en lugar de la API.
TsgcWSAPIServer_OpenAPI toma el otro enfoque. Usted escribe un documento OpenAPI 3, conecta el plugin al servidor y la especificación se convierte en el enrutador: casa las rutas, extrae los parámetros de ruta, valida la petición, aplica los esquemas de seguridad declarados y sirve tanto el documento como una página de Swagger UI. A su código le queda la parte que de verdad es suya, un manejador por operación.
Toda la configuración es una asignación
El plugin reside en sgcWebSocket_Server_API_OpenAPI. Al asignar su propiedad Server queda registrado en el servidor, y a partir de ese momento se le ofrece cada petición HTTP antes de que se ejecute 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;
No hay propiedad Active. Server es el interruptor: asignarla conecta el plugin, ponerla a nil lo desconecta, y ambas cosas con el servidor en marcha. Desconectado, las rutas que posee la especificación simplemente caen hacia su manejador habitual.
FOpenAPI.Server := nil; // detach, server keeps running
Cargar la especificación, y una trampa
Hay tres formas de cargar un documento, y no se comportan igual:
FOpenAPI.LoadFromFile('C:\api\petstore.json'); // parses immediately
FOpenAPI.LoadFromString(CS_SPEC); // parses immediately
FOpenAPI.OpenAPIOptions.Endpoint.SpecFile := 'C:\api\petstore.json'; // lazy
SpecFile se carga de forma perezosa, en la primera petición que no sea ni el endpoint de la especificación ni la página de Swagger UI. Esas dos se responden antes de que ocurra la carga, así que con solo SpecFile establecido, el primerísimo GET /openapi/openapi.json devuelve un cuerpo vacío. Use LoadFromFile o LoadFromString cuando quiera el documento completo desde la primera petición, que es casi siempre.
Lo que la especificación le da gratis
Un documento mínimo con dos operaciones:
{
"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" } } }
}
}
}
Con BasePath establecido en /openapi, ese documento por sí solo produce cuatro URLs funcionales:
| URL | Servido por |
|---|---|
/openapi/openapi.json | el documento de la especificación |
/openapi/docs | Swagger UI |
/openapi/status | la operación getStatus |
/openapi/users/alice | la operación getUser |
Gestionar las operaciones
El despacho se hace por operationId, no por ruta ni por verbo. Cuando se dispara OnRequest, el motor ya ha casado la ruta y ha rellenado los parámetros de ruta, de modo que el manejador los lee por nombre:
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;
Dejar Handled en False tiene significado: el motor responde entonces 501 Not Implemented, nombrando la operación. Una operación declarada en la especificación pero todavía no escrita informa exactamente de eso, en lugar de un confuso 404.
El objeto de contexto lleva la petición completa y los métodos auxiliares de respuesta:
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 un documento de problema RFC 7807, así que la forma de los errores es coherente en toda la API sin que usted tenga que darles formato.
Validación de la petición a partir del esquema
La validación está desactivada por defecto. Activar el indicador principal sin fijar ningún ámbito valida todo lo que declara la especificación:
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
O acótela a las partes que quiera comprobar:
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
FOpenAPI.OpenAPIOptions.Validation.ValidatePathParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateQueryParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateRequestBody := False;
Una petición que falla se responde con un 400 y un documento de problema que enumera todos los errores, antes de que se ejecute su manejador:
{"type":"about:blank","title":"Bad Request","status":400,
"detail":"Request validation failed",
"errors":["parameter 'limit' must be integer"]}
OnValidationError le permite inspeccionar los fallos y anular la decisión. Su parámetro Continue llega como False, así que ponerlo a True es un acto 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;
Seguridad declarada en la especificación
Con EnforceSecurity activado, los securitySchemes del documento se aplican a las peticiones entrantes: claves de API en una cabecera, en la query o en una cookie, HTTP Basic, tokens bearer, OAuth2 y 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';
Los tokens bearer se verifican contra JWTSecret. Un secreto HMAC se usa tal cual; un valor que contenga -----BEGIN se trata como una clave pública PEM y habilita los algoritmos RSA y ECDSA. Deje JWTSecret vacío y del token solo se comprobará su presencia, que es el ajuste adecuado cuando quiera validarlo usted mismo en OnValidateBearer:
procedure TForm1.OpenAPIValidateBearer(Sender: TObject;
const aToken: string; const aContext: TsgcOpenAPIServerContext;
var Valid: Boolean);
begin
Valid := MyTokenService.Verify(aToken);
end;
Los fallos responden 401, o 403 cuando la petición se autenticó pero se quedó corta únicamente en el scope. Existen los eventos equivalentes OnValidateAPIKey y OnValidateBasic.
Respuestas simuladas antes de que exista el código
Una operación sin manejador se puede responder a partir de los propios ejemplos y esquemas de la especificación, lo que hace productivo a un equipo de front end mientras la implementación todavía se está escribiendo:
FOpenAPI.OpenAPIOptions.Mock.Enabled := True;
FOpenAPI.OpenAPIOptions.Mock.StatusCode := 200;
Las operaciones implementadas siguen respondiendo desde su manejador; solo las no gestionadas caen hacia el mock.
Swagger UI, también sin conexión
La página de la interfaz se sirve en <BasePath>/docs y por defecto obtiene su CSS y su JavaScript de una CDN pública. En una máquina aislada de la red eso no sirve, así que apúntela a una carpeta local que contenga swagger-ui.css y swagger-ui-bundle.js y la propia página los servirá:
FOpenAPI.OpenAPIOptions.Endpoint.SwaggerUIAssetsPath := 'C:\www\swagger';
Para fijar en su lugar una versión concreta desde la CDN, establezca SwaggerUIBaseURL. Desactivar ServeSwaggerUI elimina la página por completo, que es una opción razonable para un despliegue en producción.
CORS: configure ambas políticas, con los mismos valores
Esta es la parte que más despista a la gente, así que conviene ser preciso. El servidor y el motor OpenAPI tienen cada uno su propia política CORS, y responden a mitades distintas de una llamada cross-origin:
- El preflight OPTIONS siempre lo responde el servidor, incluso para una ruta que pertenece al motor. El servidor lo sirve antes de que al plugin se le llegue a ofrecer la petición.
- La respuesta real en una ruta que pertenece al motor la sella el motor. El servidor añade sus propias cabeceras solo una vez que los plugins han rechazado la petición.
Esa división es también la razón por la que las cabeceras nunca se emiten dos veces, y un navegador rechaza una respuesta que lleva Access-Control-Allow-Origin más de una vez. Pero significa que habilitar solo una de las dos es lo que realmente rompe las cosas:
- Solo el servidor: el preflight funciona, luego la respuesta real no lleva ninguna cabecera CORS y el navegador la bloquea.
- Solo el motor: el motor responde al preflight de todas las rutas del servidor, incluidas las que no le pertenecen, mientras que sus rutas escritas a mano,
/healthy/metricsresponden sin cabecera.
Habilite las dos, con valores idénticos. Un preflight que aprueba un origen seguido de una respuesta que permite otro se rechaza igualmente:
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;
Mezclar ambos estilos
El plugin no se apodera del servidor. Se le ofrece cada petición en primer lugar y responde solo a las rutas que declara su especificación; todo lo demás llega a OnCommandGet como antes. Así, una sección contract-first puede convivir con rutas escritas a mano, con contenido estático desde DocumentRoot y con los endpoints /health y /metrics del artículo anterior, todo en un mismo puerto.
Como el plugin se ejecuta después de la barrera de autenticación, la propia autenticación del servidor sigue aplicándose, y la multi-tenencia se resuelve antes de que se ejecute el manejador de la operación, así que FServer.Tenant también es 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;
Un 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;
Un ejemplo completo y funcional, con el almacén de usuarios, la tenencia, las métricas y el plugin OpenAPI todo en un mismo servidor, se incluye como la demo REST Server en Demos\20.HTTP_Protocol\15.REST_Server.
Descargue la última versión desde la página de descargas de sgcWebSockets.
