REST Server + OpenAPI: API contract-first in Delphi | Blog eSeGeCe

REST Server + OpenAPI: API contract-first in Delphi

· Componenti
Server REST di sgcWebSockets con integrazione OpenAPI

I primi due articoli hanno costruito un server REST a mano: confronti ARequestInfo.Document, ti dirami sul verbo, analizzi tu stesso i parametri. Funziona, e per una manciata di endpoint è la strada più breve. Oltre una certa dimensione la tabella di instradamento diventa la cosa che manutieni al posto dell'API.

TsgcWSAPIServer_OpenAPI segue l'approccio opposto. Scrivi un documento OpenAPI 3, colleghi il plugin al server, e la specifica diventa il router: fa corrispondere i percorsi, estrae i parametri di percorso, valida la richiesta, applica gli schemi di sicurezza dichiarati e serve sia il documento sia una pagina Swagger UI. Al tuo codice resta la parte che è davvero tua, un gestore per operazione.

Il cablaggio è una sola assegnazione

Il plugin si trova in sgcWebSocket_Server_API_OpenAPI. Impostare la sua proprietà Server lo registra presso il server, e da quel momento in poi gli viene offerta ogni richiesta HTTP prima che venga eseguito 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;

Non esiste una proprietà Active. Server è l'interruttore: assegnarla collega il plugin, impostarla a nil lo scollega, in entrambi i casi mentre il server continua a funzionare. Una volta scollegato, i percorsi di cui la specifica è proprietaria semplicemente ricadono sul tuo gestore ordinario.

FOpenAPI.Server := nil;   // detach, server keeps running

Caricare la specifica, e una trappola

Ci sono tre modi per caricare un documento, e non si comportano in modo identico:

FOpenAPI.LoadFromFile('C:\api\petstore.json');   // parses immediately
FOpenAPI.LoadFromString(CS_SPEC);                // parses immediately
FOpenAPI.OpenAPIOptions.Endpoint.SpecFile := 'C:\api\petstore.json';  // lazy

SpecFile viene caricato in modo pigro, alla prima richiesta che non sia né l'endpoint della specifica né la pagina Swagger UI. Quelle due ricevono risposta prima che avvenga il caricamento, quindi con il solo SpecFile impostato, la primissima GET /openapi/openapi.json restituisce un corpo vuoto. Usa LoadFromFile o LoadFromString quando vuoi il documento completo fin dalla prima richiesta, cioè quasi sempre.

Cosa ti dà gratis la specifica

Un documento minimo con due operazioni:

{
  "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 impostato a /openapi, quel solo documento produce quattro URL funzionanti:

URLServito da
/openapi/openapi.jsonil documento della specifica
/openapi/docsSwagger UI
/openapi/statusoperazione getStatus
/openapi/users/aliceoperazione getUser

Gestire le operazioni

Il dispatch avviene per operationId, non per percorso o verbo. Nel momento in cui scatta OnRequest il motore ha già fatto corrispondere la rotta e riempito i parametri di percorso, quindi il gestore li legge per 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;

Lasciare Handled a False ha un significato preciso: il motore risponde allora 501 Not Implemented, indicando l'operazione. Un'operazione dichiarata nella specifica ma non ancora scritta segnala esattamente questo, invece di un 404 fuorviante.

L'oggetto contesto trasporta l'intera richiesta e i metodi di supporto per la risposta:

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 emette un problem document RFC 7807, così le forme degli errori sono coerenti in tutta l'API senza che tu debba formattarle.

Validazione delle richieste a partire dallo schema

La validazione è disattivata di default. Attivare il flag principale senza impostare alcun ambito valida tutto ciò che la specifica dichiara:

FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;

Oppure restringila alle parti che vuoi controllare:

FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
FOpenAPI.OpenAPIOptions.Validation.ValidatePathParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateQueryParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateRequestBody := False;

Una richiesta che non passa riceve una risposta 400 e un problem document che elenca ogni errore, prima che il tuo gestore venga eseguito:

{"type":"about:blank","title":"Bad Request","status":400,
 "detail":"Request validation failed",
 "errors":["parameter 'limit' must be integer"]}

OnValidationError ti permette di ispezionare i fallimenti e di ribaltare la decisione. Il suo parametro Continue arriva come False, quindi impostarlo a True è un atto deliberato:

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;

Sicurezza dichiarata nella specifica

Con EnforceSecurity attivo, i securitySchemes del documento vengono applicati alle richieste in arrivo: API key in un header, in query o in un cookie, HTTP Basic, bearer token, 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';

I bearer token vengono verificati rispetto a JWTSecret. Un segreto HMAC viene usato così com'è; un valore che contiene -----BEGIN viene trattato come una chiave pubblica PEM e abilita gli algoritmi RSA ed ECDSA. Lascia JWTSecret vuoto e il token viene controllato solo per la presenza, che è l'impostazione giusta quando vuoi validarlo tu stesso in OnValidateBearer:

procedure TForm1.OpenAPIValidateBearer(Sender: TObject;
  const aToken: string; const aContext: TsgcOpenAPIServerContext;
  var Valid: Boolean);
begin
  Valid := MyTokenService.Verify(aToken);
end;

I fallimenti rispondono 401, oppure 403 quando la richiesta si è autenticata ma è risultata insufficiente solo sullo scope. Esistono gli eventi corrispondenti OnValidateAPIKey e OnValidateBasic.

Risposte mock prima che il codice esista

A un'operazione senza gestore si può rispondere partendo dagli esempi e dagli schemi della specifica stessa, il che rende produttivo un team front end mentre l'implementazione è ancora in scrittura:

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

Le operazioni implementate continuano a rispondere dal tuo gestore; solo quelle non gestite ricadono sul mock.

Swagger UI, anche offline

La pagina della UI viene servita su <BasePath>/docs e preleva di default il proprio CSS e JavaScript da un CDN pubblico. Su una macchina isolata dalla rete questo non va bene, quindi puntala a una cartella locale che contenga swagger-ui.css e swagger-ui-bundle.js e la pagina li servirà da sé:

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

Per fissare invece una versione specifica dal CDN, imposta SwaggerUIBaseURL. Disattivare ServeSwaggerUI rimuove del tutto la pagina, il che è una scelta ragionevole per un deployment di produzione.

CORS: configura entrambe le policy, con gli stessi valori

Questa è la parte che trae in inganno più spesso, quindi vale la pena essere precisi. Il server e il motore OpenAPI possiedono ciascuno una policy CORS, e rispondono a metà diverse di una chiamata cross-origin:

Questa separazione è anche il motivo per cui gli header non vengono mai emessi due volte, e un browser rifiuta una risposta che porta Access-Control-Allow-Origin più di una volta. Ma significa che abilitare solo uno dei due è ciò che rompe davvero le cose:

Abilitali entrambi, con valori identici. Un preflight che approva un'origine seguito da una risposta che ne consente un'altra viene rifiutato allo stesso modo:

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;

Mescolare i due stili

Il plugin non prende il controllo del server. Gli viene offerta prima ogni richiesta e risponde solo ai percorsi che la sua specifica dichiara; tutto il resto raggiunge OnCommandGet come prima. Così una sezione contract-first può convivere accanto a rotte scritte a mano, contenuto statico da DocumentRoot, e gli endpoint /health e /metrics dell'articolo precedente, tutto su una sola porta.

Poiché il plugin viene eseguito dopo il controllo di autenticazione, l'autenticazione propria del server continua ad applicarsi, e la multi-tenancy viene risolta prima che il gestore dell'operazione venga eseguito, quindi FServer.Tenant è valida anche dentro OnRequest.

procedure TForm1.OpenAPIRequest(Sender: TObject;
  const aOperationId: string; const aContext: TsgcOpenAPIServerContext;
  var Handled: Boolean);
begin
  DoLog(aOperationId + ' tenant=' + FServer.Tenant);
  ...
end;

Un server 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 esempio completo e funzionante, con l'archivio utenti, la tenancy, le metriche e il plugin OpenAPI tutti su un solo server, viene distribuito come demo REST Server sotto Demos\20.HTTP_Protocol\15.REST_Server.

Scarica l'ultima build dalla pagina di download di sgcWebSockets.