REST-Server + OpenAPI: Contract-First-APIs in Delphi | eSeGeCe Blog

REST-Server + OpenAPI: Contract-First-APIs in Delphi

· Komponenten
sgcWebSockets REST-Server mit OpenAPI-Integration

Die ersten beiden Artikel haben einen REST-Server von Hand gebaut: Sie vergleichen ARequestInfo.Document, Sie verzweigen über das Verb, Sie parsen die Parameter selbst. Das funktioniert, und für eine Handvoll Endpunkte ist es der kürzeste Weg. Ab einer gewissen Größe wird die Routing-Tabelle zu dem, was Sie pflegen, statt der API.

TsgcWSAPIServer_OpenAPI wählt den anderen Ansatz. Sie schreiben ein OpenAPI-3-Dokument, hängen das Plugin an den Server, und die Spezifikation wird zum Router: sie gleicht Pfade ab, extrahiert Pfadparameter, validiert den Request, erzwingt die deklarierten Sicherheitsverfahren und liefert sowohl das Dokument als auch eine Swagger-UI-Seite aus. Ihrem Code bleibt der Teil, der tatsächlich Ihrer ist, ein Handler pro Operation.

Die Verdrahtung ist eine einzige Zuweisung

Das Plugin liegt in sgcWebSocket_Server_API_OpenAPI. Das Setzen seiner Server-Eigenschaft registriert es beim Server, und von da an wird ihm jeder HTTP-Request angeboten, bevor OnCommandGet läuft.

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;

Es gibt keine Active-Eigenschaft. Server ist der Schalter: es zuzuweisen hängt das Plugin an, es auf nil zu setzen hängt es ab, beides im laufenden Betrieb des Servers. Abgehängt fallen die Pfade, die die Spezifikation besitzt, einfach an Ihren gewöhnlichen Handler durch.

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

Die Spezifikation laden, und eine Falle

Drei Wege, ein Dokument zu laden, und sie verhalten sich nicht gleich:

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

SpecFile wird verzögert geladen, beim ersten Request, der weder der Spezifikations-Endpunkt noch die Swagger-UI-Seite ist. Diese beiden werden beantwortet, bevor das Laden stattfindet, mit ausschließlich gesetztem SpecFile liefert das allererste GET /openapi/openapi.json also einen leeren Body. Verwenden Sie LoadFromFile oder LoadFromString, wenn das Dokument ab dem ersten Request vollständig sein soll, was fast immer der Fall ist.

Was Ihnen die Spezifikation geschenkt gibt

Ein minimales Dokument mit zwei Operationen:

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

Mit BasePath auf /openapi gesetzt erzeugt allein dieses Dokument vier funktionierende URLs:

URLAusgeliefert von
/openapi/openapi.jsondem Spezifikationsdokument
/openapi/docsSwagger UI
/openapi/statusOperation getStatus
/openapi/users/aliceOperation getUser

Operationen behandeln

Die Zuordnung erfolgt über die operationId, nicht über Pfad oder Verb. Wenn OnRequest feuert, hat die Engine die Route bereits abgeglichen und die Pfadparameter gefüllt, der Handler liest sie also über ihren Namen:

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 auf False zu belassen hat eine Bedeutung: die Engine antwortet dann mit 501 Not Implemented und nennt die Operation. Eine in der Spezifikation deklarierte, aber noch nicht geschriebene Operation meldet genau das, statt eines verwirrenden 404.

Das Kontextobjekt trägt den gesamten Request und die Antwort-Hilfsmethoden:

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 gibt ein Problemdokument nach RFC 7807 aus, Fehlerformate sind also über die ganze API hinweg einheitlich, ohne dass Sie sie formatieren.

Request-Validierung aus dem Schema

Die Validierung ist standardmäßig aus. Den Hauptschalter einzuschalten, ohne einen Umfang zu setzen, validiert alles, was die Spezifikation deklariert:

FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;

Oder schränken Sie es auf die Teile ein, die geprüft werden sollen:

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

Ein Request, der durchfällt, wird mit 400 und einem Problemdokument beantwortet, das jeden Fehler auflistet, noch bevor Ihr Handler läuft:

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

OnValidationError erlaubt Ihnen, die Fehler zu prüfen und die Entscheidung zu überstimmen. Sein Parameter Continue kommt als False an, ihn auf True zu setzen ist also ein bewusster Akt:

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;

In der Spezifikation deklarierte Sicherheit

Mit eingeschaltetem EnforceSecurity werden die securitySchemes des Dokuments auf eingehende Requests angewandt: API-Keys in einem Header, einer Query oder einem Cookie, HTTP Basic, Bearer-Tokens, OAuth2 und 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 werden gegen JWTSecret geprüft. Ein HMAC-Geheimnis wird unverändert verwendet; ein Wert, der -----BEGIN enthält, wird als öffentlicher PEM-Schlüssel behandelt und aktiviert die RSA- und ECDSA-Algorithmen. Lassen Sie JWTSecret leer, wird das Token nur auf Vorhandensein geprüft, was die richtige Einstellung ist, wenn Sie es in OnValidateBearer selbst validieren wollen:

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

Fehlschläge antworten mit 401, oder mit 403, wenn der Request zwar authentifiziert war, aber allein beim Scope zu kurz kam. Es gibt entsprechende Ereignisse OnValidateAPIKey und OnValidateBasic.

Mock-Antworten, bevor der Code existiert

Eine Operation ohne Handler kann aus den Beispielen und Schemata der Spezifikation selbst beantwortet werden, was ein Frontend-Team produktiv macht, während die Implementierung noch geschrieben wird:

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

Implementierte Operationen antworten weiterhin aus Ihrem Handler; nur die unbehandelten fallen an den Mock durch.

Swagger UI, auch offline

Die UI-Seite wird unter <BasePath>/docs ausgeliefert und bezieht ihr CSS und JavaScript standardmäßig von einem öffentlichen CDN. Auf einer Maschine ohne Netzanbindung geht das nicht, verweisen Sie sie deshalb auf einen lokalen Ordner mit swagger-ui.css und swagger-ui-bundle.js, und die Seite liefert sie selbst aus:

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

Um stattdessen eine bestimmte Version vom CDN festzulegen, setzen Sie SwaggerUIBaseURL. ServeSwaggerUI auszuschalten entfernt die Seite vollständig, was für ein Produktivsystem eine vernünftige Entscheidung ist.

CORS: beide Richtlinien konfigurieren, mit denselben Werten

Das ist der eine Punkt, über den Leute stolpern, deshalb lohnt sich Genauigkeit. Der Server und die OpenAPI-Engine besitzen jeweils eine eigene CORS-Richtlinie, und sie beantworten unterschiedliche Hälften eines Cross-Origin-Aufrufs:

Diese Aufteilung ist auch der Grund, warum Header nie doppelt ausgegeben werden, und ein Browser lehnt eine Antwort ab, die Access-Control-Allow-Origin mehr als einmal trägt. Sie bedeutet aber, dass genau dann etwas kaputtgeht, wenn Sie nur eines von beiden aktivieren:

Aktivieren Sie beide, mit identischen Werten. Ein Preflight, der einen Origin genehmigt, gefolgt von einer Antwort, die einen anderen erlaubt, wird genauso abgelehnt:

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 Stile mischen

Das Plugin übernimmt den Server nicht. Ihm wird jeder Request zuerst angeboten, und es beantwortet nur die Pfade, die seine Spezifikation deklariert; alles andere erreicht OnCommandGet wie bisher. So kann ein Contract-First-Bereich neben handgeschriebenen Routen, statischen Inhalten aus DocumentRoot und den /health- und /metrics-Endpunkten aus dem vorigen Artikel bestehen, alles auf einem Port.

Weil das Plugin nach der Authentifizierungsprüfung läuft, gilt die Authentifizierung des Servers weiterhin, und die Mandantenfähigkeit wird aufgelöst, bevor der Operations-Handler läuft, FServer.Tenant ist also auch innerhalb von OnRequest gültig.

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

Ein vollständiger 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;

Ein vollständiges lauffähiges Beispiel mit Benutzerspeicher, Mandantenfähigkeit, Metriken und dem OpenAPI-Plugin auf einem Server wird als REST Server-Demo unter Demos\20.HTTP_Protocol\15.REST_Server mitgeliefert.

Laden Sie den aktuellen Build von der sgcWebSockets Download-Seite herunter.