Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,21 @@ internal object BodyGenerator {

val urlString = buildUrlString(endpoint, params)
when (endpoint.requestBody?.contentType) {
ContentType.MULTIPART_FORM_DATA -> code.buildMultipartBody(endpoint, params, urlString)
ContentType.FORM_URL_ENCODED -> code.buildFormUrlEncodedBody(endpoint, params, urlString)
ContentType.JSON_CONTENT_TYPE, null -> code.buildJsonBody(endpoint, params, urlString)
ContentType.MULTIPART_FORM_DATA -> {
code.buildMultipartBody(endpoint, params, urlString)
}

ContentType.FORM_URL_ENCODED -> {
code.buildFormUrlEncodedBody(endpoint, params, urlString)
}

ContentType.JSON_CONTENT_TYPE, null -> {
code.buildJsonBody(endpoint, params, urlString)
}

ContentType.TEXT_PLAIN, ContentType.OCTET_STREAM -> {
error("Unsupported request body content type: ${endpoint.requestBody.contentType}")
}
}

// Close the HTTP call block and chain .toResult() / .toEmptyResult()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,20 @@ internal object ParametersGenerator {

context(_: Hierarchy)
fun buildBodyParams(requestBody: RequestBody) = when (requestBody.contentType) {
ContentType.MULTIPART_FORM_DATA -> buildMultipartParameters(requestBody)
ContentType.FORM_URL_ENCODED -> buildFormParameters(requestBody)
ContentType.JSON_CONTENT_TYPE -> listOf(buildNullableParameter(requestBody.schema, BODY, requestBody.required))
ContentType.MULTIPART_FORM_DATA -> {
buildMultipartParameters(requestBody)
}

ContentType.FORM_URL_ENCODED -> {
buildFormParameters(requestBody)
}

ContentType.JSON_CONTENT_TYPE -> {
listOf(buildNullableParameter(requestBody.schema, BODY, requestBody.required))
}

ContentType.TEXT_PLAIN, ContentType.OCTET_STREAM -> {
error("Unsupported request body content type: ${requestBody.contentType}")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,25 @@ data class RequestBody(
val schema: TypeRef,
)

// the order is important!!!
// the order is important!!! (used for priority when matching content)
enum class ContentType(val value: String) {
MULTIPART_FORM_DATA("multipart/form-data"),
FORM_URL_ENCODED("application/x-www-form-urlencoded"),
JSON_CONTENT_TYPE("application/json"),
TEXT_PLAIN("text/plain"),
OCTET_STREAM("application/octet-stream"),
;

companion object {
val REQUEST_TYPES = listOf(MULTIPART_FORM_DATA, FORM_URL_ENCODED, JSON_CONTENT_TYPE)
}
}

data class Response(
val statusCode: String,
val description: String?,
val schema: TypeRef?,
val contentType: ContentType? = null,
)

data class SchemaModel(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ object SpecParser {
val body = operation.requestBody.bind()
val content = body.content.bind()

val contentType = ContentType.entries.find { it in content }.bind()
val contentType = ContentType.REQUEST_TYPES.find { it in content }.bind()

val mediaType = content[contentType].bind()

Expand All @@ -278,14 +278,21 @@ object SpecParser {
val responses = operation.responses
.orEmpty()
.mapValues { (code, resp) ->
val content: Content? = resp.content
val responseContentType = content?.let { ContentType.entries.find { it in content } }
val typeName = "${operationId.replaceFirstChar { it.uppercase() }}Response"
val schema = responseContentType
?.let { content[it] }
?.schema
?.takeUnless { it.isEmptyContent }
?.toTypeRef(typeName)
?: defaultResponseSchema(responseContentType)

Response(
statusCode = code,
description = resp.description,
schema = resp.content
?.get(ContentType.JSON_CONTENT_TYPE.value)
?.schema
?.takeUnless { it.isEmptyContent }
?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"),
schema = schema,
contentType = responseContentType,
)
}

Expand Down Expand Up @@ -655,6 +662,13 @@ object SpecParser {
return method.name.lowercase() + segments
}

/** Fallback response type when a non-JSON content type carries no explicit schema. */
private fun defaultResponseSchema(contentType: ContentType?): TypeRef? = when (contentType) {
ContentType.TEXT_PLAIN -> TypeRef.Primitive(PrimitiveType.STRING)
ContentType.OCTET_STREAM -> TypeRef.Primitive(PrimitiveType.BYTE_ARRAY)
else -> null
}

operator fun Content.get(contentType: ContentType) = this[contentType.value]

operator fun Content.contains(contentType: ContentType) = contentType.value in this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,34 @@ class ClientGeneratorTest {
assertEquals("com.example.model.Pet", returnType.typeArguments[1].toString())
}

@Test
fun `text plain response returns String`() {
val ep = endpoint(
operationId = "getText",
responses = mapOf(
"200" to Response("200", "OK", TypeRef.Primitive(PrimitiveType.STRING), ContentType.TEXT_PLAIN),
),
)
val cls = clientClass(ep)
val funSpec = cls.funSpecs.first { it.name == "getText" }
val returnType = funSpec.returnType as ParameterizedTypeName
assertEquals("kotlin.String", returnType.typeArguments[1].toString())
}

@Test
fun `octet stream response returns ByteArray`() {
val ep = endpoint(
operationId = "getBinary",
responses = mapOf(
"200" to Response("200", "OK", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), ContentType.OCTET_STREAM),
),
)
val cls = clientClass(ep)
val funSpec = cls.funSpecs.first { it.name == "getBinary" }
val returnType = funSpec.returnType as ParameterizedTypeName
assertEquals("kotlin.ByteArray", returnType.typeArguments[1].toString())
}

@Test
fun `mixed 200 and 204 responses uses 200 schema type`() {
val ep = endpoint(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,25 @@ class ContentTypeTest {
ContentType.MULTIPART_FORM_DATA,
ContentType.FORM_URL_ENCODED,
ContentType.JSON_CONTENT_TYPE,
ContentType.TEXT_PLAIN,
ContentType.OCTET_STREAM,
),
ContentType.entries,
"ContentType declaration order matters: SpecParser.find picks the first matching entry, " +
"so more specific content types must come before JSON",
)
}

@Test
fun `request types keep JSON and form priority order`() {
assertEquals(
listOf(
ContentType.MULTIPART_FORM_DATA,
ContentType.FORM_URL_ENCODED,
ContentType.JSON_CONTENT_TYPE,
),
ContentType.REQUEST_TYPES,
"Only these content types are accepted as request bodies",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,49 @@ class SpecParserTest : SpecParserTestBase() {
assertTrue(petIdParam.required, "Path parameter should be required")
}

@Test
fun `parses non-JSON response content types`() {
val spec = File.createTempFile("response-content", ".yaml").apply { deleteOnExit() }
spec.writeText(
"""
openapi: 3.0.0
info:
title: Content API
version: 1.0.0
paths:
/text:
get:
operationId: getText
responses:
'200':
description: ok
content:
text/plain:
schema:
type: string
/binary:
get:
operationId: getBinary
responses:
'200':
description: ok
content:
application/octet-stream: {}
""".trimIndent(),
)
val parsed = parseSpec(spec)

val text = parsed.endpoints.find { it.operationId == "getText" } ?: fail("getText not found")
val textResp = text.responses["200"] ?: fail("text 200 response not found")
assertEquals(ContentType.TEXT_PLAIN, textResp.contentType)
assertEquals(PrimitiveType.STRING, assertIs<TypeRef.Primitive>(textResp.schema).type)

val binary = parsed.endpoints.find { it.operationId == "getBinary" } ?: fail("getBinary not found")
val binaryResp = binary.responses["200"] ?: fail("binary 200 response not found")
assertEquals(ContentType.OCTET_STREAM, binaryResp.contentType)
assertEquals(PrimitiveType.BYTE_ARRAY, assertIs<TypeRef.Primitive>(binaryResp.schema).type)
}

@Test
fun `parsed POST pets has requestBody referencing NewPet`() {
val createPet =
Expand Down
Loading