Serveur REST + OpenAPI : des API contract-first en Delphi | Blog eSeGeCe

Serveur REST + OpenAPI : des API contract-first en Delphi

· Composants
Serveur REST sgcWebSockets avec intégration OpenAPI

Les deux premiers articles construisaient un serveur REST à la main : vous comparez ARequestInfo.Document, vous branchez sur le verbe, vous analysez les paramètres vous-même. Cela fonctionne, et pour une poignée d'endpoints c'est le chemin le plus court. Au-delà d'une certaine taille, la table de routage devient ce que vous maintenez à la place de l'API.

TsgcWSAPIServer_OpenAPI adopte l'approche inverse. Vous écrivez un document OpenAPI 3, vous attachez le plugin au serveur, et la spécification devient le routeur : elle fait correspondre les chemins, extrait les paramètres de chemin, valide la requête, applique les schémas de sécurité déclarés, et sert à la fois le document et une page Swagger UI. Il ne reste à votre code que la partie qui vous appartient vraiment, un gestionnaire par opération.

Le câblage tient en une affectation

Le plugin se trouve dans sgcWebSocket_Server_API_OpenAPI. Définir sa propriété Server l'enregistre auprès du serveur, et à partir de là chaque requête HTTP lui est proposée avant l'exécution de 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;

Il n'y a pas de propriété Active. Server est l'interrupteur : l'affecter attache le plugin, le mettre à nil le détache, et ce pendant que le serveur continue de tourner. Détaché, les chemins que la spécification possède retombent simplement sur votre gestionnaire ordinaire.

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

Charger la spécification, et un piège

Trois façons de charger un document, et elles ne se comportent pas de façon identique :

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

SpecFile est chargé paresseusement, à la première requête qui n'est ni l'endpoint de la spécification ni la page Swagger UI. Ces deux-là reçoivent leur réponse avant que le chargement n'ait lieu, donc avec seulement SpecFile défini, le tout premier GET /openapi/openapi.json renvoie un corps vide. Utilisez LoadFromFile ou LoadFromString lorsque vous voulez le document complet dès la première requête, ce qui est presque toujours le cas.

Ce que la spécification vous offre gratuitement

Un document minimal avec deux opérations :

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

Avec BasePath défini à /openapi, ce seul document produit quatre URL fonctionnelles :

URLServie par
/openapi/openapi.jsonle document de spécification
/openapi/docsSwagger UI
/openapi/statusl'opération getStatus
/openapi/users/alicel'opération getUser

Traiter les opérations

Le dispatch se fait par operationId, pas par chemin ni par verbe. Au moment où OnRequest se déclenche, le moteur a déjà fait correspondre la route et rempli les paramètres de chemin, si bien que le gestionnaire les lit par leur nom :

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;

Laisser Handled à False a un sens : le moteur répond alors 501 Not Implemented, en nommant l'opération. Une opération déclarée dans la spécification mais pas encore écrite le signale exactement ainsi, plutôt que par un 404 déroutant.

L'objet contexte porte toute la requête et les utilitaires de réponse :

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 émet un document de problème RFC 7807, ainsi les formes d'erreur sont cohérentes dans toute l'API sans que vous ayez à les mettre en forme.

Validation des requêtes à partir du schéma

La validation est désactivée par défaut. Activer l'indicateur maître sans définir de portée valide tout ce que la spécification déclare :

FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;

Ou restreignez-la aux parties que vous voulez contrôler :

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

Une requête qui échoue reçoit une réponse 400 accompagnée d'un document de problème énumérant chaque erreur, avant l'exécution de votre gestionnaire :

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

OnValidationError vous permet d'inspecter les échecs et de passer outre la décision. Son paramètre Continue arrive à False, le mettre à True est donc un acte délibéré :

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;

Sécurité déclarée dans la spécification

Avec EnforceSecurity activé, les securitySchemes du document sont appliqués aux requêtes entrantes : clés API dans un en-tête, une requête ou un cookie, HTTP Basic, jetons bearer, OAuth2 et 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';

Les jetons bearer sont vérifiés contre JWTSecret. Un secret HMAC est utilisé tel quel ; une valeur contenant -----BEGIN est traitée comme une clé publique PEM et active les algorithmes RSA et ECDSA. Laissez JWTSecret vide et le jeton n'est vérifié que sur sa présence, ce qui est le bon réglage lorsque vous voulez le valider vous-même dans OnValidateBearer :

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

Les échecs répondent 401, ou 403 lorsque la requête s'est authentifiée mais n'a échoué que sur la portée. Il existe des événements équivalents OnValidateAPIKey et OnValidateBasic.

Réponses simulées avant que le code n'existe

Une opération sans gestionnaire peut recevoir une réponse construite à partir des exemples et des schémas de la spécification elle-même, ce qui rend une équipe front end productive pendant que l'implémentation est encore en cours d'écriture :

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

Les opérations implémentées continuent de répondre depuis votre gestionnaire ; seules celles qui ne sont pas traitées retombent sur la simulation.

Swagger UI, y compris hors ligne

La page d'interface est servie sur <BasePath>/docs et tire ses CSS et son JavaScript d'un CDN public par défaut. Sur une machine isolée du réseau, cela ne convient pas, alors pointez-la vers un dossier local contenant swagger-ui.css et swagger-ui-bundle.js et la page les sert elle-même :

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

Pour épingler à la place une version précise depuis le CDN, définissez SwaggerUIBaseURL. Désactiver ServeSwaggerUI supprime entièrement la page, ce qui est un choix raisonnable pour un déploiement en production.

CORS : configurez les deux politiques, avec les mêmes valeurs

C'est la partie qui prend les gens au dépourvu, il vaut donc la peine d'être précis. Le serveur et le moteur OpenAPI possèdent chacun une politique CORS, et ils répondent à des moitiés différentes d'un appel cross-origin :

Cette répartition explique aussi pourquoi les en-têtes ne sont jamais émis deux fois, et un navigateur rejette une réponse portant plus d'une fois Access-Control-Allow-Origin. Mais cela signifie que n'activer qu'une seule des deux est ce qui casse réellement :

Activez les deux, avec des valeurs identiques. Un préflight approuvant une origine suivi d'une réponse en autorisant une autre est refusé tout autant :

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;

Mélanger les deux styles

Le plugin ne prend pas le contrôle du serveur. Chaque requête lui est proposée en premier et il ne répond qu'aux chemins déclarés par sa spécification ; tout le reste atteint OnCommandGet comme avant. Ainsi une section contract-first peut cohabiter avec des routes écrites à la main, du contenu statique servi depuis DocumentRoot, et les endpoints /health et /metrics de l'article précédent, le tout sur un seul port.

Comme le plugin s'exécute après la barrière d'authentification, l'authentification propre au serveur s'applique toujours, et la multi-location est résolue avant l'exécution du gestionnaire d'opération, donc FServer.Tenant est également valide dans OnRequest.

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

Un serveur complet

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 exemple complet et fonctionnel, avec le stockage d'utilisateurs, la multi-location, les métriques et le plugin OpenAPI tous sur un même serveur, est livré comme la démo REST Server sous Demos\20.HTTP_Protocol\15.REST_Server.

Téléchargez la dernière version depuis la page de téléchargement de sgcWebSockets.