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:
| URL | Obsługiwany przez |
|---|---|
/openapi/openapi.json | dokument specyfikacji |
/openapi/docs | Swagger UI |
/openapi/status | operację getStatus |
/openapi/users/alice | operację 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:
- Zapytanie wstępne OPTIONS zawsze obsługuje serwer, nawet dla ścieżki należącej do silnika. Serwer obsługuje je, zanim żądanie w ogóle trafi do wtyczki.
- Właściwą odpowiedź na ścieżce należącej do silnika stempluje silnik. Serwer dodaje własne nagłówki dopiero wtedy, gdy wtyczki odrzucą żądanie.
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ę:
- Tylko serwer: zapytanie wstępne kończy się powodzeniem, po czym właściwa odpowiedź nie niesie żadnego nagłówka CORS i przeglądarka ją blokuje.
- Tylko silnik: silnik obsługuje zapytanie wstępne dla każdej ścieżki serwera, także tych, które do niego nie należą, podczas gdy Twoje ręcznie napisane trasy oraz
/healthi/metricsodpowiadają bez nagłówka.
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.
