The first two articles built a REST server by hand: you compare ARequestInfo.Document, you branch on the verb, you parse the parameters yourself. That works, and for a handful of endpoints it is the shortest path. Past a certain size the routing table becomes the thing you maintain instead of the API.
TsgcWSAPIServer_OpenAPI takes the other approach. You write an OpenAPI 3 document, attach the plugin to the server, and the spec becomes the router: it matches paths, extracts path parameters, validates the request, enforces the declared security schemes, and serves both the document and a Swagger UI page. Your code is left with the part that is actually yours, one handler per operation.
The wiring is one assignment
The plugin lives in sgcWebSocket_Server_API_OpenAPI. Setting its Server property registers it with the server, and from then on it is offered every HTTP request before OnCommandGet runs.
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;
There is no Active property. Server is the switch: assigning it attaches the plugin, setting it to nil detaches it, both while the server keeps running. Detached, the paths the spec owns simply fall through to your ordinary handler.
FOpenAPI.Server := nil; // detach, server keeps running
Loading the spec, and one trap
Three ways to load a document, and they do not behave identically:
FOpenAPI.LoadFromFile('C:\api\petstore.json'); // parses immediately
FOpenAPI.LoadFromString(CS_SPEC); // parses immediately
FOpenAPI.OpenAPIOptions.Endpoint.SpecFile := 'C:\api\petstore.json'; // lazy
SpecFile is loaded lazily, on the first request that is neither the spec endpoint nor the Swagger UI page. Those two are answered before the load happens, so with only SpecFile set, the very first GET /openapi/openapi.json returns an empty body. Use LoadFromFile or LoadFromString when you want the document complete from the first request, which is almost always.
What the spec gives you for free
A minimal document with two operations:
{
"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" } } }
}
}
}
With BasePath set to /openapi, that document alone produces four working URLs:
| URL | Served by |
|---|---|
/openapi/openapi.json | the spec document |
/openapi/docs | Swagger UI |
/openapi/status | operation getStatus |
/openapi/users/alice | operation getUser |
Handling operations
Dispatch is by operationId, not by path or verb. By the time OnRequest fires the engine has already matched the route and filled in the path parameters, so the handler reads them by name:
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;
Leaving Handled as False is meaningful: the engine then answers 501 Not Implemented, naming the operation. An operation declared in the spec but not yet written reports exactly that, rather than a confusing 404.
The context object carries the whole request and the 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 emits an RFC 7807 problem document, so error shapes are consistent across the API without you formatting them.
Request validation from the schema
Validation is off by default. Turning on the master flag with no scope set validates everything the spec declares:
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
Or narrow it to the parts you want checked:
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
FOpenAPI.OpenAPIOptions.Validation.ValidatePathParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateQueryParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateRequestBody := False;
A request that fails is answered with 400 and a problem document listing every error, before your handler runs:
{"type":"about:blank","title":"Bad Request","status":400,
"detail":"Request validation failed",
"errors":["parameter 'limit' must be integer"]}
OnValidationError lets you inspect the failures and override the decision. Its Continue parameter arrives as False, so setting it to True is a deliberate act:
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;
Security declared in the spec
With EnforceSecurity on, the securitySchemes of the document are applied to incoming requests: API keys in a header, query or cookie, HTTP Basic, bearer tokens, OAuth2 and 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 are verified against JWTSecret. An HMAC secret is used as is; a value containing -----BEGIN is treated as a PEM public key and enables the RSA and ECDSA algorithms. Leave JWTSecret empty and the token is only checked for presence, which is the right setting when you want to validate it yourself in OnValidateBearer:
procedure TForm1.OpenAPIValidateBearer(Sender: TObject;
const aToken: string; const aContext: TsgcOpenAPIServerContext;
var Valid: Boolean);
begin
Valid := MyTokenService.Verify(aToken);
end;
Failures answer 401, or 403 when the request authenticated but fell short only on scope. There are matching OnValidateAPIKey and OnValidateBasic events.
Mock responses before the code exists
An operation with no handler can be answered from the spec's own examples and schemas, which makes a front end team productive while the implementation is still being written:
FOpenAPI.OpenAPIOptions.Mock.Enabled := True;
FOpenAPI.OpenAPIOptions.Mock.StatusCode := 200;
Implemented operations keep answering from your handler; only the unhandled ones fall through to the mock.
Swagger UI, including offline
The UI page is served on <BasePath>/docs and pulls its CSS and JavaScript from a public CDN by default. On an air-gapped machine that will not do, so point it at a local folder holding swagger-ui.css and swagger-ui-bundle.js and the page serves them itself:
FOpenAPI.OpenAPIOptions.Endpoint.SwaggerUIAssetsPath := 'C:\www\swagger';
To pin a specific version from the CDN instead, set SwaggerUIBaseURL. Turning ServeSwaggerUI off removes the page entirely, which is a reasonable choice for a production deployment.
CORS: configure both policies, with the same values
This is the one part that catches people out, so it is worth being precise. The server and the OpenAPI engine each own a CORS policy, and they answer different halves of a cross-origin call:
- The OPTIONS preflight is always answered by the server, even for a path the engine owns. The server serves it before the plugin is ever offered the request.
- The actual response on a path the engine owns is stamped by the engine. The server adds its own headers only once the plugins have declined the request.
That split is also why headers are never emitted twice, and a browser rejects a response carrying Access-Control-Allow-Origin more than once. But it means enabling only one of the two is what actually breaks:
- Server only: the preflight succeeds, then the real answer carries no CORS header and the browser blocks it.
- Engine only: the engine answers the preflight for every path of the server, including ones it does not own, while your handwritten routes,
/healthand/metricsanswer with no header.
Enable both, with identical values. A preflight approving one origin followed by a response allowing another is refused just the same:
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;
Mixing both styles
The plugin does not take the server over. It is offered each request first and answers only the paths its spec declares; everything else reaches OnCommandGet as before. So a contract-first section can live beside handwritten routes, static content from DocumentRoot, and the /health and /metrics endpoints from the previous article, all on one port.
Because the plugin runs after the authentication gate, the server's own authentication still applies, and multi-tenancy is resolved before the operation handler runs, so FServer.Tenant is valid inside OnRequest too.
procedure TForm1.OpenAPIRequest(Sender: TObject;
const aOperationId: string; const aContext: TsgcOpenAPIServerContext;
var Handled: Boolean);
begin
DoLog(aOperationId + ' tenant=' + FServer.Tenant);
...
end;
A 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;
A full working example, with the user store, tenancy, metrics and the OpenAPI plugin all on one server, ships as the REST Server demo under Demos\20.HTTP_Protocol\15.REST_Server.
Download the latest build from the sgcWebSockets download page.
