OpenAPI 내보내기
spider-silk-openapi는 app.routes()를 OpenAPI 3.1 문서로 바꾼다.
이 모듈은 그 목록을 읽는 리더이며 그 이상은 아니다.
core는 라우트를 평범한 데이터로 넘겨주므로 이 모듈을 위해 바뀔 것이 없다: PathPattern의 {deckId}는 이미 OpenAPI의 경로 템플릿 그 자체 이므로, 변환할 것도 리플렉션할 것도 없다.
경로는 라우터가 읽는 대로, 앞에 /를 붙이고 끝의 /는 떼어 쓰므로, get("decks", …)와 post("/decks/", …)는 /decks라는 한 항목을 함께 쓴다.
내보내기가 App의 메서드가 아니라 모듈인 데에는 이유가 하나 더 있다.
스펙 포맷은 웹 계층이 아니며, 이 프레임워크가 소유하지 않는 버전을 모든 애플리케이션이 의존하는 단 하나의 아티팩트 안에 고정해서는 안 된다.
같은 논리로 WebTest 하니스는 spider-silk-test에 있다.
설치
dependencies {
implementation 'net.benelog.spidersilk:spider-silk-openapi:1.1.0'
}
이 모듈은 core 외에 의존성을 더하지 않는다: core의 라우트 목록과 core의 Json 위에 놓인 클래스 하나다.
문서 서비스하기
app.get("/openapi.json", req -> {
JsonValue resBody = OpenApi.document("Flashcard API", "1.0.0", app.routes());
return WebResponse.json(resBody);
});
제목과 버전이 인자인 이유는 OpenAPI가 info 아래에 둘 다를 요구하기 때문이며, 어느 쪽이든 기본값을 두면 이 모듈이 남의 API 이름을 짓는 셈이 된다.
document는 JsonValue를 반환하므로 JSON API의 다른 모든 것과 결합된다.
빌드 시점에 파일로 쓰는 것도 같은 호출이다.
핸들러는 요청마다 app.routes()를 읽으므로, 이 줄 뒤에 추가된 라우트를 포함해 등록된 것은 무엇이든 나열한다.
어떤 라우트가 들어가는가
목록을 먼저 좁히지 않는 한 목록의 모든 라우트가 들어간다. API와 함께 HTML을 서비스하는 애플리케이션은 전부가 아니라 API의 라우트를 넘긴다:
List<Route> documented = app.routes().stream()
.filter(route -> route.path().startsWith("/api"))
.toList();
그 선택은 애플리케이션의 몫으로 남으며, 애초에 routes()가 목록을 돌려주는 이유도 이것이다.
이 모듈은 HTML 페이지와 엔드포인트를 구별할 방법이 없으며, 추측에 기댄 기본값은 어떤 애플리케이션에서는 반드시 틀린다.
패턴에 맨 * 세그먼트가 있는 라우트는 IllegalArgumentException을 던진다.
그 와일드카드에는 OpenAPI 경로 템플릿이 없고 거기에 넣을 이름도 없다.
그런 라우트를 조용히 빼 버리면 애플리케이션이 실제보다 적게 응답한다고 주장하는 문서를 발행하게 된다.
그래서 내보내기는 이를 빼는 대신 거부하며, 그런 라우트를 제외하려면 위의 필터 옆에 눈에 보이게 필터를 직접 쓴다.
이름 있는 꼬리는 거부되지 않고 문서에 들어간다.
/files/\{path*}는 경로 /files/{path}가 되고, 그 파라미터는 The rest of the path, slashes included.라는 설명을 가진다.
OpenAPI의 경로 템플릿에는 와일드카드가 없으므로, 별표는 core의 문법으로 남고 변수만 건너간다.
라우터에서와 마찬가지로, 세그먼트는 세그먼트 전체가 변수일 때만 변수다.
/files/*.txt는 라우터에게 리터럴 경로이며, 내보내기는 이를 그대로 쓴다.
/files/{name}.json도 리터럴이므로, 그 중괄호는 퍼센트 인코딩되어 /files/%7Bname%7D.json이 된다.
그대로 쓰면 이 경로는 어떤 파라미터도 선언하지 않는 변수를 가진 템플릿으로 읽힌다.
이름 있는 꼬리의 별표를 떼는 것과 리터럴의 중괄호를 인코딩하는 것이 이 모듈이 하는 유일한 변환이다.
{a}{b}처럼 이름이 비었거나 중괄호를 담은 변수는 템플릿이 그 이름을 쓸 방법이 없으므로 IllegalArgumentException을 던진다.
OpenAPI가 하나의 경로로 읽을 두 라우트도 둘 다의 이름을 담아 IllegalArgumentException을 던진다.
OpenAPI는 템플릿 경로를 세그먼트만으로 구별하므로, /decks/{deckId}와 /decks/{id}는 문서에 한 번만 나올 수 있는 하나의 경로다.
/files/{name}과 /files/\{name*}은 서로 다른 요청에 답하지만 템플릿 하나를 나눠 가지므로, 그 아래에서 한 operation이 다른 operation을 조용히 대체하게 된다.
문서에 담기는 것
모든 라우트는 등록 순서대로 그 경로 아래에서 소문자 메서드를 키로 하는 오퍼레이션이 된다.
경로의 모든 {name}은 string 타입의 필수 경로 파라미터가 되며, OpenAPI는 이를 선언하도록 요구한다.
각 오퍼레이션은 OK로 설명된 200 응답을 가진다.
설명과 함께 등록된 라우트는 이를 오퍼레이션의 summary로 가지며, 스펙 UI가 라우트 옆에 보여주는 필드가 바로 이것이다:
app.get("/api/decks", "List every deck", api::listDecks);
{"paths": {"/api/decks": {"get": {"summary": "List every deck", "responses": {"200": {"description": "OK"}}}}}}
설명 없이 등록된 라우트는 빈 줄로 렌더링될 빈 문자열 대신 summary를 아예 갖지 않는다.
문서에 담기는 것은 이것이 전부이며, 그래도 유효한 OpenAPI 문서다.
빠진 것은 의도적으로 빠진 것이다: 요청이나 응답 스키마, 서버 목록, 보안 스킴.
그중 어느 것도 라우트에서 도출할 수 없으므로 각각을 어딘가에 선언해야 한다.
이 모듈은 어느 것도 선언하지 않으므로 어디에 선언할지 정하지 않는다.
자동 HEAD와 OPTIONS 응답도 나열되지 않는데, routes()가 이들을 나열하지 않는 것과 같은 이유다.
테스트하기
document는 목록의 순수 함수이므로, 테스트에는 서버도 앱도 필요 없다:
JsonObject paths = OpenApi.document("Flashcard API", "1.0.0",
List.of(new Route("GET", "/api/decks/{deckId}/cards"))).asObject().getObject("paths");
assertThat(paths.getObject("/api/decks/{deckId}/cards").getObject("get").getArray("parameters"))
.hasSize(1);
Route는 레코드이므로 픽스처는 생성자 호출이다.
예제 앱의 문서는 /openapi.json에서 접근할 수 있다.
스펙 문서는 예제 앱에서 애플리케이션이 아닌 무언가가 읽도록 만든 유일한 리소스이므로, 예제는 CORS를 그 경로에만 적용한다.