Servidor REST + OpenAPI: APIs contract-first en Delphi | eSeGeCe Blog

Servidor REST + OpenAPI: APIs contract-first en Delphi

· Componentes
Servidor REST de sgcWebSockets con integración OpenAPI

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:

URLServido por
/openapi/openapi.jsonel documento de la especificación
/openapi/docsSwagger UI
/openapi/statusla operación getStatus
/openapi/users/alicela 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:

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:

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.