REST-server + OpenAPI: contract-first API's in Delphi | eSeGeCe Blog

REST-server + OpenAPI: contract-first API's in Delphi

· Componenten
sgcWebSockets REST server with OpenAPI integration

De eerste twee artikelen bouwden een REST-server met de hand: u vergelijkt ARequestInfo.Document, u vertakt op de methode, u parseert de parameters zelf. Dat werkt, en voor een handvol endpoints is het de kortste weg. Voorbij een bepaalde omvang wordt de routeringstabel het ding dat u onderhoudt in plaats van de API.

TsgcWSAPIServer_OpenAPI kiest de andere aanpak. U schrijft een OpenAPI 3-document, koppelt de plug-in aan de server, en de spec wordt de router: hij matcht paden, haalt padparameters eruit, valideert het request, dwingt de gedeclareerde beveiligingsschema's af, en bedient zowel het document als een Swagger UI-pagina. Voor uw code blijft het deel over dat werkelijk van u is, één handler per operatie.

De bedrading is één toewijzing

De plug-in zit in sgcWebSocket_Server_API_OpenAPI. Het instellen van de eigenschap Server registreert hem bij de server, en vanaf dat moment krijgt hij elk HTTP-request aangeboden voordat OnCommandGet draait.

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;

Er is geen Active-eigenschap. Server is de schakelaar: hem toewijzen koppelt de plug-in, hem op nil zetten ontkoppelt hem, allebei terwijl de server blijft draaien. Ontkoppeld vallen de paden die de spec bezit gewoon door naar uw gewone handler.

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

De spec laden, en één valkuil

Drie manieren om een document te laden, en ze gedragen zich niet hetzelfde:

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

SpecFile wordt lui geladen, bij het eerste request dat noch het spec-endpoint noch de Swagger UI-pagina is. Die twee worden beantwoord voordat het laden gebeurt, dus met alleen SpecFile ingesteld geeft de allereerste GET /openapi/openapi.json een lege body terug. Gebruik LoadFromFile of LoadFromString wanneer u het document vanaf het eerste request compleet wilt hebben, wat vrijwel altijd het geval is.

Wat de spec u gratis geeft

Een minimaal document met twee operaties:

{
  "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" } } }
    }
  }
}

Met BasePath ingesteld op /openapi levert dat document alleen al vier werkende URL's op:

URLBediend door
/openapi/openapi.jsonhet spec-document
/openapi/docsSwagger UI
/openapi/statusoperatie getStatus
/openapi/users/aliceoperatie getUser

Operaties afhandelen

De dispatch gaat via operationId, niet via pad of methode. Tegen de tijd dat OnRequest afgaat heeft de engine de route al gematcht en de padparameters ingevuld, dus de handler leest ze op naam:

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 op False laten staan is betekenisvol: de engine antwoordt dan 501 Not Implemented, met vermelding van de operatie. Een operatie die in de spec gedeclareerd is maar nog niet geschreven, meldt precies dat, in plaats van een verwarrende 404.

Het context-object draagt het volledige request en de response-helpers:

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 geeft een RFC 7807 problem-document af, zodat foutvormen consistent zijn over de hele API zonder dat u ze opmaakt.

Requestvalidatie vanuit het schema

Validatie staat standaard uit. De hoofdvlag aanzetten zonder verdere scope valideert alles wat de spec declareert:

FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;

Of beperk het tot de onderdelen die u gecontroleerd wilt hebben:

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

Een request dat faalt wordt beantwoord met een 400 en een problem-document waarin elke fout wordt opgesomd, voordat uw handler draait:

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

OnValidationError laat u de fouten inspecteren en de beslissing overrulen. De parameter Continue komt binnen als False, dus hem op True zetten is een bewuste handeling:

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;

Beveiliging gedeclareerd in de spec

Met EnforceSecurity aan worden de securitySchemes van het document toegepast op binnenkomende requests: API-sleutels in een header, query of cookie, HTTP Basic, bearer-tokens, OAuth2 en 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-tokens worden geverifieerd tegen JWTSecret. Een HMAC-geheim wordt gebruikt zoals het is; een waarde die -----BEGIN bevat wordt behandeld als een PEM-publieke sleutel en schakelt de RSA- en ECDSA-algoritmen in. Laat JWTSecret leeg en er wordt alleen gecontroleerd of het token aanwezig is, wat de juiste instelling is wanneer u het zelf wilt valideren in OnValidateBearer:

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

Mislukkingen antwoorden met 401, of met 403 wanneer het request wel geauthenticeerd is maar alleen op scope tekortschoot. Er zijn bijpassende OnValidateAPIKey- en OnValidateBasic-events.

Mock-antwoorden voordat de code bestaat

Een operatie zonder handler kan beantwoord worden vanuit de voorbeelden en schema's van de spec zelf, waardoor een front-endteam productief is terwijl de implementatie nog geschreven wordt:

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

Geïmplementeerde operaties blijven vanuit uw handler antwoorden; alleen de niet-afgehandelde vallen door naar de mock.

Swagger UI, ook offline

De UI-pagina wordt bediend op <BasePath>/docs en haalt haar CSS en JavaScript standaard van een publieke CDN. Op een machine zonder netwerkverbinding voldoet dat niet, dus wijs hem naar een lokale map met swagger-ui.css en swagger-ui-bundle.js en de pagina bedient ze zelf:

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

Wilt u in plaats daarvan een specifieke versie van de CDN vastzetten, stel dan SwaggerUIBaseURL in. ServeSwaggerUI uitzetten verwijdert de pagina volledig, wat een redelijke keuze is voor een productie-uitrol.

CORS: configureer beide policies, met dezelfde waarden

Dit is het ene onderdeel waar mensen over struikelen, dus het loont om precies te zijn. De server en de OpenAPI-engine hebben elk hun eigen CORS-policy, en ze beantwoorden verschillende helften van een cross-origin-aanroep:

Die splitsing is ook de reden dat headers nooit twee keer worden uitgestuurd, en een browser weigert een antwoord dat Access-Control-Allow-Origin meer dan eens draagt. Maar het betekent wel dat slechts één van de twee inschakelen juist is wat het stukmaakt:

Schakel beide in, met identieke waarden. Een preflight die de ene origin goedkeurt, gevolgd door een antwoord dat een andere toestaat, wordt net zo goed geweigerd:

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;

Beide stijlen mengen

De plug-in neemt de server niet over. Hij krijgt elk request als eerste aangeboden en beantwoordt alleen de paden die zijn spec declareert; al het overige bereikt OnCommandGet zoals voorheen. Zo kan een contract-first-gedeelte naast handgeschreven routes leven, naast statische content uit DocumentRoot, en naast de /health- en /metrics-endpoints uit het vorige artikel, allemaal op één poort.

Omdat de plug-in na de authenticatiepoort draait, geldt de eigen authenticatie van de server nog steeds, en multi-tenancy wordt bepaald voordat de operatiehandler draait, dus FServer.Tenant is ook binnen OnRequest geldig.

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

Een complete server

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;

Een volledig werkend voorbeeld, met de user store, tenancy, metrics en de OpenAPI-plug-in allemaal op één server, wordt meegeleverd als de REST Server-demo onder Demos\20.HTTP_Protocol\15.REST_Server.

Download de nieuwste build van de sgcWebSockets-downloadpagina.