앞의 두 글에서는 REST 서버를 직접 손으로 만들었습니다. ARequestInfo.Document를 비교하고, 메서드에 따라 분기하고, 매개변수를 직접 파싱했습니다. 그 방식도 잘 동작하며, 엔드포인트가 몇 개뿐이라면 가장 빠른 길입니다. 하지만 규모가 일정 수준을 넘어서면 API가 아니라 라우팅 테이블이 관리 대상이 되어 버립니다.
TsgcWSAPIServer_OpenAPI는 다른 접근을 취합니다. OpenAPI 3 문서를 작성하고 플러그인을 서버에 연결하면, 그 명세가 곧 라우터가 됩니다. 경로를 매칭하고, 경로 매개변수를 추출하고, 요청을 검증하고, 선언된 보안 스킴을 적용하며, 문서와 Swagger UI 페이지를 함께 제공합니다. 여러분의 코드에는 정말로 여러분의 몫인 부분, 즉 오퍼레이션당 하나의 핸들러만 남습니다.
연결은 할당 한 줄
플러그인은 sgcWebSocket_Server_API_OpenAPI에 있습니다. Server 속성을 설정하면 서버에 등록되고, 그때부터 OnCommandGet이 실행되기 전에 모든 HTTP 요청을 먼저 전달받습니다.
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;
Active 속성은 없습니다. Server가 곧 스위치입니다. 할당하면 플러그인이 연결되고, nil로 설정하면 분리되며, 둘 다 서버가 계속 실행 중인 상태에서 가능합니다. 분리되면 명세가 담당하던 경로는 그대로 일반 핸들러로 넘어갑니다.
FOpenAPI.Server := nil; // detach, server keeps running
명세 로드, 그리고 한 가지 함정
문서를 로드하는 방법은 세 가지이며, 동작이 동일하지 않습니다.
FOpenAPI.LoadFromFile('C:\api\petstore.json'); // parses immediately
FOpenAPI.LoadFromString(CS_SPEC); // parses immediately
FOpenAPI.OpenAPIOptions.Endpoint.SpecFile := 'C:\api\petstore.json'; // lazy
SpecFile은 지연 로드되며, 명세 엔드포인트도 Swagger UI 페이지도 아닌 첫 요청에서 읽힙니다. 그 두 가지는 로드가 일어나기 전에 응답되므로, SpecFile만 설정한 상태에서는 맨 처음의 GET /openapi/openapi.json이 빈 본문을 반환합니다. 첫 요청부터 문서가 완전하기를 원한다면, 즉 거의 언제나, LoadFromFile이나 LoadFromString을 사용하세요.
명세가 거저 주는 것들
오퍼레이션 두 개를 가진 최소 문서입니다.
{
"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" } } }
}
}
}
BasePath를 /openapi로 설정하면 이 문서만으로 네 개의 동작하는 URL이 만들어집니다.
| URL | 제공 주체 |
|---|---|
/openapi/openapi.json | 명세 문서 |
/openapi/docs | Swagger UI |
/openapi/status | getStatus 오퍼레이션 |
/openapi/users/alice | getUser 오퍼레이션 |
오퍼레이션 처리
디스패치는 경로나 메서드가 아니라 operationId로 이루어집니다. OnRequest가 발생하는 시점에는 엔진이 이미 라우트를 매칭하고 경로 매개변수를 채워 넣었으므로, 핸들러는 이름으로 값을 읽으면 됩니다.
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를 False로 두는 것에는 의미가 있습니다. 그러면 엔진이 해당 오퍼레이션의 이름을 밝히며 501 Not Implemented로 응답합니다. 명세에는 선언되었지만 아직 구현하지 않은 오퍼레이션은 혼란스러운 404 대신 정확히 그 사실을 알려줍니다.
컨텍스트 객체는 요청 전체와 응답 헬퍼를 담고 있습니다.
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는 RFC 7807 문제 문서를 생성하므로, 직접 형식을 맞추지 않아도 API 전체에서 오류 형태가 일관됩니다.
스키마 기반 요청 검증
검증은 기본적으로 꺼져 있습니다. 범위를 지정하지 않고 마스터 플래그만 켜면 명세가 선언한 모든 것을 검증합니다.
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
또는 검사하고 싶은 부분으로 범위를 좁힐 수도 있습니다.
FOpenAPI.OpenAPIOptions.Validation.ValidateRequest := True;
FOpenAPI.OpenAPIOptions.Validation.ValidatePathParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateQueryParams := True;
FOpenAPI.OpenAPIOptions.Validation.ValidateRequestBody := False;
검증에 실패한 요청은 핸들러가 실행되기 전에 400과 모든 오류를 나열한 문제 문서로 응답됩니다.
{"type":"about:blank","title":"Bad Request","status":400,
"detail":"Request validation failed",
"errors":["parameter 'limit' must be integer"]}
OnValidationError를 사용하면 실패 내용을 살펴보고 결정을 뒤집을 수 있습니다. Continue 매개변수는 False로 전달되므로, True로 설정하는 것은 의도적인 행위입니다.
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;
명세에 선언된 보안
EnforceSecurity를 켜면 문서의 securitySchemes가 들어오는 요청에 적용됩니다. 헤더, 쿼리, 쿠키의 API 키, HTTP Basic, 베어러 토큰, OAuth2, 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';
베어러 토큰은 JWTSecret에 대해 검증됩니다. HMAC 시크릿은 그대로 사용되고, -----BEGIN을 포함하는 값은 PEM 공개 키로 취급되어 RSA와 ECDSA 알고리즘이 활성화됩니다. JWTSecret을 비워 두면 토큰은 존재 여부만 확인되며, 이는 OnValidateBearer에서 직접 검증하고 싶을 때 알맞은 설정입니다.
procedure TForm1.OpenAPIValidateBearer(Sender: TObject;
const aToken: string; const aContext: TsgcOpenAPIServerContext;
var Valid: Boolean);
begin
Valid := MyTokenService.Verify(aToken);
end;
실패하면 401로 응답하고, 인증은 되었지만 스코프만 부족한 경우에는 403으로 응답합니다. 대응하는 OnValidateAPIKey와 OnValidateBasic 이벤트도 있습니다.
코드가 존재하기 전의 목 응답
핸들러가 없는 오퍼레이션도 명세 자체의 예제와 스키마로 응답할 수 있어서, 구현이 아직 작성되는 동안에도 프런트엔드 팀이 작업을 진행할 수 있습니다.
FOpenAPI.OpenAPIOptions.Mock.Enabled := True;
FOpenAPI.OpenAPIOptions.Mock.StatusCode := 200;
구현된 오퍼레이션은 계속 여러분의 핸들러가 응답하고, 처리되지 않은 것만 목으로 넘어갑니다.
Swagger UI, 오프라인 포함
UI 페이지는 <BasePath>/docs에서 제공되며, 기본적으로 공개 CDN에서 CSS와 JavaScript를 가져옵니다. 망분리 환경에서는 그럴 수 없으므로, swagger-ui.css와 swagger-ui-bundle.js가 들어 있는 로컬 폴더를 지정하면 페이지가 직접 그 파일들을 제공합니다.
FOpenAPI.OpenAPIOptions.Endpoint.SwaggerUIAssetsPath := 'C:\www\swagger';
대신 CDN의 특정 버전을 고정하려면 SwaggerUIBaseURL을 설정하세요. ServeSwaggerUI를 끄면 페이지가 완전히 사라지며, 이는 운영 배포에서 합리적인 선택입니다.
CORS: 두 정책 모두, 같은 값으로 설정하세요
여기가 사람들이 자주 걸려 넘어지는 부분이니 정확하게 짚을 필요가 있습니다. 서버와 OpenAPI 엔진은 각각 자신의 CORS 정책을 가지고 있으며, 교차 출처 호출의 서로 다른 절반에 응답합니다.
- 엔진이 담당하는 경로라 하더라도 OPTIONS 프리플라이트는 언제나 서버가 응답합니다. 서버는 플러그인에 요청이 전달되기도 전에 프리플라이트를 처리합니다.
- 엔진이 담당하는 경로의 실제 응답에는 엔진이 헤더를 붙입니다. 서버는 플러그인들이 요청을 거절한 뒤에야 자신의 헤더를 추가합니다.
이런 분담 덕분에 헤더가 두 번 나가는 일이 없으며, 브라우저는 Access-Control-Allow-Origin이 두 번 이상 담긴 응답을 거부합니다. 하지만 그렇기 때문에 둘 중 하나만 활성화하는 것이 오히려 문제를 일으킵니다.
- 서버만 활성화: 프리플라이트는 성공하지만 실제 응답에 CORS 헤더가 없어서 브라우저가 차단합니다.
- 엔진만 활성화: 엔진이 자신이 담당하지 않는 경로까지 포함해 서버의 모든 경로에 대해 프리플라이트에 응답하는 반면, 손으로 작성한 라우트와
/health,/metrics는 헤더 없이 응답합니다.
둘 다, 동일한 값으로 활성화하세요. 한 오리진을 승인한 프리플라이트 뒤에 다른 오리진을 허용하는 응답이 오는 것도 마찬가지로 거부됩니다.
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;
두 방식 섞어 쓰기
플러그인이 서버를 장악하지는 않습니다. 각 요청을 먼저 전달받되 자신의 명세가 선언한 경로에만 응답하고, 나머지는 전과 같이 OnCommandGet에 도달합니다. 따라서 계약 우선 영역이 손으로 작성한 라우트, DocumentRoot의 정적 콘텐츠, 그리고 이전 글에서 다룬 /health와 /metrics 엔드포인트와 나란히 하나의 포트에서 공존할 수 있습니다.
플러그인은 인증 관문 이후에 실행되므로 서버 자체의 인증이 그대로 적용되고, 멀티 테넌시는 오퍼레이션 핸들러가 실행되기 전에 결정되므로 OnRequest 안에서도 FServer.Tenant가 유효합니다.
procedure TForm1.OpenAPIRequest(Sender: TObject;
const aOperationId: string; const aContext: TsgcOpenAPIServerContext;
var Handled: Boolean);
begin
DoLog(aOperationId + ' tenant=' + FServer.Tenant);
...
end;
완성된 서버
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;
사용자 저장소, 테넌시, 메트릭, OpenAPI 플러그인을 하나의 서버에 모두 올린 완전한 예제는 Demos\20.HTTP_Protocol\15.REST_Server에 REST Server 데모로 제공됩니다.
sgcWebSockets 다운로드 페이지에서 최신 빌드를 받으세요.
