지난 두 글에서 각각 REST Docs와 Swagger를 사용하여 API 명세서를 만들어보았습니다. 각각의 기술은 둘 다 장단점이 있었습니다.
adoc
파일 외에 사용자가 수동으로 만들어줘야 하는 adoc
문서가 있었습니다. 테스트 코드는 해당 API에 대해서만 adoc
문서를 생성해주므로 그 외에 목차라던가 전체적인 문서의 구조는 사용자가 직접 작성해야합니다.이번 글에서는 각각의 장점을 결합할 수 있도록 REST Docs와 Swagger를 통합하여 사용해보겠습니다.
이전 글에서 Swagger의 의존성을 추가할 때 springdoc-openapi
를 추가하였습니다. 이 라이브러리가 애플리케이션을 분석하여 OpenAPI 3로 변환하고 Swagger UI가 이를 기반으로 UI를 구성한다고 설명하였습니다.
비슷한 방법으로 REST Docs의 테스트 코드의 결과를 OpenAPI 3로 변환하고 Swagger UI를 사용하면 REST Docs와 Swagger를 같이 사용할 수 있습니다.
먼저 스프링 프로젝트를 생성합니다. https://start.spring.io 에서 아래와 같이 Spring Web, Lombok 의존성을 추가하여 생성합니다.
저번 예제와 동일하게 이번 예제에서도 API만 구현하므로 데이터베이스 관련 의존성은 추가하지 않았습니다.
개요에서 말한 REST Docs의 테스트 코드의 결과를 OpenAPI 3로 변환하는 과정은 restdocs-api-spec 라이브러리를 사용하여 수행합니다.
깃허브의 README.md를 참고하였습니다.
https://github.com/ePages-de/restdocs-api-spec
Spring REST Docs의 출력 결과는 AsciiDoc 문서입니다. 이 라이브러리는 해당 출력 결과를 API specifications으로 변환합니다. API specifications 중에서 OpenAPI 3.0.1 json, yaml 포맷을 지원합니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.5'
id 'io.spring.dependency-management' version '1.1.3'
id 'com.epages.restdocs-api-spec' version '0.18.4' // 1
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // 2
testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.4' // 3
}
tasks.named('test') {
useJUnitPlatform()
}
// 4
openapi3 {
servers = [
{ url = 'http://localhost:8080' },
{ url = 'http://production-api-server-url.com' }
]
title = 'Post Service API'
description = 'Post Service API description'
version = '1.0.0'
format = 'json'
}
restdocs-api-spec
Gradle 플러그인을 추가합니다. restdocs-api-spec-mockmvc
의존성을 추가합니다.이전 Spring REST Docs 게시글에서 사용한 예제를 그대로 사용합니다.
공통 응답 클래스입니다.
응답 결과 열거형입니다.
게시글 생성, 조회, 업데이트, 삭제 API 입니다.
비즈니스 로직입니다. 일단 null을 리턴하도록 작성하였습니다.
이전 Spring REST Docs 게시글에서 사용한 테스트 코드를 그대로 사용합니다. 기존에 작성한 테스트 코드는 아래와 같습니다.
기존에 작성된 Spring REST Docs 테스트 코드를 사용할 때 주의해야 할 점이 있습니다. 바로 document
메서드 입니다. 기존에 사용된 MockMvcRestDocumentation.document
메서드를 MockMvcRestDocumentationWrapper.document
메서드로 교체해야합니다.
현재 코드 상단의 import static
구문을 보면 import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
구문이 있습니다. 이 라인을 import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document
구문으로 교체해줍니다.
아래 명령어를 사용하여 openapi13 태스크를 실행시킵니다.
./gradlew openapi3
태스크가 성공적으로 수행되면 build/api-spec/openapi13.json
파일이 생성됩니다. 아래와 같이 생성됩니다.
{
"openapi" : "3.0.1",
"info" : {
"title" : "Post Service API",
"description" : "Post Service API description",
"version" : "1.0.0"
},
"servers" : [ {
"url" : "http://localhost:8080"
}, {
"url" : "http://production-api-server-url.com"
} ],
"tags" : [ ],
"paths" : {
"/posts" : {
"get" : {
"tags" : [ "posts" ],
"operationId" : "get-posts",
"parameters" : [ {
"name" : "page",
"in" : "query",
"description" : "페이지 번호",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"name" : "size",
"in" : "query",
"description" : "한 페이지의 데이터 개수",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"name" : "sort",
"in" : "query",
"description" : "정렬 파라미터,오름차순 또는 내림차순 +\nex) +\ncreatedDate,asc(작성일 오름차순) +\ncreatedDate,desc(작성일 내림차순)",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"200" : {
"description" : "200",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/posts-1056458637"
},
"examples" : {
"get-posts" : {
"value" : "{\n \"code\" : 0,\n \"message\" : \"성공\",\n \"data\" : {\n \"totalPages\" : 1,\n \"pageNumber\" : 1,\n \"pageSize\" : 10,\n \"totalElements\" : 1,\n \"posts\" : [ {\n \"id\" : 1,\n \"title\" : \"title1\",\n \"author\" : \"author1\",\n \"createdTime\" : \"2023-10-09 12:00:00\"\n } ]\n }\n}"
}
}
}
}
}
}
},
"post" : {
"tags" : [ "posts" ],
"operationId" : "create-posts",
"parameters" : [ {
"name" : "Authorization",
"in" : "header",
"description" : "AccessToken",
"required" : true,
"schema" : {
"type" : "string"
},
"example" : "Bearer {AccessToken}"
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/posts-id27737830"
},
"examples" : {
"create-posts" : {
"value" : "{\n \"title\" : \"title\",\n \"content\" : \"content\"\n}"
}
}
}
}
},
"responses" : {
"200" : {
"description" : "200",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/posts-1349496598"
},
"examples" : {
"create-posts" : {
"value" : "{\n \"code\" : 0,\n \"message\" : \"성공\",\n \"data\" : {\n \"id\" : 1\n }\n}"
}
}
}
}
}
}
}
},
"/posts/{id}" : {
"get" : {
"tags" : [ "posts" ],
"operationId" : "get-post",
"parameters" : [ {
"name" : "id",
"in" : "path",
"description" : "게시글 ID",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"200" : {
"description" : "200",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/posts-id1070811564"
},
"examples" : {
"get-post" : {
"value" : "{\n \"code\" : 0,\n \"message\" : \"성공\",\n \"data\" : {\n \"id\" : 1,\n \"title\" : \"title\",\n \"content\" : \"content\",\n \"author\" : \"author\",\n \"createdTime\" : \"2023-10-09 12:00:00\"\n }\n}"
}
}
}
}
}
}
},
"put" : {
"tags" : [ "posts" ],
"operationId" : "update-posts",
"parameters" : [ {
"name" : "id",
"in" : "path",
"description" : "게시글 ID",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"name" : "Authorization",
"in" : "header",
"description" : "AccessToken",
"required" : true,
"schema" : {
"type" : "string"
},
"example" : "Bearer {AccessToken}"
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/posts-id27737830"
},
"examples" : {
"update-posts" : {
"value" : "{\n \"title\" : \"title\",\n \"content\" : \"content\"\n}"
}
}
}
}
},
"responses" : {
"200" : {
"description" : "200",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/posts-id1650436776"
},
"examples" : {
"update-posts" : {
"value" : "{\n \"code\" : 0,\n \"message\" : \"성공\",\n \"data\" : null\n}"
}
}
}
}
}
}
},
"delete" : {
"tags" : [ "posts" ],
"operationId" : "delete-posts",
"parameters" : [ {
"name" : "id",
"in" : "path",
"description" : "게시글 ID",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"name" : "Authorization",
"in" : "header",
"description" : "AccessToken",
"required" : true,
"schema" : {
"type" : "string"
},
"example" : "Bearer {AccessToken}"
} ],
"responses" : {
"200" : {
"description" : "200",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/posts-id1650436776"
},
"examples" : {
"delete-posts" : {
"value" : "{\n \"code\" : 0,\n \"message\" : \"성공\",\n \"data\" : null\n}"
}
}
}
}
}
}
}
}
},
"components" : {
"schemas" : {
"posts-id27737830" : {
"required" : [ "content", "title" ],
"type" : "object",
"properties" : {
"title" : {
"type" : "string",
"description" : "게시글 제목"
},
"content" : {
"type" : "string",
"description" : "게시글 내용"
}
}
},
"posts-1056458637" : {
"required" : [ "code", "message" ],
"type" : "object",
"properties" : {
"code" : {
"type" : "number",
"description" : "상태 코드"
},
"data" : {
"required" : [ "pageNumber", "pageSize", "posts", "totalElements", "totalPages" ],
"type" : "object",
"properties" : {
"pageNumber" : {
"type" : "number",
"description" : "현재 페이지 번호"
},
"totalPages" : {
"type" : "number",
"description" : "검색 페이지 수"
},
"pageSize" : {
"type" : "number",
"description" : "한 페이지의 데이터 개수"
},
"posts" : {
"type" : "array",
"description" : "게시글 목록",
"items" : {
"required" : [ "author", "createdTime", "id", "title" ],
"type" : "object",
"properties" : {
"author" : {
"type" : "string",
"description" : "게시글 작성자"
},
"createdTime" : {
"type" : "string",
"description" : "게시글 생성일"
},
"id" : {
"type" : "number",
"description" : "게시글 ID"
},
"title" : {
"type" : "string",
"description" : "게시글 제목"
}
}
}
},
"totalElements" : {
"type" : "number",
"description" : "검색 데이터 개수"
}
}
},
"message" : {
"type" : "string",
"description" : "상태 메세지"
}
}
},
"posts-id1650436776" : {
"required" : [ "code", "data", "message" ],
"type" : "object",
"properties" : {
"code" : {
"type" : "number",
"description" : "상태 코드"
},
"message" : {
"type" : "string",
"description" : "상태 메세지"
}
}
},
"posts-1349496598" : {
"required" : [ "code", "message" ],
"type" : "object",
"properties" : {
"code" : {
"type" : "number",
"description" : "상태 코드"
},
"data" : {
"required" : [ "id" ],
"type" : "object",
"properties" : {
"id" : {
"type" : "number",
"description" : "생성된 게시글 ID"
}
}
},
"message" : {
"type" : "string",
"description" : "상태 메세지"
}
}
},
"posts-id1070811564" : {
"required" : [ "code", "message" ],
"type" : "object",
"properties" : {
"code" : {
"type" : "number",
"description" : "상태 코드"
},
"data" : {
"required" : [ "author", "content", "createdTime", "id", "title" ],
"type" : "object",
"properties" : {
"author" : {
"type" : "string",
"description" : "게시글 작성자"
},
"createdTime" : {
"type" : "string",
"description" : "게시글 생성일"
},
"id" : {
"type" : "number",
"description" : "게시글 ID"
},
"title" : {
"type" : "string",
"description" : "게시글 제목"
},
"content" : {
"type" : "string",
"description" : "게시글 내용"
}
}
},
"message" : {
"type" : "string",
"description" : "상태 메세지"
}
}
}
}
}
}
이번에는 Swagger UI를 스프링 애플리케이션에 통합하지 않고 도커를 통해 스프링 서버와 분리하여 실행해보겠습니다. 실제 서비스를 한다고 가정하면 Swagger UI는 따로 실행되는게 맞는 것 같습니다.
먼저 Swagger UI 서버는 따로 실행되므로 스프링 서버에 API 요청시 CORS 오류가 발생하므로 스프링 애플리케이션 코드에 CORS 설정을 추가합니다.
위에서 생성한 openapi13.json 파일을 적절한 디렉토리에 복사합니다.
아래 도커 명령어로 Swagger UI 컨테이너를 실행합니다.
docker run -d -p 80:8080 --name swagger -e SWAGGER_JSON=/tmp/openapi3.json -v {openapi13.json 파일이 위치한 디렉토리 경로}:/tmp swaggerapi/swagger-ui
http://localhost 에 접속하면 아래와 같이 Swagger UI가 출력됩니다.
Try it out 버튼을 클릭하여 실제 서버에 API 요청이 가능합니다. 아래처럼 응답을 확인할 수 있습니다.
전체 코드는 https://github.com/nefertirii/apidoc 에서 확인하실 수 있습니다.