Skip to content

Commit

Permalink
add support for request body from @RequestBody annotation's implement…
Browse files Browse the repository at this point in the history
…ation field
  • Loading branch information
angryziber committed Aug 21, 2023
1 parent 5d7d99b commit 3641fab
Show file tree
Hide file tree
Showing 2 changed files with 57 additions and 27 deletions.
38 changes: 25 additions & 13 deletions openapi/src/OpenAPI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.Schema.AccessMode
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.tags.Tag
import klite.*
import klite.RequestMethod.GET
Expand All @@ -17,10 +18,10 @@ import java.net.URL
import java.time.*
import java.util.*
import kotlin.reflect.KClass
import kotlin.reflect.KParameter
import kotlin.reflect.KParameter.Kind.INSTANCE
import kotlin.reflect.KProperty1
import kotlin.reflect.KType
import kotlin.reflect.full.createType
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.isSubclassOf

Expand Down Expand Up @@ -64,7 +65,7 @@ internal fun toOperation(route: Route): Pair<String, Any> {
"parameters" to funHandler?.let {
it.params.filter { it.source != null }.map { p -> toParameter(p, op) }
},
"requestBody" to findRequestBody(route),
"requestBody" to toRequestBody(route, route.annotation<RequestBody>() ?: op?.requestBody),
"responses" to if (returnType?.classifier == Unit::class) mapOf(NoContent.value to mapOf("description" to "No content"))
else mapOf(OK.value to mapOfNotNull("description" to "OK", "content" to returnType?.toJsonContent()))
) + (op?.let { it.toNonEmptyValues { it.name != "method" } + mapOf(
Expand Down Expand Up @@ -114,17 +115,28 @@ private fun KType.toJsonSchema(): Map<String, Any>? {
)
}

private fun findRequestBody(route: Route) = (route.handler as? FunHandler)?.params?.
find { it.p.kind != INSTANCE && it.source == null && it.cls.java.packageName != "klite" }?.p?.toRequestBody()
private fun toRequestBody(route: Route, annotation: RequestBody?): Map<String, Any?> {
val bodyParam = (route.handler as? FunHandler)?.params?.find { it.p.kind != INSTANCE && it.source == null && it.cls.java.packageName != "klite" }?.p
val requestBody = annotation?.toNonEmptyValues() ?: HashMap()
if (annotation != null && annotation.content.isNotEmpty())
requestBody["content"] = annotation.content.associate {
val content = it.toNonEmptyValues { it.name != "mediaType" }
if (it.schema.implementation != Void::class.java) content["schema"] = it.schema.implementation.createType().toJsonSchema()
it.mediaType to content
}
if (bodyParam != null) requestBody.putIfAbsent("content", bodyParam.type.toJsonContent())
return requestBody
}

private fun KParameter.toRequestBody() = mapOf("content" to type.toJsonContent())
private fun KType.toJsonContent() = mapOf(MimeTypes.json to mapOf("schema" to toJsonSchema()))

internal fun <T: Annotation> T.toNonEmptyValues(filter: (KProperty1<T, *>) -> Boolean = { true }): Map<String, Any?> =
publicProperties.filter(filter).associate { it.name to when(val v = it.valueOf(this)) {
"", false, 0, Int.MAX_VALUE, Int.MIN_VALUE, 0.0, Void::class.java, AccessMode.AUTO -> null
is Enum<*> -> v.takeIf { v.name != "DEFAULT" }
is Annotation -> v.toNonEmptyValues().takeIf { it.isNotEmpty() }
is Array<*> -> v.map { (it as? Annotation)?.toNonEmptyValues() ?: it }.takeIf { it.isNotEmpty() }
else -> v
}}.filterValues { it != null }
internal fun <T: Annotation> T.toNonEmptyValues(filter: (KProperty1<T, *>) -> Boolean = { true }): MutableMap<String, Any?> = HashMap<String, Any?>().also { map ->
publicProperties.filter(filter).forEach { p ->
when(val v = p.valueOf(this)) {
"", false, 0, Int.MAX_VALUE, Int.MIN_VALUE, 0.0, Void::class.java, AccessMode.AUTO -> null
is Enum<*> -> v.takeIf { v.name != "DEFAULT" }
is Annotation -> v.toNonEmptyValues().takeIf { it.isNotEmpty() }
is Array<*> -> v.map { (it as? Annotation)?.toNonEmptyValues() ?: it }.takeIf { it.isNotEmpty() }
else -> v
}?.let { map[p.name] = it }
}}
46 changes: 32 additions & 14 deletions openapi/test/OpenAPITest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import ch.tutteli.atrium.api.verbs.expect
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.enums.ParameterIn.*
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import klite.HttpExchange
Expand All @@ -22,9 +25,21 @@ import java.time.LocalDate
import java.util.*

class OpenAPITest {
data class User(val name: String, val id: UUID)
val userSchema = mapOf(MimeTypes.json to mapOf(
"schema" to mapOf(
"type" to "object",
"properties" to mapOf(
"name" to mapOf("type" to "string"),
"id" to mapOf("type" to "string", "format" to "uuid")
),
"required" to setOf("name", "id")
)
))

@Test fun nonEmptyValues() {
@Tag(name = "hello") class Dummy {}
expect(Dummy::class.annotation<Tag>()!!.toNonEmptyValues()).toEqual(mapOf("name" to "hello"))
expect(Dummy::class.annotation<Tag>()!!.toNonEmptyValues()).toEqual(mutableMapOf("name" to "hello"))
}

@Test fun `route classes to tags`() {
Expand Down Expand Up @@ -66,32 +81,35 @@ class OpenAPITest {
}

@Test fun `request body`() {
data class User(val name: String, val id: UUID)
class MyRoutes {
fun saveUser(e: HttpExchange, @PathParam userId: UUID, body: User) {}
}

expect(toOperation(Route(POST, "/x".toRegex(), handler = FunHandler(MyRoutes(), MyRoutes::saveUser)))).toEqual("post" to mapOf(
"operationId" to "MyRoutes.saveUser",
"tags" to listOf("MyRoutes"),
"parameters" to listOf(
mapOf("name" to "userId", "required" to true, "in" to PATH, "schema" to mapOf("type" to "string", "format" to "uuid"))
),
"requestBody" to mapOf("content" to
mapOf(MimeTypes.json to mapOf(
"schema" to mapOf(
"type" to "object",
"properties" to mapOf(
"name" to mapOf("type" to "string"),
"id" to mapOf("type" to "string", "format" to "uuid")
),
"required" to setOf("name", "id")
)
))
),
"requestBody" to mapOf("content" to userSchema),
"responses" to mapOf(NoContent.value to mapOf("description" to "No content"))
))
}

@Test fun `request body from annotation's implementation field`() {
class MyRoutes {
@RequestBody(description = "Application and applicant", content = [Content(mediaType = MimeTypes.json, schema = Schema(implementation = User::class))])
fun saveUser(e: HttpExchange): User = User("x", UUID.randomUUID())
}
expect(toOperation(Route(POST, "/x".toRegex(), handler = FunHandler(MyRoutes(), MyRoutes::saveUser), annotations = MyRoutes::saveUser.annotations))).toEqual("post" to mapOf(
"operationId" to "MyRoutes.saveUser",
"tags" to listOf("MyRoutes"),
"parameters" to emptyList<Any>(),
"requestBody" to mapOf("description" to "Application and applicant", "content" to userSchema),
"responses" to mapOf(OK.value to mapOf("description" to "OK", "content" to userSchema))
))
}

@Test fun `anonymous route`() {
expect(toOperation(Route(POST, "/x".toRegex()) {})).toEqual("post" to mapOf(
"operationId" to null,
Expand Down

0 comments on commit 3641fab

Please sign in to comment.