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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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")

// ============================================================================
Expand All @@ -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"
8 changes: 8 additions & 0 deletions core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -48,8 +53,9 @@ internal object BodyGenerator {
endpoint: Endpoint,
params: Map<ParameterLocation, List<Parameter>>,
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")
Expand All @@ -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<ParameterLocation, List<Parameter>>,
Expand Down Expand Up @@ -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<T>) 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<ParameterLocation, List<Parameter>>): CodeBlock {
val (format, args) = params[ParameterLocation.PATH]
.orEmpty()
.fold($$"${%L}" + endpoint.path to listOf<Any>(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<Any>()))
}
Expand All @@ -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()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,23 +16,31 @@ 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
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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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<Endpoint>.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(
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -273,7 +305,7 @@ internal object ClientGenerator {
}
}

funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType))
funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType, responseContentType))

return funBuilder.build()
}
Expand All @@ -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<String>() 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
}
}
Loading
Loading