해당 내용은 토스의 # Kotlin으로 DSL 만들기: 반복적이고 지루한 REST Docs 벗어나기 아티클에서 아이디어를 받아서 적용한 내용입니다.
그리고, 기초적인 코틀린 문법에 대해서도 다룹니다.
DSL 또는 코틀린에서 잘못된 지식이 있다면 댓글이나joyson5582@gmail.com
로 남겨주세요.
기존 프로젝트에서는 SpringDoc 을 사용해 어노테이션 기반 문서화를 했었습니다.
아래 두가지와 같은 점 때문인데요
하지만, 이번에는 RestDocs 만의 장/단점을 느끼고 싶어서 사용을 해봤습니다.
느낀 장점으로는
adoc
) 에 문서를 작성해서 관심사가 의도적으로 분리된다. ( 테스트 - 문서 )하지만, 이런 장점과 함께 불편한 점들도 많이 느껴졌습니다.
private fun createRequest(
purchaseType: String = "CARD",
currency: String = "KRW",
amount: Int,
paymentKey: String,
orderId: String,
lottoPublishId: Long
): Map<String, Any> {
return mapOf(
"purchaseHttpRequest" to mapOf(
"purchaseType" to purchaseType,
"currency" to currency,
"amount" to amount,
"paymentKey" to paymentKey,
"orderId" to orderId
),
"lottoPublishId" to lottoPublishId
)
}
이와같이 RestAssured 에 넣을 요청을 만든다고 할 때?
purchaseHttpRequest
라는 객체에 purchase
,currency
,amount
와 같은 요소들이 들어갑니다.
그리고, 각 파라미터의 타입(String,Int) 를 통해 타입을 추론 가능합니다.
하지만, 필드 검증 및 문서화를 할 때는?
private fun commonRequestFields() = requestFields(
fieldWithPath("purchaseHttpRequest").type(JsonFieldType.OBJECT).description("구매 요청 정보"),
fieldWithPath("purchaseHttpRequest.purchaseType").type(JsonFieldType.STRING)
.description("구매 유형 (CARD, CASH 등)"),
fieldWithPath("purchaseHttpRequest.currency").type(JsonFieldType.STRING)
.description("결제 통화 (KRW, USD 등)"),
fieldWithPath("purchaseHttpRequest.amount").type(JsonFieldType.NUMBER).description("결제 금액"),
fieldWithPath("purchaseHttpRequest.paymentKey").type(JsonFieldType.STRING)
.description("결제 키 (결제 시스템에서 제공)"),
fieldWithPath("purchaseHttpRequest.orderId").type(JsonFieldType.STRING)
.description("주문 ID (결제 시스템에서 제공)"),
fieldWithPath("lottoPublishId").type(JsonFieldType.NUMBER).description("퍼블리싱 한 로또 번호"),
)
purchaseHttpRequest
Prefix 를 붙여야 함대부분의 로직은 Body 에서 끝나지만, 가끔 Parameter 나 Header 를 설정할 필요가 존재합니다.
RestAssuredRestDocumentation.document(
requestHeaders(headerWithName("Authorization").description("Basic auth credentials"))
queryParameters(parameterWithName("page").description("받는 페이지 번호"))
)
```
특정 곳에서만 사용해서 이를 관리하는게 어려움이 존재합니다. ( request-headers 가 생성되게 할지, 안되게 할지 )
```kotlin
RestAssuredRestDocumentation.document(
"sample",
requestHeaders(headerWithName("Authorization").description("Basic auth credentials")),
RequestDocumentation.queryParameters(
RequestDocumentation.parameterWithName(
"page"
).description("받는 페이지 번호")
)
)
이 값들은 document 에 넣어줘야만 문서화가 됩니다.
( 그리고, 이 역시도 값과 필드 문서화에서 중복이 발생해 첫 번째 단점을 고스란히 가집니다. )
PayloadDocumentation.requestFields(
fieldWithPath("purchaseHttpRequest").type(JsonFieldType.OBJECT).description("구매 요청 정보")
),
PayloadDocumentation.responseFields(
fieldWithPath("purchaseHttpRequest").type(JsonFieldType.OBJECT).description("구매 요청 정보")
),
이렇게 request 와 response 를 설정하는건 사실
public static RequestFieldsSnippet requestFields(List<FieldDescriptor> descriptors) {
return new RequestFieldsSnippet(descriptors);
}
public static ResponseFieldsSnippet responseFields(List<FieldDescriptor> descriptors) {
return new ResponseFieldsSnippet(descriptors);
}
매개변수는 똑같은데 단순히, 반환하는 Snippet이 다를 뿐입니다.
이제, 이런 단점들을 코틀린 DSL 을 통해 어떻게 해결했는지 설명하겠습니다.
구현한 방법을 설명하기 전, 이에 사용한 코틀린 문법을 먼저 정리합니다.
( 이번 사이드 프로젝트에서 처음 코틀린을 사용하며 기본 문법이 많이 부족해서.. )
흔히, prefix
,postfix
에서 사용하는 의미와 같습니다.
앞과 뒤에 표기를 해주듯이, infix
는 내부에 표기를 해주는 것입니다.
infix fun Int.add(other: Int): Int {
return this + other
}
println(5 add 3) // 8
호출자인 점(.)과 파라미터 괄호(()
) 를 생략하고 함수명 만으로 호출할 수 있게 해줍니다.
특정 클래스에서만 사용 가능하게 하고 싶으면?
class IntAdder {
private val histories = mutableListOf<History>()
fun add(left: Int, right: Int): Int {
return left add right
}
infix fun Int.add(other: Int): Int {
histories.add(History(this, other))
return this + other
}
}
이와같이 클래스 내부에 선언하면 됩니다. ( private 을 선언하지 않는 경우도 존재한다. )
class Person(val name: String)
fun introduce(block: Person.() -> Unit) {
val person = Person("Alice")
person.block()
}
fun main() {
introduce {
println("Hello, my name is $name")
//"Hello, my name is Alice"
}
}
처음에는, 이 부분이 되게 이해가 안가서 어려움을 느낀 부분입니다.
block: Person.() -> Unit
이 부분이 확장 람다입니다.
val person = Person("Alice")
을 생성한다.println("Hello, my name is $name")
함수를 Person 클래스 내부에서 실행된다.$name
에서 this.
나 person.
와 같은게 생략되는 이유 )이런 설명만 보면, 이걸 왜 쓰는데? 라고 생각할 수 있습니다.
아래 DSL 부분에서 설명할 내용을 잠시 보면
fun setRequest(
endpoint: String,
method: HttpMethod = HttpMethod.POST,
block: DslContainer.() -> Unit)
{
...
}
fun DslContainer.body(block: DslBuilder.() -> Unit) {
body.apply(block)
}
.setRequest("/api/orders", HttpMethod.POST) {
body {
"lottoPublishId" type DocsFieldType.NUMBER means "승인할 로또 발행의 고유 식별자"
}
}
.setRequest(...)
해당 부분이 동작하는 원리는
DslContainer 클래스에서 -> body 확장함수 호출 -> DslBuilder 클래스에서 -> String.type 을 활용한다. ( 매우 어렵다... )
쉽게 생각해서 { ... }
내부에서 내가 원하는 클래스로 실행한다
라고 생각하면 될 거 같습니다.
객체의 초기화 및 설정을 간편하게 작성하게 도와주는 함수들입니다.
data class Person(var name: String, var age: Int)
val person = Person().apply {
name = "Alice"
age = 25
}
this 를 호출하지 않고, 간편하게 설정할 수 있다. 추가로, 다양하게 객체가 설정될 수 있게 도와줍니다.
( 당연히, 멤버변수들은 변할 수 있게 var
키워드 )
반환 값은 apply
호출 후, 수정한 객체를 반홥합니다.
object Logger {
fun log(message: String) {
println("LOG: $message")
}
}
data object AppConfig {
val version = "1.0.0"
val apiUrl = "https://api.example.com"
}
fun main() {
Logger.log("Hello!")
}
공통적으로, 싱글톤 패턴 ( 인스턴스 직접 생성 X, 프로그램 실행 시 한번 만 초기화 ) 입니다.
그냥 object
는 Util 성 함수를 만들고 싶을 떄, data object
는 상수를 저장하고 싶을때 사용하면 될 거 같습니다.
( toString
, equals
, hashCode
자동 생성 )
해당 부분 부터는 토스의 아티클에서 차용해 나만의 버전으로 도입한 것입니다.
data class ApiField(
val path: String,
val docsFieldType: DocsFieldType,
var value: Any,
var description: String,
var optional: Boolean,
var children: List<ApiField> = emptyList()
)
sealed class DocsFieldType(val type: JsonFieldType) {
open val format: String? = null
data object NUMBER : DocsFieldType(JsonFieldType.NUMBER)
data object STRING : DocsFieldType(JsonFieldType.STRING)
data object BOOLEAN : DocsFieldType(JsonFieldType.BOOLEAN)
data object OBJECT : DocsFieldType(JsonFieldType.OBJECT)
data object DATE : DocsFieldType(JsonFieldType.STRING) {
override val format = "yyyy-MM-dd"
}
data object DATETIME : DocsFieldType(JsonFieldType.STRING) {
override val format = "yyyy-MM-dd HH:mm:ss"
}
data class ARRAY(val elementType: DocsFieldType) : DocsFieldType(JsonFieldType.ARRAY)
data class ENUM<T : Enum<T>>(val enums: Collection<T>) : DocsFieldType(JsonFieldType.STRING) {
companion object {
inline fun <reified T : Enum<T>> of(): ENUM<T> {
return ENUM(T::class.java.enumConstants.asList())
}
}
}
}
ARRAY
와 ENUM
은 값을 내부에 가지고 있게 해 동적으로 사용이 가능하게 합니다.
( 배열 내부의 요소 or ENUM 요소들의 값 )
class DslBuilder {
internal val fields = mutableListOf<ApiField>()
infix fun String.type(docsFieldType: DocsFieldType): ApiField {
val field = ApiField(
name = this, docsFieldType = docsFieldType,
value = "",
description = "",
optional =false,
)
fields.add(field)
return field
}
}
DslBuilder 내부에서 inline 함수를 만듭니다.
호출하면, ApiField 를 생성해주고, Builder 내부에 추가를 합니다.
이렇게 한 이유는 쉼표 또는 직접 호출하지 않고 인라인 함수를 호출해 생성하면서도, Builder 가 값을 가지고 있게 하기 위함입니다.
infix fun ApiField.means(description: String): ApiField {
this.description = description
return this
}
infix fun ApiField.value(value: Any): ApiField {
this.value = value
return this
}
infix fun ApiField.optional(flag: Boolean): ApiField {
this.optional = flag
return this
}
infix fun ApiField.withChildren(block: DslBuilder.() -> Unit): ApiField {
val childBuilder = DslBuilder()
childBuilder.block()
this.children = childBuilder.fields
return this
}
그 후, ApiField infix 를 통해서 각각의 값들을 넣어줍니다.
"purchaseHttpRequest" type DocsFieldType.OBJECT means "결제 승인 HTTP 객체" withChildren {
"purchaseType" type DocsFieldType.ENUM.of<PurchaseType>() means "구매 유형" value purchaseType
"amount" type DocsFieldType.NUMBER means "취소할 결제 금액" value amount
"paymentKey" type DocsFieldType.STRING means "취소할 결제 식별자 - 결제 시스템 제공" value paymentKey
}
DEPTH 가 있으면, withChildren
을 통해 내부 값을 설정할 수 있게 했습니다.
class DslContainer {
val headers = DslBuilder()
val body = DslBuilder()
val queryParams = DslBuilder()
...
fun DslContainer.headers(block: DslBuilder.() -> Unit) {
headers.apply(block)
}
fun DslContainer.body(block: DslBuilder.() -> Unit) {
body.apply(block)
}
fun DslContainer.params(block: DslBuilder.() -> Unit) {
queryParams.apply(block)
}
}
그 후, 이와 같이 headers,body,queryParam 를 전부 가지는 Container 를 만듭니다.
body {
...
}
headers {
...
}
params {
...
}
body
,headers
,param
중 필요한 부분들 설정을 할 수 있습니다.
fun convertBody() = body.toValue()
fun convertBodyDescriptors() = body.toFieldDescriptors()
와 같이 요청에 넣을 값과 필드 설명으로 변환할 수 있습니다.
fun List<ApiField>.toConvertValue():Map<String,Any>{
fun processField(field: ApiField): Any {
return if (field.children.isNotEmpty()) {
field.children.associate { it.name to processField(it) }
} else {
field.value
}
}
return this.associate { it.name to processField(it) }
}
fun List<ApiField>.toFieldDescriptors(): List<FieldDescriptor> {
val descriptors = mutableListOf<FieldDescriptor>()
fun processField(field: ApiField, parentPath: String = "") {
val fullPath = if (parentPath.isEmpty()) field.name else "$parentPath.${field.name}"
fun getArrayPath(type: DocsFieldType, path: String): String {
return when (type) {
is DocsFieldType.ARRAY -> getArrayPath(type.elementType, "$path[]")
else -> path
}
}
val formattedPath = getArrayPath(field.docsFieldType, fullPath)
val descriptor = fieldWithPath(formattedPath)
.type(field.docsFieldType.type)
.description(
field.description + if (field.docsFieldType is DocsFieldType.ARRAY) {
" (요소 타입: ${getArrayTypeString(field.docsFieldType.elementType)})"
} else "" + (field.docsFieldType.format?.let { " (형식: $it)" } ?: "")
)
.attributes(
Attributes.Attribute("optional", field.optional.toString().uppercase())
)
descriptors.add(descriptor)
field.children.forEach { processField(it, formattedPath) }
}
this.forEach { processField(it) }
return descriptors
}
특이점으로 재귀함수의 형식입니다. 아래와 같은 점들이 가능해집니다.
하위 요소가 있는 OBJECT
이면, path 를 더해서 계속 설정 ( purchaseRequest -> currencyRequest -> id )
하위 요소가 있는 ARRAY
이면, 하위 타입을 가져와서 설명
=> 이를 통해, 의도한대로 값을 설정 및 문서를 만들 수 있습니다.
대망의 DSL 입니다.
( 요청을 실행하기 까지 하니 DSL 에 더 가깝힌 하나, 우선 명확한 이름으로 했습니다. )
class DocsApiBuilder(private val documentName: String) {
private var endpoint: String = ""
private var requestContainer: DslContainer = DslContainer()
private var method: HttpMethod = HttpMethod.POST
private var responseContainer: DslContainer = DslContainer()
fun setRequest(
endpoint: String,
method: HttpMethod = HttpMethod.POST,
block: DslContainer.() -> Unit
): DocsApiBuilder {
this.endpoint = endpoint
this.method = method
this.requestContainer = DslContainer().apply(block)
return this
}
요청을 설정합니다.
근본적인 Http Request Message
라면
Requeset Line ( HTTP METHOD, URL )
Request Header
Request Body
setRequest
에 모든걸 다 넣는게 편해서 이와같이 구현했습니다.fun execute(log: Boolean = false): DocsApiValidator {
if (log) {
printLog()
}
try {
var requestSpec: RequestSpecification = RestAssured.given().log().all()
.contentType(ContentType.JSON)
.headers(requestContainer.convertHeaders())
.queryParams(requestContainer.convertQueryParams())
.body(requestContainer.convertBody())
val response = requestSpec
.filter(
RestAssuredRestDocumentation.document(
documentName,
HeaderDocumentation.requestHeaders(requestContainer.convertHeadersDescriptors()),
HeaderDocumentation.responseHeaders(responseContainer.convertHeadersDescriptors()),
PayloadDocumentation.requestFields(requestContainer.convertBodyDescriptors()),
PayloadDocumentation.responseFields(responseContainer.convertBodyDescriptors())
)
)
.request(method.toMethod(), endpoint)
.then().log().all()
.extract()
return DocsApiValidator(response)
} catch (e: Exception) {
throw IllegalStateException("API 문서화 중 오류가 발생했습니다: ${e.message}", e)
}
}
Header,Param,Body 에 값 주입 및 문서화를 합니다.
private fun printLog() {
println("=== DocsApiBuilder ===")
println("Document: $documentName")
println("Endpoint: $endpoint")
println("Request:")
requestContainer.printRequestInfo()
println("Response:")
responseContainer.printRequestInfo()
}
Request: =================================
Headers:
Body:
- 경로(lottoPublishId) 타입(NUMBER): 설명(주문한 영수증 ID) 값(1)
- 경로(purchaseHttpRequest) 타입(OBJECT): 설명(결제 승인 HTTP 객체)
- 경로(purchaseType) 타입(ENUM): 설명(구매 유형) 값(CARD)
- 경로(currency) 타입(ENUM): 설명(결제할 통화 유형) 값(KRW)
- 경로(amount) 타입(NUMBER): 설명(취소할 결제 금액) 값(1000)
- 경로(orderId) 타입(STRING): 설명(취소할 주문 번호) 값(order-id-1)
- 경로(paymentKey) 타입(STRING): 설명(취소할 결제 식별자 - 결제 시스템 제공) 값(paymentKey-id-1)
QueryParams:
Response: =================================
Headers:
Body:
- 경로(purchaseResponse) 타입(OBJECT): 설명(응답 데이터)
- 경로(id) 타입(STRING): 설명(취소된 결제의 고유 식별자)
- 경로(amount) 타입(NUMBER): 설명(취소된 결제 금액)
( 상태를 편하게 확인하기 위한 로그성 메소드도 있습니다.🙂 )
DocsApiBuilder("purchase-ticket-success")
.setRequest("/api/tickets", HttpMethod.POST) {
body {
"lottoPublishId" type NUMBER means "주문한 영수증 ID" value 1
"purchaseHttpRequest" type OBJECT means "결제 승인 HTTP 객체" withChildren {
"purchaseType" type ENUM.of<PurchaseType>() means "구매 유형" value PurchaseType.CARD
"currency" type ENUM.of<Currency>() means "결제할 통화 유형" value Currency.KRW
"amount" type NUMBER means "취소할 결제 금액" value 1000
"orderId" type STRING means "취소할 주문 번호" value "order-id-1"
"paymentKey" type STRING means "취소할 결제 식별자" value "paymentKey-id-1"
}
} headers {
"Payment-Error-Header" type STRING means "토스 임의 에러 코드" value "EXCEED_MAX_ONE_DAY_AMOUNT" optional true
}
}.setResponse {
body {
"purchaseResponse" type OBJECT means "응답 데이터" withChildren {
"id" type STRING means "취소된 결제의 고유 식별자"
"amount" type NUMBER means "취소된 결제 금액"
}
} }.execute(true)
.statusCode(200)
기존의 분리된 요청 명세 작성
과 요청 값 주입
이 합쳐지고
JSON 형식과 유사하게 작성할 수 있습니다.
( 다른 개발자들이 봐도, 명확하게 문서화가 되어 있기도 하고요🙂 )
그리고, 다른 장점은
import config.AcceptanceTest
import docs.DocsApiBuilder
import docs.HttpMethod
import docs.field.DocsFieldType.*
import docs.request.DslContainer
문서 관련 의존성이 응집되어 Spring RestDocs
에 관련된 의존성이 발생하지 않습니다.
현재는, 로또를 구현하며 재밌는 요소를 발견해서 DSL 을 만들었습니다.
그래서, 급하거나 다소 아쉬운 부분이 있는데 일단락을 했습니다.
"lottoPublishId" type NUMBER means "주문한 영수증 ID" value 1
처음에 불만을 가진 요소인데 해결하지 못했습니다.
코틀린에 대해 잘 모르지만, type NUMBER
와 value 1
은 결국 같은 요소라고 생각합니다.
type 을 날리고, value 만 넣는다면 더 간단하게 문서화와 요청 생성이 가능할 거 같습니다.
런타임에 타입을 추론해, 특정 타입 -> DocsFieldType 으로 변환
=== 멤버 조회 실패
==== 없는 토큰
부적절한 토큰을 담아 보내면 실패한다.
include::{snippets}/info-fail-not-valid-token/index.adoc[]
RestDocs 는 index.adoc
문서를 직접 생성해야 합니다.
이때, 문서와 코드의 분리
라는 장점도 있지만 관리의 어려움
과 번거로움
이 너무 크게 다가왔습니다.
tasks.register("generateSnippetIndexes") {
val snippetsDir = file("build/generated-snippets")
snippetsDir.listFiles { file -> file.isDirectory }?.forEach { snippetFolder ->
val includeFiles = listOf(
"http-request.adoc",
"http-response.adoc",
"request-fields.adoc",
"response-fields.adoc"
)
val includesContent = includeFiles
.filter { File(snippetFolder, it).exists() }
.joinToString("\n") { "include::${it}[]" }
val indexFile = File(snippetFolder, "index.adoc")
indexFile.writeText(includesContent)
println("Generated index.adoc in ${snippetFolder.name}:")
println(includesContent)
}
}
( 자동으로 묶어주는 task 는 추가했지만, 핵심 불편 해소는 아니였습니다. )
Custom Annotaiton 을 기반으로
@Title
은 문서의 제목(Depth2)
@SubTitle
은 문서의 중제목(Depth3)
@Detail
은 문서의 소제목(Depth4
)@Content(...)
는 문서 본문과 같은 식으로 동적 생성도 가능할 거 같습니다.
하지만, 테스트에 문서가 깊게 침범되는거 같아서 장단점을 조금 더 고려해봐야 할 거 같습니다.