Serwer REST + OpenAPI: API oparte na kontrakcie w Delphi | Blog eSeGeCe

Serwer REST + OpenAPI: API oparte na kontrakcie w Delphi

· Komponenty
sgcWebSockets REST server with OpenAPI integration

Pierwsze dwa artykuły budowały serwer REST ręcznie: porównujesz ARequestInfo.Document, rozgałęziasz kod według metody, sam parsujesz parametry. To działa i przy garstce punktów końcowych jest najkrótszą drogą. Powyżej pewnego rozmiaru to tablica tras staje się tym, co utrzymujesz, zamiast samego API.

TsgcWSAPIServer_OpenAPI podchodzi do tego inaczej. Piszesz dokument OpenAPI 3, podłączasz wtyczkę do serwera, a specyfikacja staje się routerem: dopasowuje ścieżki, wydobywa parametry ścieżki, waliduje żądanie, wymusza zadeklarowane schematy zabezpieczeń oraz serwuje zarówno sam dokument, jak i stronę Swagger UI. Twojemu kodowi zostaje ta część, która naprawdę jest Twoja, jedna procedura obsługi na operację.

Konfiguracja to jedno przypisanie

Wtyczka znajduje się w module sgcWebSocket_Server_API_OpenAPI. Ustawienie jej właściwości Server rejestruje ją w serwerze i od tej chwili otrzymuje ona każde żądanie HTTP, zanim uruchomi się 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;

Nie ma właściwości Active. To Server jest przełącznikiem: przypisanie go podłącza wtyczkę, ustawienie na nil ją odłącza, w obu przypadkach przy działającym serwerze. Po odłączeniu ścieżki należące do specyfikacji po prostu trafiają do Twojej zwykłej procedury obsługi.

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

Wczytywanie specyfikacji i jedna pułapka

Są trzy sposoby wczytania dokumentu i nie zachowują się one identycznie:

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

SpecFile jest wczytywany leniwie, przy pierwszym żądaniu, które nie jest ani punktem końcowym specyfikacji, ani stroną Swagger UI. Te dwa są obsługiwane, zanim nastąpi wczytanie, więc gdy ustawiony jest wyłącznie SpecFile, pierwsze GET /openapi/openapi.json zwraca puste ciało. Użyj LoadFromFile lub LoadFromString, gdy chcesz mieć kompletny dokument od pierwszego żądania, czyli prawie zawsze.

Co specyfikacja daje Ci za darmo

Minimalny dokument z dwiema operacjami:

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

Przy BasePath ustawionym na /openapi ten sam dokument daje cztery działające adresy URL:

URLObsługiwany przez
/openapi/openapi.jsondokument specyfikacji
/openapi/docsSwagger UI
/openapi/statusoperację getStatus
/openapi/users/aliceoperację getUser

Obsługa operacji

Rozdzielanie odbywa się według operationId, a nie według ścieżki czy metody. Zanim zgłosi się OnRequest, silnik dopasował już trasę i wypełnił parametry ścieżki, więc procedura obsługi odczytuje je po nazwie:

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;

Pozostawienie Handled jako False ma znaczenie: silnik odpowiada wtedy 501 Not Implemented, podając nazwę operacji. Operacja zadeklarowana w specyfikacji, ale jeszcze nienapisana, zgłasza dokładnie to, zamiast mylącego błędu 404.

Obiekt kontekstu niesie całe żądanie oraz metody pomocnicze odpowiedzi:

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 emituje dokument problemu zgodny z RFC 7807, więc kształt błędów jest spójny w całym API bez formatowania ich przez Ciebie.

Walidacja żądań na podstawie schematu

Walidacja jest domyślnie wyłączona. Włączenie flagi głównej bez ustawiania zakresu waliduje wszystko, co deklaruje specyfikacja:

FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;

Albo zawęź ją do tych części, które chcesz sprawdzać:

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

Żądanie, które nie przejdzie walidacji, otrzymuje odpowiedź 400 wraz z dokumentem problemu wymieniającym każdy błąd, jeszcze zanim uruchomi się Twoja procedura obsługi:

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

OnValidationError pozwala Ci przejrzeć błędy i zmienić decyzję. Jego parametr Continue przychodzi jako False, więc ustawienie go na True jest działaniem świadomym:

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;

Zabezpieczenia zadeklarowane w specyfikacji

Przy włączonym EnforceSecurity sekcja securitySchemes dokumentu jest stosowana do przychodzących żądań: klucze API w nagłówku, zapytaniu lub ciasteczku, HTTP Basic, tokeny bearer, OAuth2 oraz 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';

Tokeny bearer są weryfikowane względem JWTSecret. Sekret HMAC jest używany bez zmian; wartość zawierająca -----BEGIN jest traktowana jako klucz publiczny PEM i włącza algorytmy RSA oraz ECDSA. Pozostaw JWTSecret pusty, a token będzie sprawdzany wyłącznie pod kątem obecności, co jest właściwym ustawieniem, gdy chcesz zweryfikować go samodzielnie w OnValidateBearer:

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

Niepowodzenia kończą się odpowiedzią 401 albo 403, gdy żądanie zostało uwierzytelnione, a zabrakło mu jedynie zakresu. Istnieją odpowiadające im zdarzenia OnValidateAPIKey i OnValidateBasic.

Odpowiedzi próbne, zanim powstanie kod

Operacja bez procedury obsługi może zostać obsłużona na podstawie przykładów i schematów zawartych w samej specyfikacji, dzięki czemu zespół front endu może pracować, gdy implementacja jest dopiero pisana:

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

Zaimplementowane operacje nadal odpowiadają z Twojej procedury obsługi; tylko te nieobsłużone trafiają do mechanizmu odpowiedzi próbnych.

Swagger UI, także w trybie offline

Strona interfejsu jest serwowana pod adresem <BasePath>/docs i domyślnie pobiera swój CSS oraz JavaScript z publicznego CDN. Na maszynie odciętej od sieci to się nie sprawdzi, więc wskaż jej lokalny folder zawierający swagger-ui.css i swagger-ui-bundle.js, a strona sama je udostępni:

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

Aby zamiast tego przypiąć konkretną wersję z CDN, ustaw SwaggerUIBaseURL. Wyłączenie ServeSwaggerUI usuwa stronę całkowicie, co jest rozsądnym wyborem dla wdrożenia produkcyjnego.

CORS: skonfiguruj obie polityki, tymi samymi wartościami

To jedyna część, która sprawia ludziom kłopot, więc warto być precyzyjnym. Serwer i silnik OpenAPI mają każdy własną politykę CORS i odpowiadają za różne połowy wywołania międzyźródłowego:

Ten podział jest też powodem, dla którego nagłówki nigdy nie są emitowane dwukrotnie, a przeglądarka odrzuca odpowiedź niosącą Access-Control-Allow-Origin więcej niż raz. Oznacza to jednak, że to właśnie włączenie tylko jednej z dwóch polityk psuje sprawę:

Włącz obie, z identycznymi wartościami. Zapytanie wstępne zatwierdzające jedno źródło, po którym następuje odpowiedź zezwalająca na inne, zostanie odrzucone tak samo:

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;

Łączenie obu stylów

Wtyczka nie przejmuje serwera. Otrzymuje każde żądanie jako pierwsza i obsługuje wyłącznie ścieżki zadeklarowane w swojej specyfikacji; cała reszta trafia do OnCommandGet jak dotąd. Dzięki temu sekcja oparta na kontrakcie może żyć obok ręcznie napisanych tras, treści statycznych z DocumentRoot oraz punktów końcowych /health i /metrics z poprzedniego artykułu, wszystko na jednym porcie.

Ponieważ wtyczka działa po bramce uwierzytelniania, własne uwierzytelnianie serwera nadal obowiązuje, a wielodostępność jest rozpoznawana przed uruchomieniem procedury obsługi operacji, więc FServer.Tenant jest poprawny również wewnątrz OnRequest.

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

Kompletny serwer

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;

Pełny działający przykład, z magazynem użytkowników, wielodostępnością, metrykami i wtyczką OpenAPI na jednym serwerze, jest dostarczany jako demo REST Server w katalogu Demos\20.HTTP_Protocol\15.REST_Server.

Pobierz najnowszą wersję ze strony pobierania sgcWebSockets.