OpenAPI에서 지원하는 oneOf 기능을 사용해서 API dto를 정의해서 사용하고 싶은 때가 있습니다.
# 예시
ExampleSearchConditionDto:
type: object
oneOf:
- $ref: '#/components/schemas/ExampleSearchByNameDto'
- $ref: '#/components/schemas/ExampleSearchByPhoneNumberDto'
ExampleSearchByNameDto:
type: object
required:
- name
properties:
name:
type: string
ExampleSearchByPhoneNumberDto:
type: object
required:
- phoneNumber
properties:
phoneNumber:
type: string
그러나 프로젝트에서 위와 같이 oneOf dto를 정의한 뒤 Kotlin 코드로 codegen 하면 결과물이 기대한 것과는 조금 다르게 나왔습니다.
// codegen 된 dto
data class ExampleSearchConditionDto (
@field:JsonProperty("name")
val name: kotlin.String,
@field:JsonProperty("phoneNumber")
val phoneNumber: kotlin.String
)
name과 phoneNumber 프로퍼티가 하나의 클래스에서 모두 필수값으로 정의가 됩니다. 따라서 API 스펙대로 name만 json에 담아서 보냈을 때 서버에서는 오류가 발생하게 됩니다.
openapi에서 제공하는 discriminator를 이용하면 해당 문제를 해결할 수 있습니다.
ExampleSearchConditionDto:
type: object
required:
- searchType
properties:
searchType:
$ref: '#/components/schemas/ExampleSearchTypeEnum'
discriminator:
propertyName: searchType
ExampleSearchTypeEnum:
type: string
enum:
- NAME
- PHONE_NUMBER
위와 같이 oneOf에서 사용할 dto를 식별할 수 있는 값을 enum으로 정의해줍니다. 그리고 이 enum을 필드로 가지는 dto를 하나 만들어줍니다. 이 dto는 kotlin의 interface라고 이해하시면 됩니다.
ExampleSearchByNameDto:
type: object
allOf:
- $ref: '#/components/schemas/ExampleSearchConditionDto'
- type: object
required:
- name
properties:
name:
type: string
ExampleSearchByPhoneNumberDto:
type: object
allOf:
- $ref: '#/components/schemas/ExampleSearchConditionDto'
- type: object
required:
- phoneNumber
properties:
phoneNumber:
type: string
oneOf로 사용할 각 dto를 정의합니다. 이때, 방금 정의한 interface 역할을 하는 dto를 allOf로 결합합니다.
ExampleSearchConditionDto:
type: object
required:
- searchType
properties:
searchType:
$ref: '#/components/schemas/ExampleSearchTypeEnum'
discriminator:
propertyName: searchType
mapping:
NAME: '#/components/schemas/ExampleSearchByNameDto'
PHONE_NUMBER: '#/components/schemas/ExampleSearchByPhoneNumberDto'
마지막으로 sealed interface dto에 mapping을 선언합니다. mapping은 discriminator.propertyName에 설정된 프로퍼티에 들어오는 값에 따라 어떤 dto로 해석할지를 결정해줍니다.
기본값은 dto의 이름을 key로 하지만, 이번 예시에서는 enum을 직접 만들어서 key로 사용하기 때문에 enum 이름을 key로 설정해줍니다.
위에서 선언한 dto를 kotlin으로 codegen하면 아래와 같은 결과물을 얻게 됩니다.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "statisticsSearchType", visible = true)
@JsonSubTypes(
JsonSubTypes.Type(value = ExampleSearchByNameDto::class, name = "NAME"),
JsonSubTypes.Type(value = ExampleSearchByPhoneNumberDto::class, name = "PHONE_NUMBER")
)
interface ExampleSearchConditionDto {
@get:JsonProperty("searchType")
val searchType: ExampleSearchTypeEnum
}
interface 역할을 한다고 설명한 dto는 interface로 codegen됩니다. @JsonTypeInfo, @JsonSubTypes 어노테이션이 달려있어 요청으로 들어온 json의 형태에 따라 각 구현체로 파싱될 수 있습니다.
data class ExampleSearchByNameDto (
@field:JsonProperty("searchType")
override val searchType: ExampleSearchTypeEnum,
@field:JsonProperty("name")
override val name: String,
) : ExampleSearchConditionDto
data class ExampleSearchByPhoneNumberDto (
@field:JsonProperty("searchType")
override val searchType: ExampleSearchTypeEnum,
@field:JsonProperty("phoneNumber")
override val phoneNumber: String,
) : ExampleSearchConditionDto
oneOf로 사용하고 싶던 각 dto는 모두 interface를 구현하는 식으로 codegen됩니다.
swagger UI에서 request body로 oneOf dto 중 하나를 사용해야 한다는것을 인지할 수 없다는 단점이 있습니다.