Skip to content

Commit 8d6afd5

Browse files
authored
APIDOC - Base (#19)
* ApiDoc-1 RestDoc 적용하기 (#11) * test: RestDocs 세팅 * feat: swagger generate * docs: Swagger 관련하여 Readme 작성 * fix: BooJar build 에러 수정 * chore: CI에 Gradle Build 추가 * APIDOC-2 자체 Kotlin DSL 만들어 보기 with 토스 (#12) * test: RestDocs 세팅 * feat: swagger generate * docs: Swagger 관련하여 Readme 작성 * feat: Controller 테스트 DSL * docs: README에 토스 블로그 관련 이야기 추가 * fix: this 스코프로 인한 버그 수정 * fix: BooJar build 에러 수정 * chore: CI에 Gradle Build 추가 * feat: OpenApiTag Enum 추가 * feat: Enum Type 지원
1 parent 908c7eb commit 8d6afd5

File tree

13 files changed

+438
-14
lines changed

13 files changed

+438
-14
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Custom
22
.idea
3+
**/src/main/resources/static/swagger
34

45
# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,gradle,intellij+all
56
# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,gradle,intellij+all

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@
44
./gradlew addKtlintCheckGitPreCommitHook
55
```
66

7+
# Swagger
8+
9+
```text
10+
./gradlew copySwaggerUI
11+
```
12+
위 Task를 실행하면 크게 3가지 작업을 진행한다.
13+
1. Test 실행
14+
2. Controller 테스트 실행된 결과 snippet 을 저장
15+
3. snippet을 이용하여 Swagger 생성
16+
17+
Swagger 파일은 src/main/resources/static/swagger 아래에 저장된다.
18+
static import가 불편하고 RestDoc의 Kotlin DSL을 사용하면 정상적으로 작동하지 않아 자체적으로([토스 블로그](https://toss.tech/article/kotlin-dsl-restdocs)를 보면서) DSL을 만들었다.
19+
20+
### References
21+
22+
[RestDoc 세팅](https://techblog.woowahan.com/2597/)
23+
[Swagger 파일 생성](https://jwkim96.tistory.com/274)
24+
[토스 블로그 - Kotlin DSL을 이용하기](https://toss.tech/article/kotlin-dsl-restdocs)
25+
726
# Module
827

928
## 헥사고날 아키텍쳐

adapter-in/web/build.gradle.kts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,71 @@
1+
import groovy.lang.Closure
2+
import io.swagger.v3.oas.models.servers.Server
3+
import org.hidetake.gradle.swagger.generator.GenerateSwaggerUI
14
import org.springframework.boot.gradle.tasks.bundling.BootJar
25

6+
plugins {
7+
id("com.epages.restdocs-api-spec") version "0.18.0"
8+
id("org.hidetake.swagger.generator") version "2.19.2"
9+
}
10+
311
dependencies {
412
val springVersion by properties
13+
514
implementation(project(":support:yaml"))
615
implementation(project(":core"))
716

817
implementation("org.springframework.boot:spring-boot-starter-web:$springVersion")
918

1019
testImplementation("org.springframework.boot:spring-boot-starter-test:$springVersion")
20+
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc:3.0.0")
21+
testImplementation("com.epages:restdocs-api-spec-mockmvc:0.18.0")
22+
swaggerCodegen("io.swagger.codegen.v3:swagger-codegen-cli:3.0.42")
23+
swaggerUI("org.webjars:swagger-ui:4.18.2")
1124
}
1225

1326
tasks {
1427
withType<Jar> { enabled = false }
1528
withType<BootJar> {
1629
enabled = true
30+
dependsOn("copySwaggerUI")
1731
mainClass.set("yapp.rating.boardgame.WebApplicationKt")
1832
}
33+
34+
val generateSwaggerUIPrefix = "Api"
35+
openapi3 {
36+
setServers(
37+
listOf(
38+
toServer("http://localhost:8080"),
39+
)
40+
)
41+
title = "My API"
42+
description = "My API description"
43+
// tagDescriptionsPropertiesFile = "src/docs/tag-descriptions.yaml"
44+
version = "0.1.0"
45+
format = "yaml"
46+
}
47+
postman {
48+
title = "My API"
49+
version = "0.1.0"
50+
baseUrl = "https://localhost:8080"
51+
}
52+
swaggerSources {
53+
create(generateSwaggerUIPrefix).apply {
54+
setInputFile(file("${project.buildDir}/api-spec/openapi3.yaml"))
55+
}
56+
}
57+
withType<GenerateSwaggerUI> {
58+
dependsOn("openapi3")
59+
}
60+
register<Copy>("copySwaggerUI") {
61+
dependsOn("generateSwaggerUI$generateSwaggerUIPrefix")
62+
63+
val generateSwaggerUISampleTask =
64+
this@tasks.named<GenerateSwaggerUI>("generateSwaggerUI$generateSwaggerUIPrefix").get()
65+
66+
from("${generateSwaggerUISampleTask.outputDir}")
67+
into("src/main/resources/static/swagger")
68+
}
1969
}
70+
71+
fun toServer(url: String): Closure<Server> = closureOf<Server> { this.url = url } as Closure<Server>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package yapp.rating
2+
3+
import org.springframework.web.bind.annotation.GetMapping
4+
import org.springframework.web.bind.annotation.RequestMapping
5+
import org.springframework.web.bind.annotation.RestController
6+
7+
// TODO: Delete 테스트 코드 전용 클래스
8+
@RestController
9+
@RequestMapping("/v1/test")
10+
class TestController {
11+
12+
@GetMapping
13+
fun testGet(): TestResponse {
14+
return TestResponse("Good!")
15+
}
16+
17+
data class TestResponse(
18+
val value: String
19+
)
20+
}

adapter-in/web/src/main/kotlin/yapp/rating/boardgame/WebApplication.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package yapp.rating.boardgame
33
import org.springframework.boot.autoconfigure.SpringBootApplication
44
import org.springframework.boot.runApplication
55

6-
@SpringBootApplication
6+
@SpringBootApplication(scanBasePackages = ["yapp.rating"])
77
open class WebApplication
88

99
fun main(args: Array<String>) {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package yapp.rating
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import io.kotest.core.spec.style.FunSpec
5+
import org.springframework.restdocs.ManualRestDocumentation
6+
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation
7+
import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint
8+
import org.springframework.test.web.servlet.MockMvc
9+
import org.springframework.test.web.servlet.setup.MockMvcBuilders
10+
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder
11+
12+
// @ExtendWith(RestDocumentationExtension::class)
13+
abstract class ControllerTest : FunSpec() {
14+
protected abstract val controller: Any
15+
protected lateinit var mockMvc: MockMvc
16+
private set
17+
18+
private val restDocumentation = ManualRestDocumentation()
19+
20+
init {
21+
beforeSpec {
22+
setUpMockMvc()
23+
}
24+
beforeEach {
25+
restDocumentation.beforeTest(javaClass, it.name.testName)
26+
}
27+
afterEach {
28+
restDocumentation.afterTest()
29+
}
30+
}
31+
32+
private fun setUpMockMvc() {
33+
mockMvc = MockMvcBuilders.standaloneSetup(controller)
34+
.apply<StandaloneMockMvcBuilder>(
35+
MockMvcRestDocumentation.documentationConfiguration(restDocumentation)
36+
.uris().withScheme("http").withHost("localhost").and()
37+
.operationPreprocessors()
38+
.withRequestDefaults(prettyPrint())
39+
.withResponseDefaults(prettyPrint())
40+
)
41+
// .setControllerAdvice(ExceptionHandler())
42+
// .setCustomArgumentResolvers()
43+
// .setMessageConverters()
44+
.build()
45+
}
46+
47+
companion object {
48+
@JvmStatic
49+
protected val objectMapper = ObjectMapper()
50+
}
51+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package yapp.rating
2+
3+
import yapp.rating.base.ControllerTest
4+
import yapp.rating.base.NUMBER
5+
import yapp.rating.base.OpenApiTag
6+
import yapp.rating.base.STRING
7+
8+
class TestControllerTest : ControllerTest() {
9+
override val controller = TestController()
10+
11+
init {
12+
test("Get Test") {
13+
get("/v1/test") {}
14+
.isStatus(200)
15+
.makeDocument(
16+
DocumentInfo(identifier = "test", tag = OpenApiTag.TEST),
17+
responseFields(
18+
"value" type STRING means "English??",
19+
"test" type NUMBER means "이건 몬지몰라" isOptional true
20+
)
21+
)
22+
}
23+
}
24+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package yapp.rating.base
2+
3+
import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document
4+
import com.epages.restdocs.apispec.ResourceSnippetParametersBuilder
5+
import com.fasterxml.jackson.databind.ObjectMapper
6+
import io.kotest.core.spec.style.FunSpec
7+
import org.springframework.http.MediaType
8+
import org.springframework.restdocs.ManualRestDocumentation
9+
import org.springframework.restdocs.headers.HeaderDocumentation
10+
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation
11+
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete
12+
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get
13+
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch
14+
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post
15+
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put
16+
import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint
17+
import org.springframework.restdocs.payload.PayloadDocumentation.requestFields
18+
import org.springframework.restdocs.payload.PayloadDocumentation.responseFields
19+
import org.springframework.restdocs.payload.RequestFieldsSnippet
20+
import org.springframework.restdocs.payload.ResponseFieldsSnippet
21+
import org.springframework.restdocs.request.RequestDocumentation.pathParameters
22+
import org.springframework.restdocs.request.RequestDocumentation.queryParameters
23+
import org.springframework.restdocs.snippet.Snippet
24+
import org.springframework.test.web.servlet.MockMvc
25+
import org.springframework.test.web.servlet.ResultActions
26+
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
27+
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
28+
import org.springframework.test.web.servlet.setup.MockMvcBuilders
29+
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder
30+
31+
abstract class ControllerTest : FunSpec() {
32+
protected abstract val controller: Any
33+
34+
private val restDocumentation = ManualRestDocumentation()
35+
private lateinit var mockMvc: MockMvc
36+
37+
init {
38+
beforeSpec {
39+
setUpMockMvc()
40+
}
41+
beforeEach {
42+
restDocumentation.beforeTest(javaClass, it.name.testName)
43+
}
44+
afterEach {
45+
restDocumentation.afterTest()
46+
}
47+
}
48+
49+
private fun setUpMockMvc() {
50+
mockMvc = MockMvcBuilders.standaloneSetup(controller)
51+
.apply<StandaloneMockMvcBuilder>(
52+
MockMvcRestDocumentation.documentationConfiguration(restDocumentation)
53+
.uris().withScheme("http").withHost("localhost").and()
54+
.operationPreprocessors()
55+
.withRequestDefaults(prettyPrint())
56+
.withResponseDefaults(prettyPrint())
57+
)
58+
// .setControllerAdvice(ExceptionHandler())
59+
// .setCustomArgumentResolvers()
60+
// .setMessageConverters()
61+
.build()
62+
}
63+
64+
protected fun get(url: String, buildRequest: MockHttpServletRequestBuilder.() -> Unit): ResultActions =
65+
mockMvc.perform(get(url).apply(buildRequest))
66+
67+
protected fun get(
68+
url: String,
69+
vararg pathParams: String,
70+
buildRequest: MockHttpServletRequestBuilder.() -> Unit
71+
): ResultActions =
72+
mockMvc.perform(get(url, pathParams).apply(buildRequest))
73+
74+
protected fun post(url: String, buildRequest: MockHttpServletRequestBuilder.() -> Unit): ResultActions =
75+
mockMvc.perform(post(url).apply(buildRequest))
76+
77+
protected fun post(
78+
url: String,
79+
request: Any,
80+
buildRequest: MockHttpServletRequestBuilder.() -> Unit
81+
): ResultActions =
82+
mockMvc.perform(
83+
post(url).apply {
84+
contentType(MediaType.APPLICATION_JSON)
85+
content(objectMapper.writeValueAsString(request))
86+
}.apply(buildRequest)
87+
)
88+
89+
protected fun delete(url: String, buildRequest: MockHttpServletRequestBuilder.() -> Unit): ResultActions =
90+
mockMvc.perform(delete(url).apply(buildRequest))
91+
92+
protected fun patch(url: String, buildRequest: MockHttpServletRequestBuilder.() -> Unit): ResultActions =
93+
mockMvc.perform(patch(url).apply(buildRequest))
94+
95+
protected fun put(url: String, buildRequest: MockHttpServletRequestBuilder.() -> Unit): ResultActions =
96+
mockMvc.perform(put(url).apply(buildRequest))
97+
98+
protected fun ResultActions.isStatus(code: Int): ResultActions =
99+
andExpect(status().`is`(code))
100+
101+
protected fun ResultActions.makeDocument(
102+
documentInfo: DocumentInfo,
103+
vararg snippets: Snippet
104+
): ResultActions =
105+
andDo(
106+
document(
107+
identifier = documentInfo.identifier,
108+
resourceDetails = ResourceSnippetParametersBuilder()
109+
.description(documentInfo.description)
110+
.deprecated(documentInfo.deprecated)
111+
.tag(documentInfo.tag.value),
112+
snippets = snippets
113+
)
114+
)
115+
116+
protected infix fun String.type(fieldType: DocumentFieldType): DocumentField {
117+
return DocumentField(this, fieldType.type)
118+
}
119+
120+
protected infix fun <T : Enum<T>> String.type(fieldType: ENUM<T>): DocumentField {
121+
return DocumentField(this, fieldType.type, fieldType.enums)
122+
}
123+
124+
protected infix fun String.means(description: String): DocumentField {
125+
return DocumentField(this).apply {
126+
means(description)
127+
}
128+
}
129+
130+
protected fun requestHeaders(vararg fields: DocumentField) {
131+
HeaderDocumentation.requestHeaders(
132+
fields.map(DocumentField::toHeaderDescriptor).toList()
133+
)
134+
}
135+
136+
protected fun responseHeaders(vararg fields: DocumentField) {
137+
HeaderDocumentation.responseHeaders(
138+
fields.map(DocumentField::toHeaderDescriptor).toList()
139+
)
140+
}
141+
142+
protected fun pathParameters(vararg fields: DocumentField) {
143+
pathParameters(
144+
fields.map(DocumentField::toParameterDescriptor).toList()
145+
)
146+
}
147+
148+
protected fun queryParameters(vararg fields: DocumentField) {
149+
queryParameters(
150+
fields.map(DocumentField::toParameterDescriptor).toList()
151+
)
152+
}
153+
154+
protected fun requestFields(vararg fields: DocumentField): RequestFieldsSnippet =
155+
requestFields(
156+
fields.map(DocumentField::toFieldDescriptor).toList()
157+
)
158+
159+
protected fun responseFields(vararg fields: DocumentField): ResponseFieldsSnippet =
160+
responseFields(
161+
fields.map(DocumentField::toFieldDescriptor).toList()
162+
)
163+
164+
protected data class DocumentInfo(
165+
val identifier: String,
166+
val tag: OpenApiTag,
167+
val description: String? = null,
168+
val deprecated: Boolean = false,
169+
)
170+
171+
companion object {
172+
@JvmStatic
173+
protected val objectMapper = ObjectMapper()
174+
}
175+
}

0 commit comments

Comments
 (0)