diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index 32facce..a83f221 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -10,6 +10,7 @@ import com.squareup.kotlinpoet.MemberName val HTTP_CLIENT = ClassName("io.ktor.client", "HttpClient") val CONTENT_NEGOTIATION = ClassName("io.ktor.client.plugins.contentnegotiation", "ContentNegotiation") val HTTP_HEADERS = ClassName("io.ktor.http", "HttpHeaders") +val ENCODE_URL_PATH_PART_FUN = MemberName("io.ktor.http", "encodeURLPathPart") val JSON_FUN = MemberName("io.ktor.serialization.kotlinx.json", "json") val BODY_FUN = MemberName("io.ktor.client.call", "body") @@ -59,6 +60,8 @@ val JSON_ENCODER = ClassName("kotlinx.serialization.json", "JsonEncoder") val SERIALIZERS_MODULE = ClassName("kotlinx.serialization.modules", "SerializersModule") val JSON_OBJECT_EXT = MemberName("kotlinx.serialization.json", "jsonObject") +val JSON_PRIMITIVE_EXT = MemberName("kotlinx.serialization.json", "jsonPrimitive") +val ENCODE_TO_JSON_ELEMENT_FUN = MemberName("kotlinx.serialization.json", "encodeToJsonElement") val K_SERIALIZER = ClassName("kotlinx.serialization", "KSerializer") val SERIAL_DESCRIPTOR = ClassName("kotlinx.serialization.descriptors", "SerialDescriptor") @@ -68,7 +71,6 @@ val PRIMITIVE_KIND = ClassName("kotlinx.serialization.descriptors", "PrimitiveKi val DECODER = ClassName("kotlinx.serialization.encoding", "Decoder") val ENCODER = ClassName("kotlinx.serialization.encoding", "Encoder") -val ENCODE_TO_STRING_FUN = MemberName("kotlinx.serialization", "encodeToString") val POLYMORPHIC_FUN = MemberName("kotlinx.serialization.modules", "polymorphic") val SUBCLASS_FUN = MemberName("kotlinx.serialization.modules", "subclass") @@ -93,7 +95,6 @@ val EXPERIMENTAL_UUID_API = ClassName("kotlin.uuid", "ExperimentalUuidApi") val HTTP_ERROR = ClassName("com.avsystem.justworks", "HttpError") val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess") val HTTP_RESULT = ClassName("com.avsystem.justworks", "HttpResult") -val DESERIALIZE_ERROR_BODY_FUN = MemberName("com.avsystem.justworks", "deserializeErrorBody") // ============================================================================ // Kotlin stdlib @@ -104,6 +105,7 @@ val CLOSEABLE = ClassName("java.io", "Closeable") val IO_EXCEPTION = ClassName("java.io", "IOException") val HTTP_REQUEST_TIMEOUT_EXCEPTION = ClassName("io.ktor.client.plugins", "HttpRequestTimeoutException") val OPT_IN = ClassName("kotlin", "OptIn") +val ENUM_CLASS = ClassName("kotlin", "Enum") // ============================================================================ // Shared client base (generated) @@ -112,9 +114,10 @@ val OPT_IN = ClassName("kotlin", "OptIn") val API_CLIENT_BASE = ClassName("com.avsystem.justworks", "ApiClientBase") val HTTP_RESPONSE = ClassName("io.ktor.client.statement", "HttpResponse") val HTTP_REQUEST_BUILDER = ClassName("io.ktor.client.request", "HttpRequestBuilder") -val TO_RESULT_FUN = MemberName("com.avsystem.justworks", "toResult") -val TO_EMPTY_RESULT_FUN = MemberName("com.avsystem.justworks", "toEmptyResult") +val BODY_AS_TEXT_FUN = MemberName("io.ktor.client.statement", "bodyAsText") +val DECODE_FROM_STRING_FUN = MemberName("kotlinx.serialization", "decodeFromString") val ENCODE_PARAM_FUN = MemberName("com.avsystem.justworks", "encodeParam") +val ENCODE_PATH_PARAM_FUN = MemberName("com.avsystem.justworks", "encodePathParam") val UUID_SERIALIZER = ClassName("com.avsystem.justworks", "UuidSerializer") // ============================================================================ @@ -125,7 +128,15 @@ const val BASE_URL = "baseUrl" const val TOKEN = "token" const val CLIENT = "client" const val BODY = "body" +const val JSON_PROPERTY = "json" const val APPLY_AUTH = "applyAuth" const val SAFE_CALL = "safeCall" const val CREATE_HTTP_CLIENT = "createHttpClient" const val GENERATED_SERIALIZERS_MODULE = "generatedSerializersModule" + +// toResult/toRawResult/toEmptyResult/deserializeErrorBody are members of ApiClientBase (they need +// access to its `json` property), so call sites reference them by plain name — no import required. +const val TO_RESULT_FUN = "toResult" +const val TO_RAW_RESULT_FUN = "toRawResult" +const val TO_EMPTY_RESULT_FUN = "toEmptyResult" +const val DESERIALIZE_ERROR_BODY_FUN = "deserializeErrorBody" diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt index 0b5dd4c..fbc1ce7 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt @@ -80,6 +80,14 @@ internal fun TypeRef.toTypeName(): TypeName = when (this) { internal fun TypeRef.isBinaryUpload(): Boolean = this is TypeRef.Primitive && this.type == PrimitiveType.BYTE_ARRAY +internal fun TypeRef.containsUuid(): Boolean = when (this) { + is TypeRef.Primitive -> type == PrimitiveType.UUID + is TypeRef.Array -> items.containsUuid() + is TypeRef.Map -> valueType.containsUuid() + is TypeRef.Inline -> properties.any { it.type.containsUuid() } + is TypeRef.Reference, is TypeRef.InlineEnum, TypeRef.Unknown -> false +} + /** * Resolves the @SerialName value for a variant within a oneOf schema. */ 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 78789da..9ba6b2e 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 @@ -8,6 +8,8 @@ import com.avsystem.justworks.core.gen.CONTENT_TYPE_APPLICATION import com.avsystem.justworks.core.gen.CONTENT_TYPE_FUN import com.avsystem.justworks.core.gen.DELETE_FUN import com.avsystem.justworks.core.gen.ENCODE_PARAM_FUN +import com.avsystem.justworks.core.gen.ENCODE_PATH_PARAM_FUN +import com.avsystem.justworks.core.gen.ENCODE_URL_PATH_PART_FUN import com.avsystem.justworks.core.gen.FORM_DATA_FUN import com.avsystem.justworks.core.gen.GET_FUN import com.avsystem.justworks.core.gen.HEADERS_CLASS @@ -26,6 +28,7 @@ import com.avsystem.justworks.core.gen.SET_BODY_FUN import com.avsystem.justworks.core.gen.SUBMIT_FORM_FUN import com.avsystem.justworks.core.gen.SUBMIT_FORM_WITH_BINARY_DATA_FUN import com.avsystem.justworks.core.gen.TO_EMPTY_RESULT_FUN +import com.avsystem.justworks.core.gen.TO_RAW_RESULT_FUN import com.avsystem.justworks.core.gen.TO_RESULT_FUN import com.avsystem.justworks.core.gen.isBinaryUpload import com.avsystem.justworks.core.gen.properties @@ -39,7 +42,9 @@ import com.avsystem.justworks.core.model.Parameter import com.avsystem.justworks.core.model.ParameterLocation import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.BYTE_ARRAY import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.UNIT @@ -48,8 +53,9 @@ internal object BodyGenerator { endpoint: Endpoint, params: Map>, returnBodyType: TypeName, + responseContentType: ContentType?, ): CodeBlock { - val resultFun = if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN + val resultFun = resolveResultFun(returnBodyType, responseContentType) val code = CodeBlock.builder() code.beginControlFlow("return $SAFE_CALL") @@ -73,14 +79,21 @@ internal object BodyGenerator { } } - // Close the HTTP call block and chain .toResult() / .toEmptyResult() + // Close the HTTP call block and chain .toResult() / .toRawResult() / .toEmptyResult() code.unindent() - code.add("}.%M()\n", resultFun) + code.add("}.$resultFun()\n") code.endControlFlow() // safeCall return code.build() } + private fun resolveResultFun(returnBodyType: TypeName, responseContentType: ContentType?): String = when { + returnBodyType == UNIT -> TO_EMPTY_RESULT_FUN + returnBodyType == BYTE_ARRAY -> TO_RAW_RESULT_FUN + returnBodyType == STRING && responseContentType != ContentType.JSON_CONTENT_TYPE -> TO_RAW_RESULT_FUN + else -> TO_RESULT_FUN + } + private fun CodeBlock.Builder.buildJsonBody( endpoint: Endpoint, params: Map>, @@ -222,11 +235,27 @@ internal object BodyGenerator { } } + // These types are always JSON-primitive-safe but live outside the shared ApiClientBase.kt + // overload set (String/Number/Boolean/Enum) so that referencing them doesn't force every + // generated client to depend on kotlinx-datetime or opt into ExperimentalUuidApi. Their values + // are stringified directly at the call site instead of going through encodeParam/encodePathParam. + private val CALLSITE_TO_STRING_TYPES = setOf(PrimitiveType.UUID, PrimitiveType.DATE_TIME, PrimitiveType.DATE) + + private fun Parameter.needsCallsiteToString(): Boolean = + (schema as? TypeRef.Primitive)?.type in CALLSITE_TO_STRING_TYPES + private fun buildUrlString(endpoint: Endpoint, params: Map>): CodeBlock { val (format, args) = params[ParameterLocation.PATH] .orEmpty() .fold($$"${%L}" + endpoint.path to listOf(BASE_URL)) { (format, args), param -> - format.replace("{${param.name}}", $$"${%M(%L)}") to args + ENCODE_PARAM_FUN + param.name.toCamelCase() + val paramName = param.name.toCamelCase() + val (placeholder, newArgs) = if (param.needsCallsiteToString()) { + $$"${%L.toString().%M()}" to listOf(paramName, ENCODE_URL_PATH_PART_FUN) + } else { + $$"${%M(%L)}" to listOf(ENCODE_PATH_PARAM_FUN, paramName) + } + val newFormat = format.replace("{${param.name}}", placeholder) + newFormat to args + newArgs } return CodeBlock.of("%P", CodeBlock.of(format, *args.toTypedArray())) } @@ -238,7 +267,11 @@ internal object BodyGenerator { for (param in headerParams) { val paramName = param.name.toCamelCase() optionalGuard(param.required, paramName) { - addStatement("append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) + if (param.needsCallsiteToString()) { + addStatement("append(%S, %L.toString())", param.name, paramName) + } else { + addStatement("append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) + } } } endControlFlow() @@ -252,7 +285,11 @@ internal object BodyGenerator { for (param in queryParams) { val paramName = param.name.toCamelCase() optionalGuard(param.required, paramName) { - addStatement("this.parameters.append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) + if (param.needsCallsiteToString()) { + addStatement("this.parameters.append(%S, %L.toString())", param.name, paramName) + } else { + addStatement("this.parameters.append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) + } } } endControlFlow() diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 4c5cf6f..f8b781d 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -7,6 +7,7 @@ import com.avsystem.justworks.core.gen.BASE64_CLASS import com.avsystem.justworks.core.gen.BASE_URL import com.avsystem.justworks.core.gen.CLIENT import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT +import com.avsystem.justworks.core.gen.EXPERIMENTAL_UUID_API import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE import com.avsystem.justworks.core.gen.HEADERS_FUN import com.avsystem.justworks.core.gen.HTTP_CLIENT @@ -15,13 +16,17 @@ import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER import com.avsystem.justworks.core.gen.HTTP_RESULT import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.Hierarchy +import com.avsystem.justworks.core.gen.JSON_CLASS import com.avsystem.justworks.core.gen.JSON_ELEMENT +import com.avsystem.justworks.core.gen.JSON_PROPERTY import com.avsystem.justworks.core.gen.NameRegistry +import com.avsystem.justworks.core.gen.OPT_IN import com.avsystem.justworks.core.gen.OutputOptions import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.client.BodyGenerator.buildFunctionBody import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParams import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildNullableParameter +import com.avsystem.justworks.core.gen.containsUuid import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.shared.toAuthParam import com.avsystem.justworks.core.gen.toCamelCase @@ -29,9 +34,13 @@ import com.avsystem.justworks.core.gen.toPascalCase import com.avsystem.justworks.core.gen.toTypeName import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.ContentType import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.ParameterLocation +import com.avsystem.justworks.core.model.Response import com.avsystem.justworks.core.model.SecurityScheme +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.BYTE_ARRAY import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec @@ -74,12 +83,7 @@ internal object ClientGenerator { val simpleName = "${options.apiClassPrefix}${tag.toPascalCase()}${options.apiClassSuffix}" val className = ClassName(apiPackage, nameRegistry.register(simpleName)) - val clientInitializer = if (hasPolymorphicTypes) { - val generatedSerializersModule = MemberName(hierarchy.modelPackage, GENERATED_SERIALIZERS_MODULE) - CodeBlock.of("${CREATE_HTTP_CLIENT}(%M)", generatedSerializersModule) - } else { - CodeBlock.of("${CREATE_HTTP_CLIENT}()") - } + val clientInitializer = CodeBlock.of("${CREATE_HTTP_CLIENT}()") val tokenType = LambdaTypeName.get(returnType = STRING) val isSingleBearer = securitySchemes.singleOrNull() is SecurityScheme.Bearer @@ -93,6 +97,15 @@ internal object ClientGenerator { .superclass(API_CLIENT_BASE) .addSuperclassConstructorParameter(BASE_URL) + if (hasPolymorphicTypes) { + val generatedSerializersModule = MemberName(hierarchy.modelPackage, GENERATED_SERIALIZERS_MODULE) + classBuilder.addSuperclassConstructorParameter( + "$JSON_PROPERTY = %T { serializersModule = %M }", + JSON_CLASS, + generatedSerializersModule, + ) + } + if (isSingleBearer) { // Single Bearer: use plain "token" param name for ergonomics constructorBuilder.addParameter(TOKEN, tokenType) @@ -143,10 +156,28 @@ internal object ClientGenerator { classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) } - return FileSpec - .builder(className) - .addType(classBuilder.build()) - .build() + val fileBuilder = FileSpec.builder(className).addType(classBuilder.build()) + if (endpoints.usesUuid()) { + fileBuilder.addAnnotation( + AnnotationSpec + .builder(OPT_IN) + .addMember("%T::class", EXPERIMENTAL_UUID_API) + .build(), + ) + } + return fileBuilder.build() + } + + // A Uuid-typed path/query/header param, request body, or response schema anywhere in this tag + // group means the generated function signatures reference kotlin.uuid.Uuid directly, which + // requires this file to opt into ExperimentalUuidApi (mirrors ModelGenerator's per-model check). + private fun List.usesUuid(): Boolean = any { endpoint -> + val responseRefs = endpoint.responses.values + .asSequence() + .mapNotNull { it.schema } + val requestRef = endpoint.requestBody?.schema + val parameterRefs = endpoint.parameters.asSequence().map { it.schema } + (responseRefs + listOfNotNull(requestRef) + parameterRefs).any { it.containsUuid() } } private fun buildApplyAuth( @@ -224,6 +255,7 @@ internal object ClientGenerator { private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { val functionName = methodRegistry.register(endpoint.operationId.toCamelCase()) val returnBodyType = resolveReturnType(endpoint) + val responseContentType = resolveSuccessResponse(endpoint)?.contentType val errorType = resolveErrorType(endpoint) val returnType = HTTP_RESULT.parameterizedBy(errorType, returnBodyType) @@ -273,7 +305,7 @@ internal object ClientGenerator { } } - funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) + funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType, responseContentType)) return funBuilder.build() } @@ -296,15 +328,41 @@ internal object ClientGenerator { context(_: Hierarchy) private fun resolveReturnType(endpoint: Endpoint): TypeName { - val twoXxSchema = endpoint.responses + val response = resolveSuccessResponse(endpoint) ?: return UNIT + val schemaType = response.schema?.toTypeName() ?: return UNIT + + // The declared schema type isn't always the type that can actually be decoded off the + // wire for a given content type, so it's overridden with whatever IS a faithful, safely + // decodable representation of that content type — rather than either forcing a decode + // that throws on every call, or failing generation over a spec inconsistency: + // - application/json: a `{type: string, format: byte}` (ByteArray) schema is a base64 + // *string* on the wire, not a JSON byte array — kotlinx.serialization's built-in + // ByteArraySerializer can't decode it. Surface the (still base64-encoded) String as-is. + // - text/plain is always raw text, so String is always a faithful representation of it, + // regardless of what the schema claims (e.g. `type: integer`) — body() always + // works, and a caller wanting the parsed type can convert it themselves. + // - application/octet-stream is arbitrary binary, not necessarily valid UTF-8 text, so + // ByteArray is the only safe universal representation — never downgrade this one to + // String, unlike text/plain, since that risks throwing or corrupting non-UTF8 bytes. + return when { + schemaType == BYTE_ARRAY && response.contentType == ContentType.JSON_CONTENT_TYPE -> STRING + response.contentType == ContentType.TEXT_PLAIN -> STRING + response.contentType == ContentType.OCTET_STREAM -> BYTE_ARRAY + else -> schemaType + } + } + + // The response whose schema/contentType determine the endpoint's return type: the first 2xx + // response with a schema, or (only when there's no 2xx response at all) the default response. + private fun resolveSuccessResponse(endpoint: Endpoint): Response? { + val twoXxResponse = endpoint.responses.entries .asSequence() .filter { it.key.startsWith("2") } - .firstNotNullOfOrNull { it.value.schema } + .map { it.value } + .firstOrNull { it.schema != null } - val schema = twoXxSchema ?: endpoint.responses["default"]?.schema.takeIf { + return twoXxResponse ?: endpoint.responses["default"]?.takeIf { endpoint.responses.none { it.key.startsWith("2") } } - - return schema?.toTypeName() ?: UNIT } } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt index 725b9a2..1f8f38a 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt @@ -32,6 +32,7 @@ import com.avsystem.justworks.core.gen.UUID_SERIALIZER import com.avsystem.justworks.core.gen.UUID_TYPE import com.avsystem.justworks.core.gen.collectInlineEnums import com.avsystem.justworks.core.gen.collectInlineSchemas +import com.avsystem.justworks.core.gen.containsUuid import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.model.ModelGenerator.buildNestedVariant import com.avsystem.justworks.core.gen.model.ModelGenerator.generateDataClass @@ -770,14 +771,6 @@ internal object ModelGenerator { private val SchemaModel.isPrimitiveOnly: Boolean get() = properties.isEmpty() && allOf == null && oneOf == null && anyOf == null - private fun TypeRef.containsUuid(): Boolean = when (this) { - is TypeRef.Primitive -> type == PrimitiveType.UUID - is TypeRef.Array -> items.containsUuid() - is TypeRef.Map -> valueType.containsUuid() - is TypeRef.Inline -> properties.any { it.type.containsUuid() } - is TypeRef.Reference, is TypeRef.InlineEnum, TypeRef.Unknown -> false - } - private fun ApiSpec.usesUuid(): Boolean { val schemaRefs = schemas.asSequence().flatMap { schema -> schema.properties.map { it.type } } val endpointRefs = endpoints.asSequence().flatMap { endpoint -> diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index 60894f3..3cef6a9 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -3,14 +3,21 @@ package com.avsystem.justworks.core.gen.shared import com.avsystem.justworks.core.gen.API_CLIENT_BASE import com.avsystem.justworks.core.gen.APPLY_AUTH import com.avsystem.justworks.core.gen.BASE_URL +import com.avsystem.justworks.core.gen.BODY_AS_TEXT_FUN import com.avsystem.justworks.core.gen.BODY_FUN import com.avsystem.justworks.core.gen.CLIENT import com.avsystem.justworks.core.gen.CLOSEABLE import com.avsystem.justworks.core.gen.CONTENT_NEGOTIATION +import com.avsystem.justworks.core.gen.CONTENT_TYPE_APPLICATION +import com.avsystem.justworks.core.gen.CONTENT_TYPE_FUN import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT +import com.avsystem.justworks.core.gen.DECODE_FROM_STRING_FUN import com.avsystem.justworks.core.gen.DESERIALIZE_ERROR_BODY_FUN import com.avsystem.justworks.core.gen.ENCODE_PARAM_FUN -import com.avsystem.justworks.core.gen.ENCODE_TO_STRING_FUN +import com.avsystem.justworks.core.gen.ENCODE_PATH_PARAM_FUN +import com.avsystem.justworks.core.gen.ENCODE_TO_JSON_ELEMENT_FUN +import com.avsystem.justworks.core.gen.ENCODE_URL_PATH_PART_FUN +import com.avsystem.justworks.core.gen.ENUM_CLASS import com.avsystem.justworks.core.gen.HTTP_CLIENT import com.avsystem.justworks.core.gen.HTTP_ERROR import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER @@ -21,69 +28,140 @@ import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.IO_EXCEPTION import com.avsystem.justworks.core.gen.JSON_CLASS import com.avsystem.justworks.core.gen.JSON_FUN +import com.avsystem.justworks.core.gen.JSON_PRIMITIVE_EXT +import com.avsystem.justworks.core.gen.JSON_PROPERTY import com.avsystem.justworks.core.gen.SAFE_CALL -import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.TO_EMPTY_RESULT_FUN +import com.avsystem.justworks.core.gen.TO_RAW_RESULT_FUN +import com.avsystem.justworks.core.gen.TO_RESULT_FUN +import com.squareup.kotlinpoet.BOOLEAN import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.NUMBER import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName import com.squareup.kotlinpoet.UNIT /** * Generates the shared `ApiClientBase.kt` file containing: - * - `encodeParam()` top-level utility function - * - `HttpResponse.deserializeErrorBody()` internal helper for error body deserialization - * - `HttpResponse.mapToResult()` private extension with response mapping logic - * - `HttpResponse.toResult()` extension for typed response mapping - * - `HttpResponse.toEmptyResult()` extension for Unit response mapping - * - `ApiClientBase` abstract class with common client infrastructure + * - `encodeParam()` / `encodePathParam()` top-level overload sets — String/Number/Boolean plus a + * reified `Enum` overload — no generic passthrough, so a value that can't serialize to a JSON + * primitive (an object, a list, a map, a raw byte array) fails to compile instead of throwing at + * runtime. Int/Long/Double/Float are covered by the Number overload via ordinary subtyping. + * Uuid/Instant/LocalDate are deliberately NOT overloads here: since this file is generated once, + * independent of any spec, an unconditional Uuid/LocalDate reference would force every consumer + * to opt into ExperimentalUuidApi / depend on kotlinx-datetime even if their spec never uses + * them. BodyGenerator instead renders those three as a direct `.toString()` call at the call + * site, in the per-spec client file that already imports the type only when it's actually used. + * - `ApiClientBase` abstract class with common client infrastructure, including: + * - `json` — the shared `Json` instance, also installed into `ContentNegotiation` by + * `createHttpClient()`, so success/error bodies are decoded through the exact same + * configuration (incl. `serializersModule`) rather than Ktor's implicit `body()` converter. + * - `deserializeErrorBody()` / `mapToResult()` internal helpers for response mapping. + * - `toResult()` — decodes a JSON success body via `json.decodeFromString`. + * - `toRawResult()` — keeps Ktor's native `body()` converter, for responses whose + * declared content type is `text/plain` (String) or `application/octet-stream` (ByteArray), + * which must NOT be run through JSON decoding. + * - `toEmptyResult()` for Unit response mapping. */ internal object ApiClientBaseGenerator { - private const val SERIALIZERS_MODULE_PARAM = "serializersModule" - private const val SUCCESS_BODY = "successBody" private const val MAP_TO_RESULT = "mapToResult" + private const val SUCCESS_BODY = "successBody" private const val BLOCK = "block" - fun generate(): FileSpec { - val t = TypeVariableName("T").copy(reified = true) - val e = TypeVariableName("E").copy(reified = true) + // Kotlin types that are always JSON primitives, mapped to how to render them as a String. + // `null` conversion means the value already is a String (returned as-is). + private val PRIMITIVE_SAFE_TYPES: List> = listOf( + STRING to null, + NUMBER to "toString", + BOOLEAN to "toString", + ) - return FileSpec - .builder(API_CLIENT_BASE) - .addFunction(buildEncodeParam(t)) - .addFunction(buildDeserializeErrorBody(e)) - .addFunction(buildMapToResult(e, t)) - .addFunction(buildToResult(e, t)) - .addFunction(buildToEmptyResult(e)) - .addType(buildApiClientBaseClass()) - .build() + fun generate(): FileSpec = FileSpec + .builder(API_CLIENT_BASE) + .addFunctions(buildEncodeParamOverloads()) + .addFunctions(buildEncodePathParamOverloads()) + .addType(buildApiClientBaseClass()) + .build() + + private fun enumTypeVariable(): TypeVariableName = TypeVariableName( + "T", + ENUM_CLASS.parameterizedBy(TypeVariableName("T")), + ).copy(reified = true) + + private fun buildEncodeParamOverloads(): List { + val simpleOverloads = PRIMITIVE_SAFE_TYPES.map { (type, conversion) -> + FunSpec + .builder(ENCODE_PARAM_FUN.simpleName) + .addParameter("value", type) + .returns(STRING) + .addStatement(if (conversion == null) "return value" else "return value.$conversion()") + .build() + } + + val enumOverload = FunSpec + .builder(ENCODE_PARAM_FUN.simpleName) + .addModifiers(KModifier.INLINE) + .addTypeVariable(enumTypeVariable()) + .addParameter("value", TypeVariableName("T")) + .returns(STRING) + .addStatement( + "return %T.%M(value).%M.content", + JSON_CLASS, + ENCODE_TO_JSON_ELEMENT_FUN, + JSON_PRIMITIVE_EXT, + ).build() + + return simpleOverloads + enumOverload } - private fun buildEncodeParam(t: TypeVariableName): FunSpec = FunSpec - .builder(ENCODE_PARAM_FUN.simpleName) - .addModifiers(KModifier.INLINE) - .addTypeVariable(t) - .addParameter("value", TypeVariableName("T")) - .returns(STRING) - .addStatement("return %T.%M(value).trim('\"')", JSON_CLASS, ENCODE_TO_STRING_FUN) - .build() + private fun buildEncodePathParamOverloads(): List { + val simpleOverloads = PRIMITIVE_SAFE_TYPES.map { (type, _) -> + FunSpec + .builder(ENCODE_PATH_PARAM_FUN.simpleName) + .addParameter("value", type) + .returns(STRING) + .addStatement("return %M(value).%M()", ENCODE_PARAM_FUN, ENCODE_URL_PATH_PART_FUN) + .build() + } + + val enumOverload = FunSpec + .builder(ENCODE_PATH_PARAM_FUN.simpleName) + .addModifiers(KModifier.INLINE) + .addTypeVariable(enumTypeVariable()) + .addParameter("value", TypeVariableName("T")) + .returns(STRING) + .addStatement("return %M(value).%M()", ENCODE_PARAM_FUN, ENCODE_URL_PATH_PART_FUN) + .build() + + return simpleOverloads + enumOverload + } private fun buildDeserializeErrorBody(e: TypeVariableName): FunSpec = FunSpec - .builder("deserializeErrorBody") + .builder(DESERIALIZE_ERROR_BODY_FUN) .addAnnotation(PublishedApi::class) .addModifiers(KModifier.INTERNAL, KModifier.SUSPEND, KModifier.INLINE) .addTypeVariable(e) .receiver(HTTP_RESPONSE) .returns(TypeVariableName("E").copy(nullable = true)) .beginControlFlow("return try") - .addStatement("%M()", BODY_FUN) + .beginControlFlow("when (%M()?.withoutParameters())", CONTENT_TYPE_FUN) + .addStatement( + "%T.Json -> %L.%M(%M())", + CONTENT_TYPE_APPLICATION, + JSON_PROPERTY, + DECODE_FROM_STRING_FUN, + BODY_AS_TEXT_FUN, + ).addStatement("else -> %M()", BODY_FUN) + .endControlFlow() .nextControlFlow("catch (e: %T)", Exception::class) .addStatement("if (e is %T) throw e", ClassName("kotlinx.coroutines", "CancellationException")) .addStatement("null") @@ -105,27 +183,42 @@ internal object ApiClientBaseGenerator { HTTP_SUCCESS, SUCCESS_BODY, ).addStatement( - "in 300..399 -> %T.Redirect(status.value, %M())", + "in 300..399 -> %T.Redirect(status.value, %L())", HTTP_ERROR, DESERIALIZE_ERROR_BODY_FUN, ).apply { for ((name, code) in ApiResponseGenerator.HTTP_ERROR_SUBTYPES) { addStatement( - "$code -> %T.$name(%M())", + "$code -> %T.$name(%L())", HTTP_ERROR, DESERIALIZE_ERROR_BODY_FUN, ) } }.addStatement( - "else -> %T.Other(status.value, %M())", + "else -> %T.Other(status.value, %L())", HTTP_ERROR, DESERIALIZE_ERROR_BODY_FUN, ).endControlFlow() .build() private fun buildToResult(e: TypeVariableName, t: TypeVariableName): FunSpec = FunSpec - .builder("toResult") - .addModifiers(KModifier.SUSPEND, KModifier.INLINE) + .builder(TO_RESULT_FUN) + .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) + .addTypeVariable(t) + .receiver(HTTP_RESPONSE) + .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), TypeVariableName("T"))) + .addStatement( + "return %L { %L.%M(%M()) }", + MAP_TO_RESULT, + JSON_PROPERTY, + DECODE_FROM_STRING_FUN, + BODY_AS_TEXT_FUN, + ).build() + + private fun buildToRawResult(e: TypeVariableName, t: TypeVariableName): FunSpec = FunSpec + .builder(TO_RAW_RESULT_FUN) + .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND, KModifier.INLINE) .addTypeVariable(e) .addTypeVariable(t) .receiver(HTTP_RESPONSE) @@ -134,8 +227,8 @@ internal object ApiClientBaseGenerator { .build() private fun buildToEmptyResult(e: TypeVariableName): FunSpec = FunSpec - .builder("toEmptyResult") - .addModifiers(KModifier.SUSPEND, KModifier.INLINE) + .builder(TO_EMPTY_RESULT_FUN) + .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND, KModifier.INLINE) .addTypeVariable(e) .receiver(HTTP_RESPONSE) .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), UNIT)) @@ -143,9 +236,15 @@ internal object ApiClientBaseGenerator { .build() private fun buildApiClientBaseClass(): TypeSpec { + val jsonParam = ParameterSpec + .builder(JSON_PROPERTY, JSON_CLASS) + .defaultValue("%T", JSON_CLASS) + .build() + val constructor = FunSpec .constructorBuilder() .addParameter(BASE_URL, STRING) + .addParameter(jsonParam) .build() val baseUrlProp = PropertySpec @@ -154,6 +253,13 @@ internal object ApiClientBaseGenerator { .addModifiers(KModifier.PROTECTED) .build() + val jsonProp = PropertySpec + .builder(JSON_PROPERTY, JSON_CLASS) + .initializer(JSON_PROPERTY) + .addAnnotation(PublishedApi::class) + .addModifiers(KModifier.INTERNAL) + .build() + val clientProp = PropertySpec .builder(CLIENT, HTTP_CLIENT) .addModifiers(KModifier.PROTECTED, KModifier.ABSTRACT) @@ -165,17 +271,25 @@ internal object ApiClientBaseGenerator { .addStatement("$CLIENT.close()") .build() + val e = TypeVariableName("E").copy(reified = true) + return TypeSpec .classBuilder(API_CLIENT_BASE) .addModifiers(KModifier.ABSTRACT) .addSuperinterface(CLOSEABLE) .primaryConstructor(constructor) .addProperty(baseUrlProp) + .addProperty(jsonProp) .addProperty(clientProp) .addFunction(closeFun) .addFunction(buildApplyAuth()) .addFunction(buildSafeCall()) .addFunction(buildCreateHttpClient()) + .addFunction(buildDeserializeErrorBody(e)) + .addFunction(buildMapToResult(e, TypeVariableName("T").copy(reified = true))) + .addFunction(buildToResult(e, TypeVariableName("T").copy(reified = true))) + .addFunction(buildToRawResult(e, TypeVariableName("T").copy(reified = true))) + .addFunction(buildToEmptyResult(e)) .build() } @@ -211,22 +325,10 @@ internal object ApiClientBaseGenerator { private fun buildCreateHttpClient(): FunSpec = FunSpec .builder(CREATE_HTTP_CLIENT) .addModifiers(KModifier.PROTECTED) - .addParameter( - ParameterSpec - .builder(SERIALIZERS_MODULE_PARAM, SERIALIZERS_MODULE.copy(nullable = true)) - .defaultValue("null") - .build(), - ).returns(HTTP_CLIENT) + .returns(HTTP_CLIENT) .beginControlFlow("return %T", HTTP_CLIENT) .beginControlFlow("install(%T)", CONTENT_NEGOTIATION) - .beginControlFlow("if ($SERIALIZERS_MODULE_PARAM != null)") - .addStatement( - "%M(%T { this.$SERIALIZERS_MODULE_PARAM = $SERIALIZERS_MODULE_PARAM })", - JSON_FUN, - JSON_CLASS, - ).nextControlFlow("else") - .addStatement("%M()", JSON_FUN) - .endControlFlow() + .addStatement("%M(%L)", JSON_FUN, JSON_PROPERTY) .endControlFlow() .addStatement("expectSuccess = false") .endControlFlow() diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt index 65df199..ea69002 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt @@ -17,7 +17,26 @@ class ApiClientBaseGeneratorTest { private val classSpec: TypeSpec get() = file.members.filterIsInstance().first { it.name == "ApiClientBase" } - private fun topLevelFun(name: String): FunSpec = file.members.filterIsInstance().first { it.name == name } + private fun topLevelFunOverloads(name: String): List = + file.members.filterIsInstance().filter { it.name == name } + + // toResult/toRawResult/toEmptyResult/mapToResult/deserializeErrorBody are members of + // ApiClientBase (not top-level) so they can reach its `json` property. + private fun classFun(name: String): FunSpec = classSpec.funSpecs.first { it.name == name } + + // Every simple (non-enum) overload takes a single non-generic parameter of one of these types — + // all of them JSON-primitive-safe. Notably absent: any collection/array/map/object type, and + // also Uuid/Instant/LocalDate — those are JSON-primitive-safe too, but rendering them here would + // force every generated client to opt into ExperimentalUuidApi / depend on kotlinx-datetime even + // when the spec never uses them. BodyGeneratorTest / ClientGeneratorTest cover how those three + // are actually encoded (a direct .toString() call at the call site). + private val expectedSimpleParamTypes = setOf( + "kotlin.String", + "kotlin.Number", + "kotlin.Boolean", + ) + + private fun FunSpec.singleParamType(): String = parameters.single().type.toString() // -- ApiClientBase class -- @@ -33,10 +52,10 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has constructor with only baseUrl`() { + fun `ApiClientBase has constructor with baseUrl and json`() { val constructor = assertNotNull(classSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertEquals(listOf("baseUrl"), paramNames) + assertEquals(listOf("baseUrl", "json"), paramNames) } @Test @@ -80,31 +99,154 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has createHttpClient function`() { + fun `ApiClientBase has createHttpClient function with no parameters`() { val create = classSpec.funSpecs.first { it.name == "createHttpClient" } assertTrue(KModifier.PROTECTED in create.modifiers) - val param = create.parameters.first { it.name == "serializersModule" } - assertTrue(param.type.isNullable, "serializersModule should be nullable") - assertEquals("null", param.defaultValue.toString()) + assertTrue( + create.parameters.isEmpty(), + "createHttpClient should take no parameters; json is a constructor property now", + ) val body = create.body.toString() assertTrue(body.contains("ContentNegotiation"), "Expected ContentNegotiation install") + assertTrue(body.contains("json(json)"), "Expected the shared json property installed into ContentNegotiation") assertTrue(body.contains("expectSuccess"), "Expected expectSuccess = false") } + // -- json property: constructor-injected val -- + + @Test + fun `ApiClientBase constructor takes baseUrl and a defaulted json parameter`() { + val constructor = assertNotNull(classSpec.primaryConstructor) + val jsonParam = constructor.parameters.first { it.name == "json" } + assertEquals("kotlinx.serialization.json.Json", jsonParam.type.toString()) + assertEquals( + "kotlinx.serialization.json.Json", + jsonParam.defaultValue.toString(), + "Expected the default Json instance as default value", + ) + } + + @Test + fun `ApiClientBase has an internal PublishedApi val json property`() { + val jsonProp = classSpec.propertySpecs.first { it.name == "json" } + // internal + @PublishedApi, not protected: mapToResult()/deserializeErrorBody() are + // @PublishedApi internal inline functions, and a public-API inline function (which + // @PublishedApi internal counts as) is not allowed to reference a `protected` member. + assertTrue(KModifier.INTERNAL in jsonProp.modifiers) + assertTrue( + jsonProp.annotations.any { it.typeName.toString() == "kotlin.PublishedApi" }, + "Expected @PublishedApi on json", + ) + assertTrue(!jsonProp.mutable, "json must be a val") + assertEquals("json", jsonProp.initializer.toString()) + } + // -- Top-level functions -- + // -- encodeParam / encodePathParam: overload set, not a generic passthrough -- + // + // These are no longer single generic functions. A generic passthrough would accept + // ANY type, including objects/lists/maps that don't serialize to a JsonPrimitive, and only fail + // at runtime when Json.encodeToJsonElement(...).jsonPrimitive throws. Restricting to an explicit + // overload set (one per JSON-primitive-safe type, plus a reified Enum overload) means a value + // that can't be a JSON primitive fails to *compile* in the generated client instead. + @Test - fun `encodeParam is inline with reified type parameter`() { - val fn = topLevelFun("encodeParam") - assertTrue(KModifier.INLINE in fn.modifiers) - val typeVar = fn.typeVariables.first() + fun `encodeParam has exactly one overload per JSON-primitive-safe type plus one enum overload`() { + val overloads = topLevelFunOverloads("encodeParam") + assertEquals(expectedSimpleParamTypes.size + 1, overloads.size, "Unexpected number of encodeParam overloads") + } + + @Test + fun `encodeParam simple overloads take exactly the expected non-generic types`() { + val simpleOverloads = topLevelFunOverloads("encodeParam").filter { it.typeVariables.isEmpty() } + val actualParamTypes = simpleOverloads.map { it.singleParamType() }.toSet() + assertEquals(expectedSimpleParamTypes, actualParamTypes) + assertTrue(simpleOverloads.none { KModifier.INLINE in it.modifiers }, "Simple overloads need no inline/reified") + } + + @Test + fun `encodeParam has no overload accepting a collection, map, or array type`() { + val paramTypes = topLevelFunOverloads("encodeParam").map { it.singleParamType() } + assertTrue( + paramTypes.none { it.startsWith("kotlin.collections.") }, + "Found a collection-typed overload: $paramTypes", + ) + } + + @Test + fun `encodeParam string overload is the identity function, not a JSON round-trip`() { + val stringOverload = topLevelFunOverloads("encodeParam").first { it.singleParamType() == "kotlin.String" } + assertEquals("return value\n", stringOverload.body.toString()) + } + + @Test + fun `encodeParam enum overload is inline reified and bounded by Enum, extracting raw JSON primitive content`() { + val enumOverload = topLevelFunOverloads("encodeParam").first { it.typeVariables.isNotEmpty() } + assertTrue(KModifier.INLINE in enumOverload.modifiers) + val typeVar = enumOverload.typeVariables.single() assertTrue(typeVar.isReified, "Expected reified type variable") + assertTrue(typeVar.bounds.any { it.toString().contains("Enum") }, "Expected an Enum bound") + + val body = enumOverload.body.toString() + assertTrue(body.contains("encodeToJsonElement"), "Expected encodeToJsonElement call") + assertTrue(body.contains("jsonPrimitive"), "Expected jsonPrimitive extraction") + assertTrue(body.contains(".content"), "Expected raw .content, not a URL-encoded value") + assertTrue(!body.contains("encodeURLPathPart"), "encodeParam must not URL-encode") + } + + @Test + fun `encodePathParam has exactly one overload per JSON-primitive-safe type plus one enum overload`() { + val overloads = topLevelFunOverloads("encodePathParam") + assertEquals( + expectedSimpleParamTypes.size + 1, + overloads.size, + "Unexpected number of encodePathParam overloads", + ) + } + + @Test + fun `encodePathParam simple overloads delegate to the matching encodeParam overload and URL-encode`() { + val simpleOverloads = topLevelFunOverloads("encodePathParam").filter { it.typeVariables.isEmpty() } + assertEquals(expectedSimpleParamTypes, simpleOverloads.map { it.singleParamType() }.toSet()) + for (overload in simpleOverloads) { + assertTrue(KModifier.INLINE !in overload.modifiers, "Simple overloads need no inline/reified") + val body = overload.body.toString() + assertTrue(body.contains("encodeParam(value)"), "Expected delegation to encodeParam: $body") + assertTrue(body.contains("encodeURLPathPart"), "Expected encodeURLPathPart to escape the segment: $body") + } + } + + @Test + fun `encodePathParam enum overload is inline reified, bounded by Enum, and URL-encodes`() { + val enumOverload = topLevelFunOverloads("encodePathParam").first { it.typeVariables.isNotEmpty() } + assertTrue(KModifier.INLINE in enumOverload.modifiers) + val typeVar = enumOverload.typeVariables.single() + assertTrue(typeVar.isReified, "Expected reified type variable") + assertTrue(typeVar.bounds.any { it.toString().contains("Enum") }, "Expected an Enum bound") + + val body = enumOverload.body.toString() + assertTrue(body.contains("encodeParam(value)"), "Expected delegation to encodeParam") + assertTrue(body.contains("encodeURLPathPart"), "Expected encodeURLPathPart to escape the segment") + } + + @Test + fun `ApiClientBase file never references Uuid, Instant, or LocalDate, so it needs no extra deps`() { + // Regression guard: this file is generated once, independent of any spec. Referencing + // kotlinx.datetime.LocalDate (or requiring an ExperimentalUuidApi opt-in) here would force + // EVERY consumer to add kotlinx-datetime / opt in, even for a spec with no date/uuid fields. + assertTrue(file.annotations.none { it.typeName.toString() == "kotlin.OptIn" }, "Expected no file-level @OptIn") + val rendered = file.toString() + assertTrue("Uuid" !in rendered, "ApiClientBase.kt must not reference Uuid") + assertTrue("kotlinx.datetime" !in rendered, "ApiClientBase.kt must not depend on kotlinx-datetime") + assertTrue("kotlin.time.Instant" !in rendered, "ApiClientBase.kt must not reference Instant") } @OptIn(ExperimentalKotlinPoetApi::class) @Test - fun `toResult is suspend inline with reified E and T, no context parameter`() { - val fn = topLevelFun("toResult") + fun `toResult is a protected suspend inline member with reified E and T, no context parameter`() { + val fn = classFun("toResult") + assertTrue(KModifier.PROTECTED in fn.modifiers, "Expected toResult to be a member, not top-level") assertTrue(KModifier.SUSPEND in fn.modifiers) assertTrue(KModifier.INLINE in fn.modifiers) assertEquals(2, fn.typeVariables.size, "Expected E and T type variables") @@ -113,12 +255,41 @@ class ApiClientBaseGeneratorTest { assertTrue(fn.contextParameters.isEmpty(), "Expected no context parameters") val returnType = fn.returnType as ParameterizedTypeName assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) + + // #110: decode explicitly through the shared `json`, not Ktor's implicit body() — + // otherwise a JSON-quoted String success body keeps its quotes. Rendered outside a + // FileSpec's import context, %M member references print fully qualified (e.g. + // "kotlinx.serialization.decodeFromString"), so check the pieces separately rather than + // one contiguous "json.decodeFromString" substring. + val body = fn.body.toString() + assertTrue(body.contains("json"), "Expected the shared json receiver, got: $body") + assertTrue(body.contains("decodeFromString"), "Expected decodeFromString, got: $body") + assertTrue(body.contains("bodyAsText"), "Expected bodyAsText() as the decoded input, got: $body") + } + + @OptIn(ExperimentalKotlinPoetApi::class) + @Test + fun `toRawResult is a protected suspend inline member using Ktor's native body converter`() { + val fn = classFun("toRawResult") + assertTrue(KModifier.PROTECTED in fn.modifiers, "Expected toRawResult to be a member, not top-level") + assertTrue(KModifier.SUSPEND in fn.modifiers) + assertTrue(KModifier.INLINE in fn.modifiers) + assertEquals(2, fn.typeVariables.size, "Expected E and T type variables") + assertTrue(fn.typeVariables.all { it.isReified }, "Expected reified type variables") + assertNotNull(fn.receiverType, "Expected HttpResponse receiver") + + // Used for text/plain (String) / octet-stream (ByteArray) responses, which must NOT go + // through json.decodeFromString — the raw bytes/text aren't necessarily valid JSON. + val body = fn.body.toString() + assertTrue(body.contains("body()"), "Expected the native body() converter, got: $body") + assertTrue(!body.contains("decodeFromString"), "toRawResult must not JSON-decode, got: $body") } @OptIn(ExperimentalKotlinPoetApi::class) @Test fun `toEmptyResult returns HttpResult E Unit with no context parameter`() { - val fn = topLevelFun("toEmptyResult") + val fn = classFun("toEmptyResult") + assertTrue(KModifier.PROTECTED in fn.modifiers, "Expected toEmptyResult to be a member, not top-level") assertTrue(KModifier.SUSPEND in fn.modifiers) assertTrue(KModifier.INLINE in fn.modifiers) assertEquals(1, fn.typeVariables.size, "Expected E type variable") @@ -131,7 +302,7 @@ class ApiClientBaseGeneratorTest { @Test fun `mapToResult branches on specific status codes`() { - val fn = topLevelFun("mapToResult") + val fn = classFun("mapToResult") val body = fn.body.toString() assertTrue(body.contains("in 200..299"), "Expected 2xx success range") assertTrue(body.contains("HttpSuccess"), "Expected HttpSuccess for success") @@ -160,8 +331,8 @@ class ApiClientBaseGeneratorTest { } @Test - fun `deserializeErrorBody helper function exists`() { - val fn = topLevelFun("deserializeErrorBody") + fun `deserializeErrorBody helper function exists and decodes through the shared json`() { + val fn = classFun("deserializeErrorBody") assertTrue(KModifier.INTERNAL in fn.modifiers) assertTrue(KModifier.INLINE in fn.modifiers) assertTrue(KModifier.SUSPEND in fn.modifiers) @@ -169,10 +340,21 @@ class ApiClientBaseGeneratorTest { assertTrue(fn.typeVariables.first().isReified, "Expected reified type variable") assertNotNull(fn.receiverType, "Expected HttpResponse receiver") val body = fn.body.toString() - assertTrue(body.contains("body"), "Expected body() call") + assertTrue(body.contains("json"), "Expected the shared json receiver, got: $body") + assertTrue(body.contains("decodeFromString"), "Expected decodeFromString, got: $body") + assertTrue(body.contains("bodyAsText"), "Expected bodyAsText() as the decoded input, got: $body") assertTrue(body.contains("catch"), "Expected catch block for fallback") } + @Test + fun `deserializeErrorBody dispatches on the response content type at runtime, falling back to native body()`() { + val fn = classFun("deserializeErrorBody") + val body = fn.body.toString() + assertTrue(body.contains("contentType"), "Expected a runtime contentType() check, got: $body") + assertTrue(body.contains("withoutParameters"), "Expected charset params to be stripped, got: $body") + assertTrue(body.contains("body()"), "Expected fallback to the native body() converter, got: $body") + } + @Test fun `generates single file named ApiClientBase`() { assertEquals("ApiClientBase", file.name) 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 03612f7..af38959 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 @@ -489,6 +489,145 @@ class ClientGeneratorTest { assertEquals("kotlin.ByteArray", returnType.typeArguments[1].toString()) } + // -- text/plain and octet-stream responses must keep Ktor's native body() + // converter (toRawResult), NOT the JSON-decoding toResult() — a raw text/plain body isn't + // necessarily valid JSON, so running it through json.decodeFromString would break it. + + @Test + fun `text plain response is chained with toRawResult, not toResult`() { + val ep = endpoint( + operationId = "getText", + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Primitive(PrimitiveType.STRING), ContentType.TEXT_PLAIN), + ), + ) + val cls = clientClass(ep) + val body = cls.funSpecs + .first { it.name == "getText" } + .body + .toString() + assertTrue(body.contains(".toRawResult()"), "Expected toRawResult() for text/plain, got: $body") + assertFalse(body.contains(".toResult()"), "text/plain must not use JSON-decoding toResult(), got: $body") + } + + @Test + fun `octet stream response is chained with toRawResult, not toResult`() { + 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 body = cls.funSpecs + .first { it.name == "getBinary" } + .body + .toString() + assertTrue(body.contains(".toRawResult()"), "Expected toRawResult() for octet-stream, got: $body") + assertFalse(body.contains(".toResult()"), "octet-stream must not use JSON-decoding toResult(), got: $body") + } + + @Test + fun `JSON string response (bare type string schema) is chained with toResult, not toRawResult`() { + val ep = endpoint( + operationId = "getToken", + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Primitive(PrimitiveType.STRING), ContentType.JSON_CONTENT_TYPE), + ), + ) + val cls = clientClass(ep) + val body = cls.funSpecs + .first { it.name == "getToken" } + .body + .toString() + assertTrue(body.contains(".toResult()"), "Expected JSON-decoding toResult() for a JSON string, got: $body") + assertFalse(body.contains(".toRawResult()"), "Got: $body") + } + + // A `format: byte` (ByteArray) schema under application/json is a base64 *string* on the wire, + // not a JSON byte array — kotlinx.serialization's built-in ByteArraySerializer can't decode + // it, and claiming a decoded ByteArray here would be misleading. Downgrade the return type to + // String (still base64-encoded) instead, which already decodes correctly via the existing + // JSON-string path + @Test + fun `byte array response under application json downgrades to String and uses toResult`() { + val ep = endpoint( + operationId = "getEncodedBinary", + responses = mapOf( + "200" to Response( + "200", + "OK", + TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), + ContentType.JSON_CONTENT_TYPE, + ), + ), + ) + val cls = clientClass(ep) + val fn = cls.funSpecs.first { it.name == "getEncodedBinary" } + val returnType = fn.returnType as ParameterizedTypeName + assertEquals("kotlin.String", returnType.typeArguments[1].toString(), "Expected String, not ByteArray") + val body = fn.body.toString() + assertTrue(body.contains(".toResult()"), "Expected JSON-decoding toResult(), got: $body") + assertFalse(body.contains(".toRawResult()"), "Got: $body") + } + + @Test + fun `byte array response under application octet stream keeps ByteArray and uses toRawResult`() { + val ep = endpoint( + operationId = "getBinaryOctetStream", + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), ContentType.OCTET_STREAM), + ), + ) + val cls = clientClass(ep) + val fn = cls.funSpecs.first { it.name == "getBinaryOctetStream" } + val returnType = fn.returnType as ParameterizedTypeName + assertEquals("kotlin.ByteArray", returnType.typeArguments[1].toString()) + val body = fn.body.toString() + assertTrue(body.contains(".toRawResult()"), "Expected raw body() converter, got: $body") + assertFalse(body.contains(".toResult()"), "Got: $body") + } + + // text/plain is always raw text, so String is always a faithful representation of it, + // regardless of what the schema claims — body() always works, and a caller wanting + // the parsed type (e.g. Int) can convert it themselves. + @Test + fun `text plain response with a non-String schema downgrades to String and uses toRawResult`() { + val ep = endpoint( + operationId = "getCount", + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Primitive(PrimitiveType.INT), ContentType.TEXT_PLAIN), + ), + ) + val cls = clientClass(ep) + val fn = cls.funSpecs.first { it.name == "getCount" } + val returnType = fn.returnType as ParameterizedTypeName + assertEquals("kotlin.String", returnType.typeArguments[1].toString(), "Expected String, not Int") + val body = fn.body.toString() + assertTrue(body.contains(".toRawResult()"), "Expected raw body() converter, got: $body") + assertFalse(body.contains(".toResult()"), "Got: $body") + } + + // application/octet-stream is arbitrary binary, not necessarily valid UTF-8 text, so ByteArray + // is the only safe universal representation of it — unlike text/plain, this is never + // downgraded to String, since that risks throwing or corrupting non-UTF8 bytes. + @Test + fun `octet stream response with a non-ByteArray schema downgrades to ByteArray and uses toRawResult`() { + val ep = endpoint( + operationId = "getCount", + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Primitive(PrimitiveType.INT), ContentType.OCTET_STREAM), + ), + ) + val cls = clientClass(ep) + val fn = cls.funSpecs.first { it.name == "getCount" } + val returnType = fn.returnType as ParameterizedTypeName + assertEquals("kotlin.ByteArray", returnType.typeArguments[1].toString(), "Expected ByteArray, not Int") + val body = fn.body.toString() + assertTrue(body.contains(".toRawResult()"), "Expected raw body() converter, got: $body") + assertFalse(body.contains(".toResult()"), "Got: $body") + } + @Test fun `mixed 200 and 204 responses uses 200 schema type`() { val ep = endpoint( @@ -577,21 +716,24 @@ class ClientGeneratorTest { // -- SER-01: Polymorphic spec wires SerializersModule -- @Test - fun `polymorphic spec wires serializersModule in createHttpClient call`() { + fun `polymorphic spec wires serializersModule into the json passed to the ApiClientBase superclass`() { val files = generate(spec(endpoint()), hasPolymorphicTypes = true) - val clientProperty = files + val cls = files .first() .members .filterIsInstance() .first() - .propertySpecs - .first { it.name == "client" } - val clientInitializer = clientProperty.initializer.toString() + val superParams = cls.superclassConstructorParameters.map { it.toString() } assertTrue( - clientInitializer.contains("generatedSerializersModule"), - "Expected generatedSerializersModule reference", + superParams.any { it.contains("json") && it.contains("generatedSerializersModule") }, + "Expected a json = Json { serializersModule = generatedSerializersModule } superclass param, got: $superParams", ) - assertTrue(clientInitializer.contains("createHttpClient"), "Expected createHttpClient call") + + val clientInitializer = cls.propertySpecs + .first { it.name == "client" } + .initializer + .toString() + assertEquals("createHttpClient()", clientInitializer, "createHttpClient() no longer takes serializersModule") } // -- CONT-01: Multipart form-data code generation -- @@ -725,6 +867,173 @@ class ClientGeneratorTest { assertFalse(body.contains("if (body"), "Should NOT check body != null when no requestBody") } + // -- Path params must be URL-encoded via encodePathParam, not the raw encodeParam -- + + @Test + fun `path parameters are encoded via encodePathParam`() { + val ep = endpoint( + path = "/pets/{petId}", + operationId = "getPet", + parameters = listOf( + Parameter("petId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.STRING), null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "getPet" } + val body = funSpec.body.toString() + assertTrue(body.contains("encodePathParam(petId)"), "Expected path param encoded via encodePathParam") + } + + @Test + fun `query and header parameters still use encodeParam, not encodePathParam`() { + val ep = endpoint( + path = "/pets/{petId}", + operationId = "getPet", + parameters = listOf( + Parameter("petId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.STRING), null), + Parameter("filter", ParameterLocation.QUERY, true, TypeRef.Primitive(PrimitiveType.STRING), null), + Parameter("X-Trace-Id", ParameterLocation.HEADER, true, TypeRef.Primitive(PrimitiveType.STRING), null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "getPet" } + val body = funSpec.body.toString() + assertTrue(body.contains("encodeParam(filter)"), "Expected query param encoded via encodeParam") + assertTrue(body.contains("encodeParam(xTraceId)"), "Expected header param encoded via encodeParam") + assertFalse(body.contains("encodePathParam(filter)"), "Query param must not use encodePathParam") + assertFalse(body.contains("encodePathParam(xTraceId)"), "Header param must not use encodePathParam") + } + + // -- Uuid/Instant/LocalDate params: encoded via a direct .toString() call site, not + // encodeParam/encodePathParam. These types are JSON-primitive-safe, but ApiClientBase.kt is + // generated once, independent of any spec, so giving them a shared overload there would force + // every generated client to opt into ExperimentalUuidApi / depend on kotlinx-datetime even for + // specs that never use them. + + @Test + fun `Uuid path parameter is stringified and URL-path-encoded at the call site`() { + val ep = endpoint( + path = "/events/{eventId}", + operationId = "getEvent", + parameters = listOf( + Parameter("eventId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.UUID), null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "getEvent" } + val body = funSpec.body.toString() + // KotlinPoet fully-qualifies %M references (e.g. io.ktor.http.encodeURLPathPart) when + // rendering a bare FunSpec outside a FileSpec's import context, so check the pieces + // separately rather than one contiguous "toString().encodeURLPathPart()" substring. + assertTrue(body.contains("eventId.toString()"), "Expected a direct toString() call, got: $body") + assertTrue(body.contains("encodeURLPathPart()"), "Expected URL-path-encoding, got: $body") + assertFalse(body.contains("encodePathParam(eventId)"), "Uuid path param must not go through encodePathParam") + } + + @Test + fun `Instant path parameter is stringified and URL-path-encoded at the call site`() { + val ep = endpoint( + path = "/events/{occurredAt}", + operationId = "getEvent", + parameters = listOf( + Parameter("occurredAt", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.DATE_TIME), null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "getEvent" } + val body = funSpec.body.toString() + assertTrue(body.contains("occurredAt.toString()"), "Expected a direct toString() call, got: $body") + assertTrue(body.contains("encodeURLPathPart()"), "Expected URL-path-encoding, got: $body") + assertFalse( + body.contains("encodePathParam(occurredAt)"), + "Instant path param must not go through encodePathParam", + ) + } + + @Test + fun `optional LocalDate query parameter is stringified at the call site, inside the null guard`() { + val ep = endpoint( + operationId = "listEvents", + parameters = listOf( + Parameter("since", ParameterLocation.QUERY, false, TypeRef.Primitive(PrimitiveType.DATE), null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listEvents" } + val body = funSpec.body.toString() + assertTrue(body.contains("if (since != null)"), "Expected null guard for optional param") + assertTrue( + body.contains("""this.parameters.append("since", since.toString())"""), + "Expected direct toString() call, got: $body", + ) + assertFalse(body.contains("encodeParam(since)"), "LocalDate query param must not go through encodeParam") + } + + @Test + fun `optional Uuid header parameter is stringified at the call site, inside the null guard`() { + val ep = endpoint( + operationId = "listEvents", + parameters = listOf( + Parameter("traceId", ParameterLocation.HEADER, false, TypeRef.Primitive(PrimitiveType.UUID), null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listEvents" } + val body = funSpec.body.toString() + assertTrue(body.contains("if (traceId != null)"), "Expected null guard for optional param") + assertTrue( + body.contains("""append("traceId", traceId.toString())"""), + "Expected direct toString() call, got: $body", + ) + assertFalse(body.contains("encodeParam(traceId)"), "Uuid header param must not go through encodeParam") + } + + @Test + fun `Int query parameter still resolves through encodeParam via the Number overload`() { + val ep = endpoint( + operationId = "listEvents", + parameters = listOf( + Parameter("limit", ParameterLocation.QUERY, false, TypeRef.Primitive(PrimitiveType.INT), null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listEvents" } + val body = funSpec.body.toString() + assertTrue(body.contains("""this.parameters.append("limit","""), "Expected append(\"limit\", ...), got: $body") + assertTrue(body.contains("encodeParam(limit)"), "Expected Int query param to still go through encodeParam") + } + + // -- Uuid-typed param/body/response forces the generated client file to opt into + // ExperimentalUuidApi, since the function signature references kotlin.uuid.Uuid directly + // (independent of how encodeParam/encodePathParam encode it). + + @Test + fun `client file opts into ExperimentalUuidApi when a param is Uuid-typed`() { + val ep = endpoint( + path = "/events/{eventId}", + operationId = "getEvent", + parameters = listOf( + Parameter("eventId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.UUID), null), + ), + ) + val file = generate(spec(ep)).first() + val optInAnnotation = file.annotations.firstOrNull { it.typeName.toString() == "kotlin.OptIn" } + assertNotNull(optInAnnotation, "Expected a file-level @OptIn annotation") + assertTrue( + optInAnnotation.members.any { it.toString().contains("ExperimentalUuidApi") }, + "Expected @OptIn(ExperimentalUuidApi::class)", + ) + } + + @Test + fun `client file does not opt into ExperimentalUuidApi when no param is Uuid-typed`() { + val file = generate(spec(endpoint())).first() + assertTrue( + file.annotations.none { it.typeName.toString() == "kotlin.OptIn" }, + "Expected no file-level @OptIn when no Uuid is involved", + ) + } + // -- URL interpolation: baseUrl must be interpolated, not literal -- @Test diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index 36d1df0..0c57bd9 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt @@ -289,13 +289,13 @@ class IntegrationTest { "public suspend fun getInventory(): HttpResult>", ) - // PetApi.getPetById: int64 path param -> Long, path templated via encodeParam. + // PetApi.getPetById: int64 path param -> Long, path templated via encodePathParam. val petApi = gen.client("PetApi") assertContains( petApi, "public suspend fun getPetById(petId: Long): HttpResult", ) - assertContains(petApi, "/pet/\${encodeParam(petId)}") + assertContains(petApi, "/pet/\${encodePathParam(petId)}") } @Test diff --git a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt index cfd51af..5b51dde 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -965,6 +965,301 @@ class JustworksPluginFunctionalTest { ) } + @Test + fun `path parameter values with spaces and slashes are percent-encoded, not left raw`() { + // Regression test for issue #108: path params were previously interpolated via encodeParam, + // which strips the JSON quotes but does NOT URL-encode. A "/" would split the URL into an + // extra path segment, and a raw space would produce an invalid URL. Both must now go through + // the generated encodePathParam(), which additionally applies Ktor's encodeURLPathPart(). + writeBuildFile() + + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" + id("com.avsystem.justworks") + } + + repositories { + mavenCentral() + } + + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-content-negotiation:3.1.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") + testImplementation(kotlin("test-junit")) + } + + justworks { + specs { + register("main") { + specFile = file("api/petstore.yaml") + packageName = "com.example" + } + } + } + """.trimIndent(), + ) + + writeFile( + "src/test/kotlin/PathParamEncodingTest.kt", + """ + import com.avsystem.justworks.encodePathParam + import kotlin.test.Test + import kotlin.test.assertEquals + import kotlin.test.assertFalse + + class PathParamEncodingTest { + @Test + fun `space in a path param value is percent-encoded rather than left raw`() { + val encoded = encodePathParam("free form") + assertFalse(' ' in encoded, "Raw space in a URL path segment: ${'$'}encoded") + assertEquals("free%20form", encoded) + } + + @Test + fun `slash in a path param value is percent-encoded rather than splitting the path`() { + val encoded = encodePathParam("a/b") + assertFalse('/' in encoded, "A literal slash would introduce an extra path segment: ${'$'}encoded") + assertEquals("a%2Fb", encoded) + } + + @Test + fun `value with both a space and a slash is fully percent-encoded`() { + val encoded = encodePathParam("a b/c") + assertEquals("a%20b%2Fc", encoded) + } + } + """.trimIndent(), + ) + + val result = runner("test").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + "justworksGenerateMain should succeed", + ) + assertEquals( + TaskOutcome.SUCCESS, + result.task(":test")?.outcome, + "path param encoding test against the generated encodePathParam() should pass", + ) + } + + @Test + fun `query parameter that cannot serialize to a JSON primitive fails to compile, not to throw at runtime`() { + // encodeParam()/encodePathParam() are an explicit overload set (String, Number, Boolean, and + // a reified Enum) rather than a generic passthrough. Uuid/Instant/LocalDate are + // handled separately (see the call-site-encoding test below). A query param typed as an array + // has no matching overload, so the *generated client* now fails to compile instead of + // compiling and throwing "JsonArray is not a JsonPrimitive" the first time someone calls the + // endpoint. + writeFile( + "api/petstore.yaml", + """ + openapi: '3.0.0' + info: + title: Petstore + version: '1.0' + paths: + /pets: + get: + operationId: listPets + summary: List pets + tags: + - pets + parameters: + - name: tags + in: query + required: true + schema: + type: array + items: + type: string + responses: + '200': + description: A list of pets + """.trimIndent(), + ) + + writeBuildFile() + + val result = runner("compileKotlin").buildAndFail() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + "code generation itself should still succeed; only compilation of the generated code should fail", + ) + assertEquals( + TaskOutcome.FAILED, + result.task(":compileKotlin")?.outcome, + "compileKotlin must fail: no encodeParam() overload accepts a List", + ) + assertTrue( + result.output.contains("encodeParam"), + "Expected the compiler error to point at the unresolved encodeParam(...) call, got: ${result.output}", + ) + } + + @Test + fun `Uuid, Instant, and LocalDate path, query, and header params compile against the real types`() { + // Uuid/Instant/LocalDate are JSON-primitive-safe but deliberately have no overload in the + // shared ApiClientBase.kt (see ApiClientBaseGeneratorTest) — BodyGenerator instead renders a + // direct .toString() call (plus .encodeURLPathPart() for path segments) at the call site, in + // the per-spec client file. ClientGeneratorTest already checks the exact generated source + // text; this test proves that text actually *compiles* against the real kotlin.uuid.Uuid, + // kotlin.time.Instant, and kotlinx.datetime.LocalDate types and real Ktor — not just that it + // looks right as a KotlinPoet string. + writeFile( + "api/petstore.yaml", + """ + openapi: '3.0.0' + info: + title: Events + version: '1.0' + paths: + /events/{eventId}/at/{occurredAt}: + get: + operationId: getEvent + tags: + - events + parameters: + - name: eventId + in: path + required: true + schema: + type: string + format: uuid + - name: occurredAt + in: path + required: true + schema: + type: string + format: date-time + responses: + '200': + description: OK + /events: + get: + operationId: listEvents + tags: + - events + parameters: + - name: since + in: query + required: true + schema: + type: string + format: date + - name: X-Trace-Id + in: header + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + """.trimIndent(), + ) + + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" + id("com.avsystem.justworks") + } + + repositories { + mavenCentral() + } + + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-content-negotiation:3.1.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") + testImplementation(kotlin("test-junit")) + } + + justworks { + specs { + register("main") { + specFile = file("api/petstore.yaml") + packageName = "com.example" + } + } + } + """.trimIndent(), + ) + + // The generated client class isn't `open`, so it can't be subclassed with a mock HttpClient + // from a test — this test only proves the call-site .toString()/.encodeURLPathPart() code + // BodyGenerator emits for these three types actually compiles and type-checks, by referencing + // the generated function with real Uuid/Instant/LocalDate arguments. The exact encoded string + // shape is covered by ClientGeneratorTest at the KotlinPoet-source level, and the encoding + // primitives themselves (Ktor's encodeURLPathPart, LocalDate/Instant ISO-8601 toString) are + // exercised directly in the path-param-encoding functional test above. + writeFile( + "src/test/kotlin/DateTimeParamUsageTest.kt", + """ + import com.example.api.EventsApi + import kotlin.test.Test + import kotlin.time.Instant + import kotlin.uuid.ExperimentalUuidApi + import kotlin.uuid.Uuid + import kotlinx.datetime.LocalDate + + @OptIn(ExperimentalUuidApi::class) + class DateTimeParamUsageTest { + private val api = EventsApi("https://example.com") + + // Never invoked (there is no mock server to call) — its BODY must still type-check, + // which is enough to prove every encodeParam(...)/.toString() call site BodyGenerator + // emitted for Uuid/Instant/LocalDate path/query/header params resolves against the + // real types. If any of them had no valid target, this file would fail to compile. + private suspend fun exerciseGeneratedCallSites() { + val eventId = Uuid.parse("f47ac10b-58cc-4372-a567-0e02b2c3d479") + val occurredAt = Instant.parse("2024-01-15T10:30:00Z") + val since = LocalDate.parse("2024-01-15") + val traceId = Uuid.parse("f47ac10b-58cc-4372-a567-0e02b2c3d479") + api.getEvent(eventId, occurredAt) + api.listEvents(since, traceId) + } + + @Test + fun `compiles`() { + // See exerciseGeneratedCallSites() above — that's the actual assertion. + } + } + """.trimIndent(), + ) + + val result = runner("compileTestKotlin").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + "justworksGenerateMain should succeed", + ) + assertEquals( + TaskOutcome.SUCCESS, + result.task(":compileTestKotlin")?.outcome, + "Uuid/Instant/LocalDate params must compile against the real types", + ) + } + @Test fun `multiple specs with identical security schemes pass the build`() { writeFile( @@ -1027,4 +1322,185 @@ class JustworksPluginFunctionalTest { runner("justworksSharedTypes").build() } + + @Test + fun `json string responses strip quotes at runtime, text-plain responses stay raw`() { + writeFile( + "api/petstore.yaml", + """ + openapi: '3.0.0' + info: + title: Quoting Test + version: '1.0' + paths: + /token: + get: + operationId: getToken + tags: + - quoting + responses: + '200': + description: A JSON-quoted string + content: + application/json: + schema: + type: string + /raw: + get: + operationId: getRaw + tags: + - quoting + responses: + '200': + description: A raw text/plain string + content: + text/plain: + schema: + type: string + """.trimIndent(), + ) + + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" + id("com.avsystem.justworks") + } + + repositories { + mavenCentral() + } + + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-content-negotiation:3.1.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") + testImplementation(kotlin("test-junit")) + testImplementation("io.ktor:ktor-client-mock:3.1.1") + } + + justworks { + specs { + register("main") { + specFile = file("api/petstore.yaml") + packageName = "com.example" + } + } + } + """.trimIndent(), + ) + + // A hand-written ApiClientBase subclass with a MockEngine swapped in for `client` — this + // exercises the real (generated) toResult()/toRawResult() member functions against a fake + // HTTP response, proving the decoding behavior at runtime rather than just inspecting source. + writeFile( + "src/test/kotlin/QuoteStrippingTest.kt", + """ + import com.avsystem.justworks.ApiClientBase + import com.avsystem.justworks.HttpError + import com.avsystem.justworks.HttpSuccess + import io.ktor.client.HttpClient + import io.ktor.client.engine.mock.MockEngine + import io.ktor.client.engine.mock.respond + import io.ktor.client.plugins.contentnegotiation.ContentNegotiation + import io.ktor.client.request.get + import io.ktor.http.HttpHeaders + import io.ktor.http.HttpStatusCode + import io.ktor.http.headersOf + import io.ktor.serialization.kotlinx.json.json + import kotlinx.coroutines.runBlocking + import kotlin.test.Test + import kotlin.test.assertEquals + + class QuoteStrippingTest { + private class TestClient(baseUrl: String, engine: MockEngine) : ApiClientBase(baseUrl) { + override val client: HttpClient = HttpClient(engine) { + install(ContentNegotiation) { json() } + } + + fun jsonToken() = runBlocking { + client.get("${'$'}baseUrl/token").toResult() + } + + fun rawText() = runBlocking { + client.get("${'$'}baseUrl/raw").toRawResult() + } + } + + @Test + fun `a JSON-quoted String success body decodes without the surrounding quotes`() { + val engine = MockEngine { + respond( + "\"abc-123\"", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, listOf("application/json")), + ) + } + val result = TestClient("http://test", engine).jsonToken() + assertEquals(HttpSuccess(200, "abc-123"), result) + } + + @Test + fun `a text-plain String success body is passed through untouched, not JSON-decoded`() { + val engine = MockEngine { + respond( + "abc-123", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, listOf("text/plain")), + ) + } + val result = TestClient("http://test", engine).rawText() + assertEquals(HttpSuccess(200, "abc-123"), result) + } + + @Test + fun `a text-plain 404 error body decodes to its raw text, not null`() { + val engine = MockEngine { + respond( + "Not found", + HttpStatusCode.NotFound, + headersOf(HttpHeaders.ContentType, listOf("text/plain")), + ) + } + val result = TestClient("http://test", engine).jsonToken() + assertEquals(HttpError.NotFound("Not found"), result) + } + + @Test + fun `a 404 error body with no Content-Type decodes to its raw text, not null`() { + val engine = MockEngine { respond("Not found", HttpStatusCode.NotFound) } + val result = TestClient("http://test", engine).jsonToken() + assertEquals(HttpError.NotFound("Not found"), result) + } + } + """.trimIndent(), + ) + + val result = runner("test").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + "justworksGenerateMain should succeed", + ) + assertEquals( + TaskOutcome.SUCCESS, + result.task(":test")?.outcome, + "MockEngine round-trip test for issue #110 should pass", + ) + + // Also verify codegen wires each response's declared content type to the right helper: + // the JSON string response to toResult(), the text/plain response to toRawResult(). + val clientFile = projectDir.resolve("build/generated/justworks/main/com/example/api/QuotingApi.kt") + assertTrue(clientFile.exists(), "QuotingApi.kt should exist") + val functions = clientFile.readText().split(Regex("(?=suspend fun )")) + val getTokenFn = functions.first { it.contains("getToken(") } + val getRawFn = functions.first { it.contains("getRaw(") } + assertTrue(getTokenFn.contains(".toResult()"), "getToken (JSON string) should use toResult(), got: $getTokenFn") + assertTrue(getRawFn.contains(".toRawResult()"), "getRaw (text/plain) should use toRawResult(), got: $getRawFn") + } }