From 5b281bd6541d051de207a242f8caa37e96236be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 9 Jun 2026 15:07:00 +0200 Subject: [PATCH 1/2] feat(core): support non-JSON response content types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response parsing previously only looked at application/json, silently dropping schemas for other content types. Now the parser picks the first matching response content type (JSON keeps priority) and records it on the Response model. text/plain maps to String, application/octet-stream to ByteArray — including when the content carries no explicit schema. Ktor's body() deserializes both natively, so no generator changes beyond the resolved return type are needed. Request bodies stay limited to the existing JSON/form/multipart types via ContentType.REQUEST_TYPES. Closes #36 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/gen/client/BodyGenerator.kt | 18 ++++++-- .../core/gen/client/ParametersGenerator.kt | 18 ++++++-- .../avsystem/justworks/core/model/ApiSpec.kt | 11 ++++- .../justworks/core/parser/SpecParser.kt | 23 +++++++--- .../justworks/core/gen/ClientGeneratorTest.kt | 28 ++++++++++++ .../justworks/core/model/ContentTypeTest.kt | 15 +++++++ .../justworks/core/parser/SpecParserTest.kt | 43 +++++++++++++++++++ 7 files changed, 144 insertions(+), 12 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt index 0eb99528..78789daa 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt @@ -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() diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt index dbec75f8..c1fe6072 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt @@ -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}") + } } } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index dba29e84..ec1b5641 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -76,17 +76,26 @@ 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 { + /** Content types accepted as request bodies. */ + 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( diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 535e9fc3..f36600a6 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -258,7 +258,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() @@ -276,13 +276,19 @@ object SpecParser { val responses = operation.responses .orEmpty() .mapValues { (code, resp) -> + val content = resp.content + val responseContentType = ContentType.entries.find { content != null && it in content } + val typeName = "${operationId.replaceFirstChar { it.uppercase() }}Response" + val schema = responseContentType + ?.let { content?.get(it) } + ?.schema + ?.toTypeRef(typeName) + ?: defaultResponseSchema(responseContentType) Response( statusCode = code, description = resp.description, - schema = resp.content - ?.get(ContentType.JSON_CONTENT_TYPE.value) - ?.schema - ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), + schema = schema, + contentType = responseContentType, ) } @@ -572,6 +578,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 diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index c98d642a..d294b3e1 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -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( diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/model/ContentTypeTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/model/ContentTypeTest.kt index eac8cf93..2f58ec02 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/model/ContentTypeTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/model/ContentTypeTest.kt @@ -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", + ) + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index 603e151e..5f18fe71 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -124,6 +124,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(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(binaryResp.schema).type) + } + @Test fun `parsed POST pets has requestBody referencing NewPet`() { val createPet = From 08dcc4961b4476636782196d1a8f6133055c1d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 9 Jun 2026 15:56:59 +0200 Subject: [PATCH 2/2] refactor(core): simplify response content type resolution logic --- .../kotlin/com/avsystem/justworks/core/model/ApiSpec.kt | 1 - .../com/avsystem/justworks/core/parser/SpecParser.kt | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index ec1b5641..c5ab54ad 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -86,7 +86,6 @@ enum class ContentType(val value: String) { ; companion object { - /** Content types accepted as request bodies. */ val REQUEST_TYPES = listOf(MULTIPART_FORM_DATA, FORM_URL_ENCODED, JSON_CONTENT_TYPE) } } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index f36600a6..186333d1 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -276,14 +276,15 @@ object SpecParser { val responses = operation.responses .orEmpty() .mapValues { (code, resp) -> - val content = resp.content - val responseContentType = ContentType.entries.find { content != null && it in content } + 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?.get(it) } + ?.let { content[it] } ?.schema ?.toTypeRef(typeName) ?: defaultResponseSchema(responseContentType) + Response( statusCode = code, description = resp.description,