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..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 @@ -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( 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 292fd894..2cdacaa2 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 @@ -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() @@ -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, ) } @@ -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 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 28cd4b50..b4c44662 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 @@ -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(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 =