diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Utils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Utils.kt new file mode 100644 index 0000000..9547a06 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Utils.kt @@ -0,0 +1,5 @@ +package com.avsystem.justworks.core + +import kotlin.enums.enumEntries + +inline fun > String.toEnumOrNull(): T? = enumEntries().find { it.name.equals(this, true) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt deleted file mode 100644 index e182e6f..0000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.ApiSpec -import com.avsystem.justworks.core.model.Endpoint -import com.avsystem.justworks.core.model.HttpMethod -import com.avsystem.justworks.core.model.Parameter -import com.avsystem.justworks.core.model.ParameterLocation -import com.avsystem.justworks.core.model.TypeRef -import com.squareup.kotlinpoet.ClassName -import com.squareup.kotlinpoet.CodeBlock -import com.squareup.kotlinpoet.ContextParameter -import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi -import com.squareup.kotlinpoet.FileSpec -import com.squareup.kotlinpoet.FunSpec -import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.LambdaTypeName -import com.squareup.kotlinpoet.MemberName -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.UNIT - -private const val DEFAULT_TAG = "Default" -private const val API_SUFFIX = "Api" - -/** - * Generates one KotlinPoet [FileSpec] per API tag, each containing a client class - * that extends `ApiClientBase` with suspend functions for every endpoint in that tag group. - */ -@OptIn(ExperimentalKotlinPoetApi::class) -class ClientGenerator(private val apiPackage: String, private val modelPackage: String) { - fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { - val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } - return grouped.map { (tag, endpoints) -> generateClientFile(tag, endpoints, hasPolymorphicTypes) } - } - - private fun generateClientFile( - tag: String, - endpoints: List, - hasPolymorphicTypes: Boolean = false, - ): FileSpec { - val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX") - - val clientInitializer = if (hasPolymorphicTypes) { - val generatedSerializersModule = MemberName(modelPackage, GENERATED_SERIALIZERS_MODULE) - CodeBlock.of("$CREATE_HTTP_CLIENT(%M)", generatedSerializersModule) - } else { - CodeBlock.of("$CREATE_HTTP_CLIENT()") - } - - val tokenType = LambdaTypeName.get(returnType = STRING) - - val primaryConstructor = FunSpec - .constructorBuilder() - .addParameter(BASE_URL, STRING) - .addParameter(TOKEN, tokenType) - .build() - - val httpClientProperty = PropertySpec - .builder(CLIENT, HTTP_CLIENT) - .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) - .initializer(clientInitializer) - .build() - - val classBuilder = TypeSpec - .classBuilder(className) - .superclass(API_CLIENT_BASE) - .addSuperclassConstructorParameter(BASE_URL) - .addSuperclassConstructorParameter(TOKEN) - .primaryConstructor(primaryConstructor) - .addProperty(httpClientProperty) - - classBuilder.addFunctions(endpoints.map(::generateEndpointFunction)) - - return FileSpec - .builder(className) - .addType(classBuilder.build()) - .build() - } - - private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { - val functionName = endpoint.operationId.toCamelCase() - val returnBodyType = resolveReturnType(endpoint) - val returnType = HTTP_SUCCESS.parameterizedBy(returnBodyType) - - val funBuilder = FunSpec - .builder(functionName) - .addModifiers(KModifier.SUSPEND) - .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) - .returns(returnType) - - val params = endpoint.parameters.groupBy { it.location } - - val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> - ParameterSpec(param.name.toCamelCase(), TypeMapping.toTypeName(param.schema, modelPackage)) - } - - val queryParams = params[ParameterLocation.QUERY].orEmpty().map { param -> - buildNullableParameter(param.schema, param.name, param.required) - } - - val headerParams = params[ParameterLocation.HEADER].orEmpty().map { param -> - buildNullableParameter(param.schema, param.name, param.required) - } - - funBuilder.addParameters(pathParams + queryParams + headerParams) - - if (endpoint.requestBody != null) { - funBuilder.addParameter( - buildNullableParameter(endpoint.requestBody.schema, BODY, endpoint.requestBody.required), - ) - } - - funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) - - return funBuilder.build() - } - - private fun buildNullableParameter( - typeRef: TypeRef, - name: String, - required: Boolean, - ): ParameterSpec { - val baseType = TypeMapping.toTypeName(typeRef, modelPackage) - - val builder = ParameterSpec.builder(name.toCamelCase(), baseType.copy(nullable = !required)) - if (!required) builder.defaultValue("null") - return builder.build() - } - - private fun buildFunctionBody( - endpoint: Endpoint, - params: Map>, - returnBodyType: TypeName, - ): CodeBlock { - val httpMethodFun = when (endpoint.method) { - HttpMethod.GET -> GET_FUN - HttpMethod.POST -> POST_FUN - HttpMethod.PUT -> PUT_FUN - HttpMethod.DELETE -> DELETE_FUN - HttpMethod.PATCH -> PATCH_FUN - } - - val (format, args) = params[ParameterLocation.PATH] - .orEmpty() - .fold($$"${'$'}{$$BASE_URL}" + endpoint.path to emptyList()) { (format, args), param -> - format.replace("{${param.name}}", $$"${%M(%L)}") to args + ENCODE_PARAM_FUN + param.name.toCamelCase() - } - - val urlString = CodeBlock.of("%P", CodeBlock.of(format, *args.toTypedArray())) - val resultFun = if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN - - val code = CodeBlock.builder() - - code.beginControlFlow("return $SAFE_CALL") - - code.beginControlFlow("$CLIENT.%M(%L)", httpMethodFun, urlString) - code.addStatement("$APPLY_AUTH()") - - val headerParams = params[ParameterLocation.HEADER] - if (!headerParams.isNullOrEmpty()) { - code.beginControlFlow("%M", HEADERS_FUN) - for (param in headerParams) { - val paramName = param.name.toCamelCase() - code.optionalGuard(param.required, paramName) { - addStatement("append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) - } - } - code.endControlFlow() - } - - val queryParams = params[ParameterLocation.QUERY] - if (!queryParams.isNullOrEmpty()) { - code.beginControlFlow("url") - for (param in queryParams) { - val paramName = param.name.toCamelCase() - code.optionalGuard(param.required, paramName) { - addStatement("this.parameters.append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) - } - } - code.endControlFlow() - } - - if (endpoint.requestBody != null) { - code.optionalGuard(endpoint.requestBody.required, BODY) { - addStatement("%M(%T.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APPLICATION) - addStatement("%M(%L)", SET_BODY_FUN, BODY) - } - } - - code.endControlFlow() // client.METHOD - code.unindent() - code.add("}.%M()\n", resultFun) - - return code.build() - } - - private fun resolveReturnType(endpoint: Endpoint): TypeName = endpoint.responses.entries - .asSequence() - .filter { it.key.startsWith("2") } - .firstNotNullOfOrNull { it.value.schema } - ?.let { successResponse -> TypeMapping.toTypeName(successResponse, modelPackage) } - ?: UNIT - - /** - * If [required], emits [block] directly. Otherwise wraps it in `if (name != null) { ... }`. - */ - private inline fun CodeBlock.Builder.optionalGuard( - required: Boolean, - name: String, - block: CodeBlock.Builder.() -> Unit, - ) { - if (!required) beginControlFlow("if (%L != null)", name) - block() - if (!required) endControlFlow() - } -} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index 10c6715..aff30d7 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -1,5 +1,10 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.client.ClientGenerator +import com.avsystem.justworks.core.gen.model.ModelGenerator +import com.avsystem.justworks.core.gen.shared.ApiClientBaseGenerator +import com.avsystem.justworks.core.gen.shared.ApiResponseGenerator +import com.avsystem.justworks.core.gen.shared.SerializersModuleGenerator import com.avsystem.justworks.core.model.ApiSpec import java.io.File @@ -14,14 +19,14 @@ object CodeGenerator { spec: ApiSpec, modelPackage: String, apiPackage: String, - outputDir: File - ): Result { - val modelFiles = ModelGenerator(modelPackage).generate(spec) + outputDir: File, + ): Result = context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { + val modelFiles = ModelGenerator.generate(spec) modelFiles.forEach { it.writeTo(outputDir) } val hasPolymorphicTypes = modelFiles.any { it.name == SerializersModuleGenerator.FILE_NAME } - val clientFiles = ClientGenerator(apiPackage, modelPackage).generate(spec, hasPolymorphicTypes) + val clientFiles = ClientGenerator.generate(spec, hasPolymorphicTypes) clientFiles.forEach { it.writeTo(outputDir) } return Result(modelFiles.size, clientFiles.size) 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 e2166db..e5dbd9a 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 @@ -25,6 +25,19 @@ val PUT_FUN = MemberName("io.ktor.client.request", "put") val DELETE_FUN = MemberName("io.ktor.client.request", "delete") val PATCH_FUN = MemberName("io.ktor.client.request", "patch") +// ============================================================================ +// Ktor Forms & Multipart +// ============================================================================ + +val SUBMIT_FORM_FUN = MemberName("io.ktor.client.request.forms", "submitForm") +val SUBMIT_FORM_WITH_BINARY_DATA_FUN = MemberName("io.ktor.client.request.forms", "submitFormWithBinaryData") +val FORM_DATA_FUN = MemberName("io.ktor.client.request.forms", "formData") +val CHANNEL_PROVIDER = ClassName("io.ktor.client.request.forms", "ChannelProvider") +val PARAMETERS_FUN = MemberName("io.ktor.http", "parameters") +val CONTENT_TYPE_CLASS = ClassName("io.ktor.http", "ContentType") +val HEADERS_CLASS = ClassName("io.ktor.http", "Headers") +val HTTP_METHOD_CLASS = ClassName("io.ktor.http", "HttpMethod") + // ============================================================================ // kotlinx.serialization // ============================================================================ diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Packages.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Packages.kt new file mode 100644 index 0000000..0e6c5de --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Packages.kt @@ -0,0 +1,20 @@ +package com.avsystem.justworks.core.gen + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.MemberName + +internal sealed interface Package { + val name: String +} + +@JvmInline +internal value class ModelPackage(override val name: String) : Package + +@JvmInline +internal value class ApiPackage(override val name: String) : Package + +internal operator fun ClassName.Companion.invoke(pkg: Package, vararg simpleNames: String): ClassName = + ClassName(pkg.name, *simpleNames) + +internal operator fun MemberName.Companion.invoke(pkg: Package, memberName: String): MemberName = + MemberName(pkg.name, memberName) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt deleted file mode 100644 index 8bb3cc2..0000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.PrimitiveType -import com.avsystem.justworks.core.model.TypeRef -import com.squareup.kotlinpoet.BOOLEAN -import com.squareup.kotlinpoet.BYTE_ARRAY -import com.squareup.kotlinpoet.ClassName -import com.squareup.kotlinpoet.DOUBLE -import com.squareup.kotlinpoet.FLOAT -import com.squareup.kotlinpoet.INT -import com.squareup.kotlinpoet.LIST -import com.squareup.kotlinpoet.LONG -import com.squareup.kotlinpoet.MAP -import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy -import com.squareup.kotlinpoet.STRING -import com.squareup.kotlinpoet.TypeName - -/** - * Maps [TypeRef] sealed variants to KotlinPoet [TypeName] instances. - */ -object TypeMapping { - fun toTypeName(typeRef: TypeRef, modelPackage: String): TypeName = when (typeRef) { - is TypeRef.Primitive -> { - when (typeRef.type) { - PrimitiveType.STRING -> STRING - PrimitiveType.INT -> INT - PrimitiveType.LONG -> LONG - PrimitiveType.DOUBLE -> DOUBLE - PrimitiveType.FLOAT -> FLOAT - PrimitiveType.BOOLEAN -> BOOLEAN - PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY - PrimitiveType.DATE_TIME -> INSTANT - PrimitiveType.DATE -> LOCAL_DATE - PrimitiveType.UUID -> UUID_TYPE - } - } - - is TypeRef.Array -> { - LIST.parameterizedBy(toTypeName(typeRef.items, modelPackage)) - } - - is TypeRef.Map -> { - MAP.parameterizedBy(STRING, toTypeName(typeRef.valueType, modelPackage)) - } - - is TypeRef.Reference -> { - ClassName(modelPackage, typeRef.schemaName) - } - - is TypeRef.Inline -> { - ClassName(modelPackage, typeRef.contextHint.toInlinedName()) - } - - is TypeRef.Unknown -> { - JSON_ELEMENT - } - } -} 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 new file mode 100644 index 0000000..af5c67d --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt @@ -0,0 +1,69 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.BOOLEAN +import com.squareup.kotlinpoet.BYTE_ARRAY +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.DOUBLE +import com.squareup.kotlinpoet.FLOAT +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.LIST +import com.squareup.kotlinpoet.LONG +import com.squareup.kotlinpoet.MAP +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName + +internal val TypeRef.properties: List + get() = when (this) { + is TypeRef.Inline -> properties + is TypeRef.Array, is TypeRef.Map, is TypeRef.Primitive, is TypeRef.Reference, TypeRef.Unknown -> emptyList() + } + +internal val TypeRef.requiredProperties: Set + get() = when (this) { + is TypeRef.Inline -> requiredProperties + is TypeRef.Array, is TypeRef.Map, is TypeRef.Primitive, is TypeRef.Reference, TypeRef.Unknown -> emptySet() + } + +context(modelPackage: ModelPackage) +internal fun TypeRef.toTypeName(): TypeName = when (this) { + is TypeRef.Primitive -> { + when (type) { + PrimitiveType.STRING -> STRING + PrimitiveType.INT -> INT + PrimitiveType.LONG -> LONG + PrimitiveType.DOUBLE -> DOUBLE + PrimitiveType.FLOAT -> FLOAT + PrimitiveType.BOOLEAN -> BOOLEAN + PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY + PrimitiveType.DATE_TIME -> INSTANT + PrimitiveType.DATE -> LOCAL_DATE + PrimitiveType.UUID -> UUID_TYPE + } + } + + is TypeRef.Array -> { + LIST.parameterizedBy(items.toTypeName()) + } + + is TypeRef.Map -> { + MAP.parameterizedBy(STRING, valueType.toTypeName()) + } + + is TypeRef.Reference -> { + ClassName(modelPackage, schemaName) + } + + is TypeRef.Inline -> { + ClassName(modelPackage, contextHint.toInlinedName()) + } + + is TypeRef.Unknown -> { + JSON_ELEMENT + } +} + +fun TypeRef.isBinaryUpload(): Boolean = this is TypeRef.Primitive && this.type == PrimitiveType.BYTE_ARRAY 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 new file mode 100644 index 0000000..af8bc2f --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt @@ -0,0 +1,237 @@ +package com.avsystem.justworks.core.gen.client + +import com.avsystem.justworks.core.gen.APPLY_AUTH +import com.avsystem.justworks.core.gen.BASE_URL +import com.avsystem.justworks.core.gen.BODY +import com.avsystem.justworks.core.gen.CLIENT +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.FORM_DATA_FUN +import com.avsystem.justworks.core.gen.GET_FUN +import com.avsystem.justworks.core.gen.HEADERS_CLASS +import com.avsystem.justworks.core.gen.HEADERS_FUN +import com.avsystem.justworks.core.gen.HTTP_HEADERS +import com.avsystem.justworks.core.gen.HTTP_METHOD_CLASS +import com.avsystem.justworks.core.gen.PARAMETERS_FUN +import com.avsystem.justworks.core.gen.PATCH_FUN +import com.avsystem.justworks.core.gen.POST_FUN +import com.avsystem.justworks.core.gen.PUT_FUN +import com.avsystem.justworks.core.gen.SAFE_CALL +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_RESULT_FUN +import com.avsystem.justworks.core.gen.isBinaryUpload +import com.avsystem.justworks.core.gen.properties +import com.avsystem.justworks.core.gen.requiredProperties +import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.gen.toPascalCase +import com.avsystem.justworks.core.model.ContentType +import com.avsystem.justworks.core.model.Endpoint +import com.avsystem.justworks.core.model.HttpMethod +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.CodeBlock +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.UNIT + +internal object BodyGenerator { + fun buildFunctionBody( + endpoint: Endpoint, + params: Map>, + returnBodyType: TypeName, + ): CodeBlock = CodeBlock + .builder() + .beginControlFlow("return $SAFE_CALL") + .apply { + val urlString = buildUrlString(endpoint, params) + when (endpoint.requestBody?.contentType) { + ContentType.MULTIPART_FORM_DATA -> buildMultipartBody(endpoint, params, urlString) + ContentType.FORM_URL_ENCODED -> buildFormUrlEncodedBody(endpoint, params, urlString) + ContentType.JSON_CONTENT_TYPE, null -> buildJsonBody(endpoint, params, urlString) + } + }.unindent() + .add("}.%M()\n", if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN) + .build() + + private fun CodeBlock.Builder.buildJsonBody( + endpoint: Endpoint, + params: Map>, + urlString: CodeBlock, + ) { + val httpMethodFun = when (endpoint.method) { + HttpMethod.GET -> GET_FUN + HttpMethod.POST -> POST_FUN + HttpMethod.PUT -> PUT_FUN + HttpMethod.DELETE -> DELETE_FUN + HttpMethod.PATCH -> PATCH_FUN + } + + beginControlFlow("$CLIENT.%M(%L)", httpMethodFun, urlString) + addCommonRequestParts(params) + + optionalGuard(endpoint.requestBody?.required ?: false, BODY) { + addStatement("%M(%T.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APPLICATION) + addStatement("%M(%L)", SET_BODY_FUN, BODY) + } + + endControlFlow() // client.METHOD + } + + private fun CodeBlock.Builder.buildMultipartBody( + endpoint: Endpoint, + params: Map>, + urlString: CodeBlock, + ) { + val properties = endpoint.requestBody + ?.schema + ?.properties + .orEmpty() + + beginControlFlow( + "$CLIENT.%M(\nurl = %L,\nformData = %M", + SUBMIT_FORM_WITH_BINARY_DATA_FUN, + urlString, + FORM_DATA_FUN, + ) + + for (prop in properties) { + val paramName = prop.name.toCamelCase() + if (prop.type.isBinaryUpload()) { + beginControlFlow( + "append(%S, %L, %T.build", + prop.name, + paramName, + HEADERS_CLASS, + ) + addStatement( + "append(%T.ContentType, %L.toString())", + HTTP_HEADERS, + "${paramName}ContentType", + ) + addStatement( + "append(%T.ContentDisposition, %P)", + HTTP_HEADERS, + CodeBlock.of($$"filename=\"${%L}\"", "${paramName}Name"), + ) + endControlFlow() + add(")\n") + } else { + addStatement("append(%S, %L)", prop.name, paramName) + } + } + + finishFormRequest(endpoint, params) + } + + private fun CodeBlock.Builder.buildFormUrlEncodedBody( + endpoint: Endpoint, + params: Map>, + urlString: CodeBlock, + ) { + val properties = endpoint.requestBody + ?.schema + ?.properties + .orEmpty() + + val requiredProperties = endpoint.requestBody + ?.schema + ?.requiredProperties + .orEmpty() + + beginControlFlow( + "$CLIENT.%M(\nurl = %L,\nformParameters = %M", + SUBMIT_FORM_FUN, + urlString, + PARAMETERS_FUN, + ) + + for (prop in properties) { + val paramName = prop.name.toCamelCase() + val isRequired = endpoint.requestBody?.required == true && prop.name in requiredProperties + val isString = prop.type == TypeRef.Primitive(PrimitiveType.STRING) + val valueExpr = if (isString) paramName else "$paramName.toString()" + + optionalGuard(isRequired, paramName) { + addStatement("append(%S, %L)", prop.name, valueExpr) + } + } + + finishFormRequest(endpoint, params) + } + + private fun CodeBlock.Builder.finishFormRequest( + endpoint: Endpoint, + params: Map>, + ) { + endControlFlow() // formData / parameters + beginControlFlow(")") + addCommonRequestParts(params) + addHttpMethodIfNeeded(endpoint.method) + endControlFlow() + } + + private fun CodeBlock.Builder.addCommonRequestParts(params: Map>) { + addStatement("${APPLY_AUTH}()") + addHeaderParams(params) + addQueryParams(params) + } + + private fun CodeBlock.Builder.addHttpMethodIfNeeded(method: HttpMethod) { + if (method != HttpMethod.POST) { + addStatement("method = %T.%L", HTTP_METHOD_CLASS, method.name.toPascalCase()) + } + } + + private fun buildUrlString(endpoint: Endpoint, params: Map>): CodeBlock { + val (format, args) = params[ParameterLocation.PATH] + .orEmpty() + .fold($$"${'$'}{$$BASE_URL}" + endpoint.path to emptyList()) { (format, args), param -> + format.replace("{${param.name}}", $$"${%M(%L)}") to args + ENCODE_PARAM_FUN + param.name.toCamelCase() + } + return CodeBlock.of("%P", CodeBlock.of(format, *args.toTypedArray())) + } + + private fun CodeBlock.Builder.addHeaderParams(params: Map>) { + val headerParams = params[ParameterLocation.HEADER] + if (!headerParams.isNullOrEmpty()) { + beginControlFlow("%M", HEADERS_FUN) + for (param in headerParams) { + val paramName = param.name.toCamelCase() + optionalGuard(param.required, paramName) { + addStatement("append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) + } + } + endControlFlow() + } + } + + private fun CodeBlock.Builder.addQueryParams(params: Map>) { + val queryParams = params[ParameterLocation.QUERY] + if (!queryParams.isNullOrEmpty()) { + beginControlFlow("url") + 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) + } + } + endControlFlow() + } + } + + internal inline fun CodeBlock.Builder.optionalGuard( + required: Boolean, + name: String, + block: CodeBlock.Builder.() -> Unit, + ) { + if (!required) beginControlFlow("if (%L != null)", name) + block() + if (!required) 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 new file mode 100644 index 0000000..8900a67 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -0,0 +1,147 @@ +package com.avsystem.justworks.core.gen.client + +import com.avsystem.justworks.core.gen.API_CLIENT_BASE +import com.avsystem.justworks.core.gen.ApiPackage +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.GENERATED_SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.HTTP_CLIENT +import com.avsystem.justworks.core.gen.HTTP_ERROR +import com.avsystem.justworks.core.gen.HTTP_SUCCESS +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.RAISE +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.invoke +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.ApiSpec +import com.avsystem.justworks.core.model.Endpoint +import com.avsystem.justworks.core.model.ParameterLocation +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.ContextParameter +import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.MemberName +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.UNIT + +/** + * Generates one KotlinPoet [FileSpec] per API tag, each containing a client class + * that extends `ApiClientBase` with suspend functions for every endpoint in that tag group. + */ + +@OptIn(ExperimentalKotlinPoetApi::class) +internal object ClientGenerator { + private const val DEFAULT_TAG = "Default" + private const val API_SUFFIX = "Api" + + context(_: ModelPackage, _: ApiPackage) + fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { + val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } + return grouped.map { (tag, endpoints) -> generateClientFile(tag, endpoints, hasPolymorphicTypes) } + } + + context(modelPackage: ModelPackage, apiPackage: ApiPackage) + private fun generateClientFile( + tag: String, + endpoints: List, + hasPolymorphicTypes: Boolean = false, + ): FileSpec { + val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX") + + val clientInitializer = if (hasPolymorphicTypes) { + val generatedSerializersModule = MemberName(modelPackage, GENERATED_SERIALIZERS_MODULE) + CodeBlock.of("${CREATE_HTTP_CLIENT}(%M)", generatedSerializersModule) + } else { + CodeBlock.of("${CREATE_HTTP_CLIENT}()") + } + + val tokenType = LambdaTypeName.get(returnType = STRING) + + val primaryConstructor = FunSpec + .constructorBuilder() + .addParameter(BASE_URL, STRING) + .addParameter(TOKEN, tokenType) + .build() + + val httpClientProperty = PropertySpec + .builder(CLIENT, HTTP_CLIENT) + .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) + .initializer(clientInitializer) + .build() + + val classBuilder = TypeSpec + .classBuilder(className) + .superclass(API_CLIENT_BASE) + .addSuperclassConstructorParameter(BASE_URL) + .addSuperclassConstructorParameter(TOKEN) + .primaryConstructor(primaryConstructor) + .addProperty(httpClientProperty) + + classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) + + return FileSpec + .builder(className) + .addType(classBuilder.build()) + .build() + } + + context(_: ModelPackage) + private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { + val functionName = endpoint.operationId.toCamelCase() + val returnBodyType = resolveReturnType(endpoint) + val returnType = HTTP_SUCCESS.parameterizedBy(returnBodyType) + + val funBuilder = FunSpec + .builder(functionName) + .addModifiers(KModifier.SUSPEND) + .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) + .returns(returnType) + + val params = endpoint.parameters.groupBy { it.location } + + val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> + ParameterSpec(param.name.toCamelCase(), param.schema.toTypeName()) + } + + val queryParams = params[ParameterLocation.QUERY].orEmpty().map { param -> + buildNullableParameter(param.schema, param.name, param.required) + } + + val headerParams = params[ParameterLocation.HEADER].orEmpty().map { param -> + buildNullableParameter(param.schema, param.name, param.required) + } + + funBuilder.addParameters(pathParams + queryParams + headerParams) + + if (endpoint.requestBody != null) { + funBuilder.addParameters(buildBodyParams(endpoint.requestBody)) + } + + funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) + + return funBuilder.build() + } + + context(_: ModelPackage) + private fun resolveReturnType(endpoint: Endpoint): TypeName = endpoint.responses.entries + .asSequence() + .filter { it.key.startsWith("2") } + .firstNotNullOfOrNull { it.value.schema } + ?.toTypeName() + ?: UNIT +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt new file mode 100644 index 0000000..845b13f --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt @@ -0,0 +1,59 @@ +package com.avsystem.justworks.core.gen.client + +import com.avsystem.justworks.core.gen.BODY +import com.avsystem.justworks.core.gen.CHANNEL_PROVIDER +import com.avsystem.justworks.core.gen.CONTENT_TYPE_CLASS +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.isBinaryUpload +import com.avsystem.justworks.core.gen.properties +import com.avsystem.justworks.core.gen.requiredProperties +import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.gen.toTypeName +import com.avsystem.justworks.core.model.ContentType +import com.avsystem.justworks.core.model.RequestBody +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.STRING + +internal object ParametersGenerator { + context(_: ModelPackage) + fun buildMultipartParameters(requestBody: RequestBody): List = + requestBody.schema.properties.flatMap { prop -> + val name = prop.name.toCamelCase() + if (prop.type.isBinaryUpload()) { + listOf( + ParameterSpec(name, CHANNEL_PROVIDER), + ParameterSpec("${name}Name", STRING), + ParameterSpec("${name}ContentType", CONTENT_TYPE_CLASS), + ) + } else { + listOf( + ParameterSpec(name, prop.type.toTypeName()), + ) + } + } + + context(_: ModelPackage) + fun buildFormParameters(requestBody: RequestBody): List = requestBody.schema.properties.map { prop -> + val isRequired = requestBody.required && prop.name in requestBody.schema.requiredProperties + buildNullableParameter(prop.type, prop.name, isRequired) + } + + context(_: ModelPackage) + fun buildNullableParameter( + typeRef: TypeRef, + name: String, + required: Boolean, + ): ParameterSpec { + val builder = ParameterSpec.builder(name.toCamelCase(), typeRef.toTypeName().copy(nullable = !required)) + if (!required) builder.defaultValue("null") + return builder.build() + } + + context(_: ModelPackage) + fun buildBodyParams(requestBody: RequestBody) = when (requestBody.contentType) { + ContentType.MULTIPART_FORM_DATA -> buildMultipartParameters(requestBody) + ContentType.FORM_URL_ENCODED -> buildFormParameters(requestBody) + ContentType.JSON_CONTENT_TYPE -> listOf(buildNullableParameter(requestBody.schema, BODY, requestBody.required)) + } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt similarity index 83% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt index 6f0c950..2c4930d 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt @@ -1,6 +1,35 @@ -package com.avsystem.justworks.core.gen +package com.avsystem.justworks.core.gen.model import arrow.core.raise.catch +import com.avsystem.justworks.core.gen.DECODER +import com.avsystem.justworks.core.gen.ENCODER +import com.avsystem.justworks.core.gen.EXPERIMENTAL_SERIALIZATION_API +import com.avsystem.justworks.core.gen.EXPERIMENTAL_UUID_API +import com.avsystem.justworks.core.gen.INSTANT +import com.avsystem.justworks.core.gen.InlineSchemaDeduplicator +import com.avsystem.justworks.core.gen.InlineSchemaKey +import com.avsystem.justworks.core.gen.JSON_CLASS_DISCRIMINATOR +import com.avsystem.justworks.core.gen.JSON_CONTENT_POLYMORPHIC_SERIALIZER +import com.avsystem.justworks.core.gen.JSON_ELEMENT +import com.avsystem.justworks.core.gen.JSON_OBJECT_EXT +import com.avsystem.justworks.core.gen.K_SERIALIZER +import com.avsystem.justworks.core.gen.LOCAL_DATE +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.OPT_IN +import com.avsystem.justworks.core.gen.PRIMITIVE_KIND +import com.avsystem.justworks.core.gen.PRIMITIVE_SERIAL_DESCRIPTOR_FUN +import com.avsystem.justworks.core.gen.SERIALIZABLE +import com.avsystem.justworks.core.gen.SERIALIZATION_EXCEPTION +import com.avsystem.justworks.core.gen.SERIAL_DESCRIPTOR +import com.avsystem.justworks.core.gen.SERIAL_NAME +import com.avsystem.justworks.core.gen.USE_SERIALIZERS +import com.avsystem.justworks.core.gen.UUID_TYPE +import com.avsystem.justworks.core.gen.invoke +import com.avsystem.justworks.core.gen.shared.SerializersModuleGenerator +import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.gen.toEnumConstantName +import com.avsystem.justworks.core.gen.toInlinedName +import com.avsystem.justworks.core.gen.toTypeName import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.EnumModel import com.avsystem.justworks.core.model.PrimitiveType @@ -22,15 +51,17 @@ import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.WildcardTypeName import kotlinx.datetime.LocalDate +import kotlin.sequences.flatMap import kotlin.time.Instant /** - * Generates KotlinPoet [FileSpec] instances from an [ApiSpec]. + * Generates KotlinPoet [com.squareup.kotlinpoet.FileSpec] instances from an [com.avsystem.justworks.core.model.ApiSpec]. * - * Produces one file per [SchemaModel] (data class, sealed interface, or allOf composed class) - * and one file per [EnumModel] (enum class), all annotated with kotlinx.serialization annotations. + * Produces one file per [com.avsystem.justworks.core.model.SchemaModel] (data class, sealed interface, or allOf composed class) + * and one file per [com.avsystem.justworks.core.model.EnumModel] (enum class), all annotated with kotlinx.serialization annotations. */ -class ModelGenerator(private val modelPackage: String) { +internal object ModelGenerator { + context(_: ModelPackage) fun generate(spec: ApiSpec): List = context( buildHierarchyInfo(spec.schemas), InlineSchemaDeduplicator(spec.schemas.map { it.name }.toSet()), @@ -41,9 +72,9 @@ class ModelGenerator(private val modelPackage: String) { if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it) } - val enumFiles = spec.enums.map(::generateEnumClass) + val enumFiles = spec.enums.map { generateEnumClass(it) } - val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate() + val serializersModuleFile = SerializersModuleGenerator.generate() val uuidSerializerFile = if (spec.usesUuid()) generateUuidSerializer() else null @@ -57,6 +88,7 @@ class ModelGenerator(private val modelPackage: String) { val schemas: List, ) + context(modelPackage: ModelPackage) private fun buildHierarchyInfo(schemas: List): HierarchyInfo { fun SchemaModel.variants() = oneOf ?: anyOf ?: emptyList() @@ -118,7 +150,7 @@ class ModelGenerator(private val modelPackage: String) { }.toList() } - context(hierarchy: HierarchyInfo) + context(hierarchy: HierarchyInfo, _: ModelPackage) private fun generateSchemaFiles(schema: SchemaModel): List = when { !schema.anyOf.isNullOrEmpty() || !schema.oneOf.isNullOrEmpty() -> { if (schema.name in hierarchy.anyOfWithoutDiscriminator) { @@ -129,9 +161,7 @@ class ModelGenerator(private val modelPackage: String) { } schema.isPrimitiveOnly -> { - val targetType = schema.underlyingType - ?.let { TypeMapping.toTypeName(it, modelPackage) } - ?: STRING + val targetType = schema.underlyingType?.toTypeName() ?: STRING listOf(generateTypeAlias(schema, targetType)) } @@ -145,7 +175,7 @@ class ModelGenerator(private val modelPackage: String) { * - anyOf without discriminator: @Serializable(with = XxxSerializer::class) * - oneOf or anyOf with discriminator: plain @Serializable + @JsonClassDiscriminator */ - context(hierarchy: HierarchyInfo) + context(hierarchy: HierarchyInfo, modelPackage: ModelPackage) private fun generateSealedInterface(schema: SchemaModel): FileSpec { val className = ClassName(modelPackage, schema.name) @@ -193,7 +223,7 @@ class ModelGenerator(private val modelPackage: String) { /** * Generates a JsonContentPolymorphicSerializer object for an anyOf schema without discriminator. */ - context(hierarchy: HierarchyInfo) + context(hierarchy: HierarchyInfo, modelPackage: ModelPackage) private fun generatePolymorphicSerializer(schema: SchemaModel): FileSpec { val sealedClassName = ClassName(modelPackage, schema.name) val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") @@ -249,6 +279,7 @@ class ModelGenerator(private val modelPackage: String) { /** * Builds the body code for selectDeserializer using field-presence heuristics. */ + context(modelPackage: ModelPackage) private fun buildSelectDeserializerBody( parentName: String, uniqueFieldsPerVariant: Map, @@ -291,7 +322,7 @@ class ModelGenerator(private val modelPackage: String) { /** * Generates a data class FileSpec, with superinterfaces and @SerialName resolved from hierarchy. */ - context(hierarchy: HierarchyInfo) + context(hierarchy: HierarchyInfo, modelPackage: ModelPackage) private fun generateDataClass(schema: SchemaModel): FileSpec { val className = ClassName(modelPackage, schema.name) @@ -309,7 +340,7 @@ class ModelGenerator(private val modelPackage: String) { val constructorBuilder = FunSpec.constructorBuilder() val propertySpecs = sortedProps.map { prop -> - val type = TypeMapping.toTypeName(prop.type, modelPackage).copy(nullable = prop.nullable) + val type = prop.type.toTypeName().copy(nullable = prop.nullable) val kotlinName = prop.name.toCamelCase() val paramBuilder = ParameterSpec.builder(kotlinName, type) @@ -324,7 +355,12 @@ class ModelGenerator(private val modelPackage: String) { val propBuilder = PropertySpec .builder(kotlinName, type) .initializer(kotlinName) - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) + .addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", prop.name) + .build(), + ) propBuilder.build() } @@ -338,7 +374,12 @@ class ModelGenerator(private val modelPackage: String) { .addSuperinterfaces(superinterfaces) if (serialName != null) { - typeSpec.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", serialName).build()) + typeSpec.addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", serialName) + .build(), + ) } if (schema.description != null) { @@ -350,7 +391,10 @@ class ModelGenerator(private val modelPackage: String) { val hasUuid = schema.properties.any { it.type.containsUuid() } if (hasUuid) { fileBuilder.addAnnotation( - AnnotationSpec.builder(OPT_IN).addMember("%T::class", EXPERIMENTAL_UUID_API).build(), + AnnotationSpec + .builder(OPT_IN) + .addMember("%T::class", EXPERIMENTAL_UUID_API) + .build(), ) fileBuilder.addAnnotation( AnnotationSpec @@ -366,6 +410,8 @@ class ModelGenerator(private val modelPackage: String) { /** * Formats a default value from a PropertyModel for use in KotlinPoet ParameterSpec.defaultValue(). */ + + context(modelPackage: ModelPackage) private fun formatDefaultValue(prop: PropertyModel): CodeBlock = when (prop.type) { is TypeRef.Primitive -> { when (prop.type.type) { @@ -424,6 +470,7 @@ class ModelGenerator(private val modelPackage: String) { } ?: variantSchemaName + context(modelPackage: ModelPackage) private fun generateEnumClass(enum: EnumModel): FileSpec { val className = ClassName(modelPackage, enum.name) @@ -432,8 +479,12 @@ class ModelGenerator(private val modelPackage: String) { enum.values.forEach { value -> val anonymousClass = TypeSpec .anonymousClassBuilder() - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value).build()) - .build() + .addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", value) + .build(), + ).build() typeSpec.addEnumConstant(value.toEnumConstantName(), anonymousClass) } @@ -474,7 +525,7 @@ class ModelGenerator(private val modelPackage: String) { return visited.toList() } - context(_: HierarchyInfo) + context(_: HierarchyInfo, _: ModelPackage) private fun generateNestedInlineClass(schema: SchemaModel): FileSpec = generateDataClass(schema.copy(name = schema.name.toInlinedName())) @@ -504,6 +555,7 @@ class ModelGenerator(private val modelPackage: String) { return schemaRefs.plus(endpointRefs).any { it.containsUuid() } } + context(modelPackage: ModelPackage) private fun generateUuidSerializer(): FileSpec { val uuidSerializerClass = ClassName(modelPackage, "UuidSerializer") @@ -539,11 +591,16 @@ class ModelGenerator(private val modelPackage: String) { return FileSpec .builder(uuidSerializerClass) - .addAnnotation(AnnotationSpec.builder(OPT_IN).addMember("%T::class", EXPERIMENTAL_UUID_API).build()) - .addType(objectSpec) + .addAnnotation( + AnnotationSpec + .builder(OPT_IN) + .addMember("%T::class", EXPERIMENTAL_UUID_API) + .build(), + ).addType(objectSpec) .build() } + context(modelPackage: ModelPackage) private fun generateTypeAlias(schema: SchemaModel, primitiveType: TypeName): FileSpec { val className = ClassName(modelPackage, schema.name) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt similarity index 82% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index 8c953e7..0e9afdc 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -1,5 +1,34 @@ -package com.avsystem.justworks.core.gen - +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.CREATE_HTTP_CLIENT +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.HEADERS_FUN +import com.avsystem.justworks.core.gen.HTTP_CLIENT +import com.avsystem.justworks.core.gen.HTTP_ERROR +import com.avsystem.justworks.core.gen.HTTP_ERROR_TYPE +import com.avsystem.justworks.core.gen.HTTP_HEADERS +import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER +import com.avsystem.justworks.core.gen.HTTP_REQUEST_TIMEOUT_EXCEPTION +import com.avsystem.justworks.core.gen.HTTP_RESPONSE +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.NETWORK_ERROR +import com.avsystem.justworks.core.gen.RAISE +import com.avsystem.justworks.core.gen.RAISE_FUN +import com.avsystem.justworks.core.gen.SAFE_CALL +import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.TOKEN import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.ContextParameter import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi @@ -24,7 +53,7 @@ import com.squareup.kotlinpoet.UNIT * - `ApiClientBase` abstract class with common client infrastructure */ @OptIn(ExperimentalKotlinPoetApi::class) -object ApiClientBaseGenerator { +internal object ApiClientBaseGenerator { private const val BLOCK = "block" private const val MAP_TO_RESULT = "mapToResult" private const val SUCCESS_BODY = "successBody" @@ -44,7 +73,7 @@ object ApiClientBaseGenerator { } private fun buildEncodeParam(t: TypeVariableName): FunSpec = FunSpec - .builder("encodeParam") + .builder(ENCODE_PARAM_FUN.simpleName) .addModifiers(KModifier.INLINE) .addTypeVariable(t) .addParameter("value", TypeVariableName("T")) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt similarity index 73% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt index f983258..7596b08 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt @@ -1,5 +1,9 @@ -package com.avsystem.justworks.core.gen +package com.avsystem.justworks.core.gen.shared +import com.avsystem.justworks.core.gen.BODY +import com.avsystem.justworks.core.gen.HTTP_ERROR +import com.avsystem.justworks.core.gen.HTTP_ERROR_TYPE +import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.INT @@ -10,7 +14,7 @@ import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName /** - * Generates [FileSpec]s containing: + * Generates [com.squareup.kotlinpoet.FileSpec]s containing: * - `HttpErrorType` enum class with Client, Server, Network values * - `HttpError` data class with code, message, type fields * - `HttpSuccess` data class wrapping successful responses @@ -42,10 +46,22 @@ object ApiResponseGenerator { .classBuilder(HTTP_ERROR) .addModifiers(KModifier.DATA) .primaryConstructor(primaryConstructor) - .addProperty(PropertySpec.builder(CODE, INT).initializer(CODE).build()) - .addProperty(PropertySpec.builder(MESSAGE, STRING).initializer(MESSAGE).build()) - .addProperty(PropertySpec.builder(TYPE, HTTP_ERROR_TYPE).initializer(TYPE).build()) - .build() + .addProperty( + PropertySpec + .builder(CODE, INT) + .initializer(CODE) + .build(), + ).addProperty( + PropertySpec + .builder(MESSAGE, STRING) + .initializer(MESSAGE) + .build(), + ).addProperty( + PropertySpec + .builder(TYPE, HTTP_ERROR_TYPE) + .initializer(TYPE) + .build(), + ).build() return FileSpec .builder(HTTP_ERROR) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/SerializersModuleGenerator.kt similarity index 68% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/SerializersModuleGenerator.kt index 255807c..1d1dd54 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/SerializersModuleGenerator.kt @@ -1,10 +1,17 @@ -package com.avsystem.justworks.core.gen +package com.avsystem.justworks.core.gen.shared -import com.avsystem.justworks.core.gen.ModelGenerator.HierarchyInfo +import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.POLYMORPHIC_FUN +import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.SUBCLASS_FUN +import com.avsystem.justworks.core.gen.invoke +import com.avsystem.justworks.core.gen.model.ModelGenerator import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.PropertySpec +import kotlin.collections.iterator /** * Generates a `SerializersModule` registration file for all polymorphic sealed hierarchies. @@ -12,17 +19,15 @@ import com.squareup.kotlinpoet.PropertySpec * Produces a top-level `val generatedSerializersModule: SerializersModule` property * that registers each sealed interface with its subclass variants. */ -class SerializersModuleGenerator(private val modelPackage: String) { - companion object { - const val FILE_NAME = "SerializersModule" - } +internal object SerializersModuleGenerator { + const val FILE_NAME = "SerializersModule" /** - * Generates a [FileSpec] containing the SerializersModule registration. + * Generates a [com.squareup.kotlinpoet.FileSpec] containing the SerializersModule registration. * Returns null if the hierarchy has no sealed types to register. */ - context(hierarchy: HierarchyInfo) + context(hierarchy: ModelGenerator.HierarchyInfo, modelPackage: ModelPackage) fun generate(): FileSpec? { // anyOf hierarchies without a discriminator use JsonContentPolymorphicSerializer // with custom deserialization logic, so they don't need SerializersModule registration. @@ -52,7 +57,7 @@ class SerializersModuleGenerator(private val modelPackage: String) { .build() return FileSpec - .builder(modelPackage, FILE_NAME) + .builder(modelPackage.name, FILE_NAME) .addProperty(prop) .build() } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index fdcc056..8c4f6c1 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -31,11 +31,7 @@ enum class HttpMethod { POST, PUT, DELETE, - PATCH; - - companion object { - fun parse(name: String): HttpMethod? = entries.find { it.name.equals(name, true) } - } + PATCH } data class Parameter( @@ -50,19 +46,22 @@ data class Parameter( enum class ParameterLocation { PATH, QUERY, - HEADER; - - companion object { - fun parse(name: String): ParameterLocation? = entries.find { it.name.equals(name, true) } - } + HEADER } data class RequestBody( val required: Boolean, - val contentType: String, + val contentType: ContentType, val schema: TypeRef, ) +// the order is important!!! +enum class ContentType(val value: String) { + MULTIPART_FORM_DATA("multipart/form-data"), + FORM_URL_ENCODED("application/x-www-form-urlencoded"), + JSON_CONTENT_TYPE("application/json"), +} + data class Response( val statusCode: String, val description: String?, @@ -100,11 +99,7 @@ data class EnumModel( enum class EnumBackingType { STRING, - INTEGER; - - companion object { - fun parse(name: String): EnumBackingType? = entries.find { it.name.equals(name, true) } - } + INTEGER } data class Discriminator(val propertyName: String, val mapping: Map) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 09f4119..97e0f9c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -14,6 +14,7 @@ import arrow.core.toNonEmptyListOrNull import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.Warnings import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.ContentType import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.EnumBackingType @@ -27,9 +28,11 @@ import com.avsystem.justworks.core.model.RequestBody import com.avsystem.justworks.core.model.Response import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.TypeRef +import com.avsystem.justworks.core.toEnumOrNull import io.swagger.parser.OpenAPIParser import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.media.Content import io.swagger.v3.oas.models.media.Schema import io.swagger.v3.parser.core.models.ParseOptions import java.io.File @@ -172,7 +175,7 @@ object SpecParser { pathItem .readOperationsMap() .asSequence() - .mapNotNull { (method, value) -> HttpMethod.parse(method.name)?.let { it to value } } + .mapNotNull { (method, value) -> method.name.toEnumOrNull()?.let { it to value } } .map { (method, operation) -> val operationId = operation.operationId ?: generateOperationId(method, path) @@ -183,11 +186,19 @@ object SpecParser { val requestBody = nullable { val body = operation.requestBody.bind() val content = body.content.bind() - val schema = content[JSON_CONTENT_TYPE]?.schema.bind() + + val contentType = ContentType.entries.find { it in content }.bind() + + val mediaType = content[contentType].bind() + + val schema = mediaType.schema + ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request") + .bind() + RequestBody( required = body.required ?: false, - contentType = JSON_CONTENT_TYPE, - schema = schema.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request"), + contentType = contentType, + schema = schema, ) } @@ -198,7 +209,7 @@ object SpecParser { statusCode = code, description = resp.description, schema = resp.content - ?.get(JSON_CONTENT_TYPE) + ?.get(ContentType.JSON_CONTENT_TYPE.value) ?.schema ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), ) @@ -220,7 +231,7 @@ object SpecParser { context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun SwaggerParameter.toParameter(): Parameter = Parameter( name = name ?: "", - location = ParameterLocation.parse(`in`) ?: ParameterLocation.QUERY, + location = `in`.toEnumOrNull() ?: ParameterLocation.QUERY, required = required ?: false, schema = schema?.toTypeRef() ?: TypeRef.Primitive(PrimitiveType.STRING), description = description, @@ -283,7 +294,7 @@ object SpecParser { private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel = EnumModel( name = name, description = schema.description, - type = EnumBackingType.parse(schema.type) ?: EnumBackingType.STRING, + type = schema.type.toEnumOrNull() ?: EnumBackingType.STRING, values = schema.enum.map { it.toString() }, ) @@ -434,11 +445,13 @@ object SpecParser { return method.name.lowercase() + segments } + operator fun Content.get(contentType: ContentType) = this[contentType.value] + + operator fun Content.contains(contentType: ContentType) = contentType.value in this + private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } - private const val JSON_CONTENT_TYPE = "application/json" - private const val SCHEMA_PREFIX = "#/components/schemas/" private val STRING_FORMAT_MAP = mapOf( 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 b2f98a6..4027fd8 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 @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.shared.ApiClientBaseGenerator import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt index f4fd938..8f971c0 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.shared.ApiResponseGenerator import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName 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 3f60c32..9c4b599 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 @@ -1,32 +1,41 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.client.ClientGenerator 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.HttpMethod 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.PropertyModel import com.avsystem.justworks.core.model.RequestBody import com.avsystem.justworks.core.model.Response import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi +import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.ParameterizedTypeName import com.squareup.kotlinpoet.TypeSpec import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue class ClientGeneratorTest { private val apiPackage = "com.example.api" private val modelPackage = "com.example.model" - private val generator = ClientGenerator(apiPackage, modelPackage) - private fun spec(endpoints: List) = ApiSpec( + private fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = + context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { + ClientGenerator.generate(spec, hasPolymorphicTypes) + } + + private fun spec(vararg endpoints: Endpoint) = ApiSpec( title = "Test", version = "1.0", - endpoints = endpoints, + endpoints = endpoints.toList(), schemas = emptyList(), enums = emptyList(), ) @@ -53,8 +62,8 @@ class ClientGeneratorTest { responses = responses, ) - private fun clientClass(endpoints: List): TypeSpec { - val files = generator.generate(spec(endpoints)) + private fun clientClass(vararg endpoints: Endpoint): TypeSpec { + val files = generate(spec(*endpoints)) return files .first() .members @@ -66,12 +75,11 @@ class ClientGeneratorTest { @Test fun `generates one client class per tag`() { - val endpoints = - listOf( - endpoint(operationId = "listPets", tags = listOf("Pets")), - endpoint(path = "/store", operationId = "getInventory", tags = listOf("Store")), - ) - val files = generator.generate(spec(endpoints)) + val endpoints = arrayOf( + endpoint(operationId = "listPets", tags = listOf("Pets")), + endpoint(path = "/store", operationId = "getInventory", tags = listOf("Store")), + ) + val files = generate(spec(*endpoints)) assertEquals(2, files.size) val classNames = files @@ -88,7 +96,7 @@ class ClientGeneratorTest { @Test fun `endpoint functions are suspend`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } assertTrue(KModifier.SUSPEND in funSpec.modifiers, "Expected SUSPEND modifier") } @@ -97,23 +105,16 @@ class ClientGeneratorTest { @Test fun `supports all HTTP methods`() { - val methods = - listOf( - HttpMethod.GET to "getPet", - HttpMethod.POST to "createPet", - HttpMethod.PUT to "updatePet", - HttpMethod.DELETE to "deletePet", - HttpMethod.PATCH to "patchPet", - ) - val endpoints = - methods.map { (method, opId) -> - endpoint(method = method, operationId = opId) - } - val cls = clientClass(endpoints) - val funBodies = - cls.funSpecs.associate { - it.name to it.body.toString() - } + val methods = listOf( + HttpMethod.GET to "getPet", + HttpMethod.POST to "createPet", + HttpMethod.PUT to "updatePet", + HttpMethod.DELETE to "deletePet", + HttpMethod.PATCH to "patchPet", + ) + val endpoints = methods.map { (method, opId) -> endpoint(method = method, operationId = opId) }.toTypedArray() + val cls = clientClass(*endpoints) + val funBodies = cls.funSpecs.associate { it.name to it.body.toString() } assertTrue( funBodies["getPet"]!!.contains("request.get(") || funBodies["getPet"]!!.contains("request.`get`("), "GET method expected", @@ -145,12 +146,11 @@ class ClientGeneratorTest { endpoint( path = "/pets/{petId}", operationId = "getPet", - parameters = - listOf( - Parameter("petId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.LONG), null), - ), + parameters = listOf( + Parameter("petId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.LONG), null), + ), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "getPet" } val param = funSpec.parameters.first { it.name == "petId" } assertEquals("kotlin.Long", param.type.toString()) @@ -168,7 +168,7 @@ class ClientGeneratorTest { Parameter("limit", ParameterLocation.QUERY, true, TypeRef.Primitive(PrimitiveType.INT), null), ), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val param = funSpec.parameters.first { it.name == "limit" } assertEquals("kotlin.Int", param.type.toString()) @@ -178,15 +178,14 @@ class ClientGeneratorTest { @Test fun `optional query parameters default to null`() { - val ep = - endpoint( - operationId = "listPets", - parameters = - listOf( - Parameter("limit", ParameterLocation.QUERY, false, TypeRef.Primitive(PrimitiveType.INT), null), - ), - ) - val cls = clientClass(listOf(ep)) + val ep = endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter("limit", ParameterLocation.QUERY, false, TypeRef.Primitive(PrimitiveType.INT), null), + ), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val param = funSpec.parameters.first { it.name == "limit" } assertTrue(param.type.isNullable, "Optional query param should be nullable") @@ -197,13 +196,12 @@ class ClientGeneratorTest { @Test fun `request body becomes function parameter`() { - val ep = - endpoint( - method = HttpMethod.POST, - operationId = "createPet", - requestBody = RequestBody(true, "application/json", TypeRef.Reference("Pet")), - ) - val cls = clientClass(listOf(ep)) + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createPet", + requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, TypeRef.Reference("Pet")), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "createPet" } val bodyParam = funSpec.parameters.first { it.name == "body" } assertEquals("com.example.model.Pet", bodyParam.type.toString()) @@ -213,7 +211,7 @@ class ClientGeneratorTest { @Test fun `return type is Success parameterized`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType assertNotNull(returnType) @@ -227,7 +225,7 @@ class ClientGeneratorTest { @OptIn(ExperimentalKotlinPoetApi::class) @Test fun `endpoint functions have Raise HttpError context parameter`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val contextParameters = funSpec.contextParameters assertTrue(contextParameters.isNotEmpty(), "Expected context parameter") @@ -241,21 +239,20 @@ class ClientGeneratorTest { @Test fun `header parameters become function parameters`() { - val ep = - endpoint( - operationId = "listPets", - parameters = - listOf( - Parameter( - "X-Request-Id", - ParameterLocation.HEADER, - true, - TypeRef.Primitive(PrimitiveType.STRING), - null, - ), + val ep = endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter( + "X-Request-Id", + ParameterLocation.HEADER, + true, + TypeRef.Primitive(PrimitiveType.STRING), + null, ), - ) - val cls = clientClass(listOf(ep)) + ), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val param = funSpec.parameters.first { it.name == "xRequestId" } assertEquals("kotlin.String", param.type.toString()) @@ -263,21 +260,20 @@ class ClientGeneratorTest { @Test fun `header parameters are emitted inside headers block`() { - val ep = - endpoint( - operationId = "listPets", - parameters = - listOf( - Parameter( - "X-Request-Id", - ParameterLocation.HEADER, - true, - TypeRef.Primitive(PrimitiveType.STRING), - null, - ), + val ep = endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter( + "X-Request-Id", + ParameterLocation.HEADER, + true, + TypeRef.Primitive(PrimitiveType.STRING), + null, ), - ) - val cls = clientClass(listOf(ep)) + ), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() assertTrue(body.contains("headers"), "Expected headers block in generated body") @@ -288,7 +284,7 @@ class ClientGeneratorTest { @Test fun `client constructor has baseUrl parameter`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val constructor = assertNotNull(cls.primaryConstructor) val baseUrl = constructor.parameters.first { it.name == "baseUrl" } assertEquals("kotlin.String", baseUrl.type.toString()) @@ -298,7 +294,7 @@ class ClientGeneratorTest { @Test fun `client constructor has token provider parameter`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val constructor = assertNotNull(cls.primaryConstructor) val token = constructor.parameters.first { it.name == "token" } assertEquals("() -> kotlin.String", token.type.toString(), "token should be a () -> String lambda") @@ -309,7 +305,7 @@ class ClientGeneratorTest { @Test fun `untagged endpoints go to DefaultApi`() { val ep = endpoint(operationId = "healthCheck", tags = emptyList()) - val files = generator.generate(spec(listOf(ep))) + val files = generate(spec(ep)) val className = files .first() @@ -324,24 +320,57 @@ class ClientGeneratorTest { @Test fun `void response uses Unit type parameter`() { - val ep = - endpoint( - method = HttpMethod.DELETE, - operationId = "deletePet", - responses = mapOf("204" to Response("204", "No content", null)), - ) - val cls = clientClass(listOf(ep)) + val ep = endpoint( + method = HttpMethod.DELETE, + operationId = "deletePet", + responses = mapOf("204" to Response("204", "No content", null)), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "deletePet" } val returnType = funSpec.returnType as ParameterizedTypeName assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) assertEquals("kotlin.Unit", returnType.typeArguments.first().toString()) } + // -- CONT-03: Response code handling -- + + @Test + fun `201 Created with schema returns typed response`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createPet", + responses = mapOf( + "201" to Response("201", "Created", TypeRef.Reference("Pet")), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createPet" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) + assertEquals("com.example.model.Pet", returnType.typeArguments.first().toString()) + } + + @Test + fun `mixed 200 and 204 responses uses 200 schema type`() { + val ep = endpoint( + method = HttpMethod.DELETE, + operationId = "removePet", + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "204" to Response("204", "No content", null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "removePet" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals("com.example.model.Pet", returnType.typeArguments.first().toString()) + } + // -- Client class extends ApiClientBase -- @Test fun `client class extends ApiClientBase`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) assertEquals("com.avsystem.justworks.ApiClientBase", cls.superclass.toString()) } @@ -349,10 +378,7 @@ class ClientGeneratorTest { @Test fun `polymorphic spec wires serializersModule in createHttpClient call`() { - val files = ClientGenerator( - apiPackage, - modelPackage, - ).generate(spec(listOf(endpoint())), hasPolymorphicTypes = true) + val files = generate(spec(endpoint()), hasPolymorphicTypes = true) val clientProperty = files .first() .members @@ -368,12 +394,207 @@ class ClientGeneratorTest { assertTrue(clientInitializer.contains("createHttpClient"), "Expected createHttpClient call") } + // -- CONT-01: Multipart form-data code generation -- + + @Test + fun `multipart endpoint generates submitFormWithBinaryData call`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "uploadFile", + requestBody = RequestBody( + required = true, + contentType = ContentType.MULTIPART_FORM_DATA, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), + PropertyModel("description", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("file", "description"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "uploadFile" } + val body = funSpec.body.toString() + assertTrue(body.contains("submitFormWithBinaryData"), "Expected submitFormWithBinaryData call") + assertTrue(body.contains("formData"), "Expected formData builder") + } + + @Test + fun `multipart endpoint has ChannelProvider param for binary field`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "uploadFile", + requestBody = RequestBody( + required = true, + contentType = ContentType.MULTIPART_FORM_DATA, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), + ), + requiredProperties = setOf("file"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "uploadFile" } + val paramTypes = funSpec.parameters.associate { it.name to it.type.toString() } + assertEquals("io.ktor.client.request.forms.ChannelProvider", paramTypes["file"]) + assertEquals("kotlin.String", paramTypes["fileName"]) + assertEquals("io.ktor.http.ContentType", paramTypes["fileContentType"]) + } + + @Test + fun `multipart text fields use simple append`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "uploadFile", + requestBody = RequestBody( + required = true, + contentType = ContentType.MULTIPART_FORM_DATA, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), + PropertyModel("description", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("file", "description"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "uploadFile" } + val body = funSpec.body.toString() + assertTrue(body.contains("append(\"description\", description)"), "Expected simple append for text field") + } + + @Test + fun `multipart binary fields include ContentDisposition header`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "uploadFile", + requestBody = RequestBody( + required = true, + contentType = ContentType.MULTIPART_FORM_DATA, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), + ), + requiredProperties = setOf("file"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "uploadFile" } + val body = funSpec.body.toString() + assertTrue(body.contains("ContentDisposition"), "Expected ContentDisposition in headers") + assertTrue(body.contains("filename"), "Expected filename in ContentDisposition") + } + + @Test + fun `existing JSON requestBody still generates setBody pattern`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createPet", + requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, TypeRef.Reference("Pet")), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createPet" } + val body = funSpec.body.toString() + assertTrue(body.contains("setBody"), "Expected setBody for JSON content type") + assertFalse(body.contains("submitForm"), "Should NOT contain submitForm for JSON") + } + + // -- CONT-02: Form-urlencoded code generation -- + + @Test + fun `form-urlencoded endpoint generates submitForm call`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createUser", + requestBody = RequestBody( + required = true, + contentType = ContentType.FORM_URL_ENCODED, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("username", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), + ), + requiredProperties = setOf("username", "age"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createUser" } + val body = funSpec.body.toString() + assertTrue(body.contains("submitForm"), "Expected submitForm call") + assertTrue(body.contains("parameters"), "Expected parameters builder") + + val paramTypes = funSpec.parameters.associate { it.name to it.type.toString() } + assertEquals("kotlin.String", paramTypes["username"]) + assertEquals("kotlin.Int", paramTypes["age"]) + } + + @Test + fun `form-urlencoded non-string params use toString`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createUser", + requestBody = RequestBody( + required = true, + contentType = ContentType.FORM_URL_ENCODED, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("username", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), + ), + requiredProperties = setOf("username", "age"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createUser" } + val body = funSpec.body.toString() + assertTrue(body.contains("age.toString()"), "Expected toString() for non-string param") + assertFalse(body.contains("username.toString()"), "String param should NOT use toString()") + } + + @Test + fun `form-urlencoded optional field generates nullable param with guard`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createUser", + requestBody = RequestBody( + required = true, + contentType = ContentType.FORM_URL_ENCODED, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("username", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("nickname", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("username"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createUser" } + val nicknameParam = funSpec.parameters.first { it.name == "nickname" } + assertTrue(nicknameParam.type.isNullable, "Optional form field should be nullable") + assertEquals("null", nicknameParam.defaultValue.toString()) + + val body = funSpec.body.toString() + assertTrue(body.contains("if (nickname != null)"), "Expected null guard for optional field") + } + @Test fun `non-polymorphic spec has createHttpClient without serializersModule`() { - val files = ClientGenerator( - apiPackage, - modelPackage, - ).generate(spec(listOf(endpoint())), hasPolymorphicTypes = false) + val files = generate(spec(endpoint()), hasPolymorphicTypes = false) val clientProperty = files .first() .members @@ -390,7 +611,7 @@ class ClientGeneratorTest { @Test fun `generated code calls applyAuth`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() assertTrue(body.contains("applyAuth()"), "Expected applyAuth() call") @@ -398,7 +619,7 @@ class ClientGeneratorTest { @Test fun `generated code calls safeCall`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() assertTrue(body.contains("safeCall"), "Expected safeCall call") @@ -406,7 +627,7 @@ class ClientGeneratorTest { @Test fun `generated code calls toResult for typed response`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() assertTrue(body.contains("toResult"), "Expected toResult call") @@ -420,7 +641,7 @@ class ClientGeneratorTest { operationId = "deletePet", responses = mapOf("204" to Response("204", "No content", null)), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "deletePet" } val body = funSpec.body.toString() assertTrue(body.contains("toEmptyResult"), "Expected toEmptyResult call") 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 cbf97de..d23ba1c 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 @@ -1,7 +1,12 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.client.ClientGenerator +import com.avsystem.justworks.core.gen.model.ModelGenerator +import com.avsystem.justworks.core.gen.shared.ApiClientBaseGenerator +import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.parser.ParseResult import com.avsystem.justworks.core.parser.SpecParser +import com.squareup.kotlinpoet.FileSpec import java.io.File import kotlin.test.Test import kotlin.test.assertFalse @@ -35,14 +40,21 @@ class IntegrationTest { } } + private fun generateModel(spec: ApiSpec): List = + context(ModelPackage(modelPackage)) { ModelGenerator.generate(spec) } + + private fun generateClient(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = + context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { + ClientGenerator.generate(spec, hasPolymorphicTypes) + } + @Test fun `real-world specs generate compilable enum code without class body conflicts`() { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec if (spec.enums.isEmpty()) continue - val generator = ModelGenerator(modelPackage) - val files = generator.generate(spec) + val files = generateModel(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") val enumSources = files @@ -98,13 +110,11 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val modelGenerator = ModelGenerator(modelPackage) - val modelFiles = modelGenerator.generate(spec) + val modelFiles = generateModel(spec) assertTrue(modelFiles.isNotEmpty(), "$fixture: ModelGenerator should produce files") if (spec.endpoints.isNotEmpty()) { - val clientGenerator = ClientGenerator(apiPackage, modelPackage) - val clientFiles = clientGenerator.generate(spec) + val clientFiles = generateClient(spec) assertTrue( clientFiles.isNotEmpty(), "$fixture: ClientGenerator should produce files for a spec with endpoints", @@ -123,8 +133,7 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val generator = ModelGenerator(modelPackage) - val files = generator.generate(spec) + val files = generateModel(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") val allSources = files.map { it.toString() } @@ -154,8 +163,7 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val generator = ModelGenerator(modelPackage) - val files = generator.generate(spec) + val files = generateModel(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") for (file in files) { diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt index 649d8f5..a75a79a 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.model.ModelGenerator import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.EnumModel @@ -16,9 +17,12 @@ import kotlin.test.assertTrue class ModelGeneratorPolymorphicTest { private val modelPackage = "com.example.model" - private val generator = ModelGenerator(modelPackage) - private fun spec(schemas: List = emptyList(), enums: List = emptyList(),) = ApiSpec( + private fun generate(spec: ApiSpec) = context(ModelPackage("com.example.model")) { + ModelGenerator.generate(spec) + } + + private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", version = "1.0", endpoints = emptyList(), @@ -45,7 +49,7 @@ class ModelGeneratorPolymorphicTest { discriminator = discriminator, ) - private fun findType(files: List, name: String,): TypeSpec { + private fun findType(files: List, name: String): TypeSpec { for (file in files) { val found = file.members.filterIsInstance().find { it.name == name } if (found != null) return found @@ -85,7 +89,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("sideLength"), ) - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") assertTrue(KModifier.SEALED in shapeType.modifiers, "Expected SEALED modifier on Shape") @@ -102,7 +106,7 @@ class ModelGeneratorPolymorphicTest { val circleSchema = schema(name = "Circle") val squareSchema = schema(name = "Square") - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") val annotations = shapeType.annotations.map { it.typeName.toString() } @@ -125,7 +129,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("radius"), ) - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema))) val circleType = findType(files, "Circle") val superinterfaces = circleType.superinterfaces.keys.map { it.toString() } @@ -149,7 +153,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("radius"), ) - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema))) val circleType = findType(files, "Circle") val serialNameAnnotation = @@ -183,7 +187,7 @@ class ModelGeneratorPolymorphicTest { val circleSchema = schema(name = "Circle") val squareSchema = schema(name = "Square") - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") val discriminatorAnnotation = @@ -216,7 +220,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("radius"), ) - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema))) val circleType = findType(files, "Circle") val serialNameAnnotation = @@ -244,7 +248,7 @@ class ModelGeneratorPolymorphicTest { ) val circleSchema = schema(name = "Circle") - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema))) val shapeFile = findFile(files, "Shape") val optInAnnotation = @@ -287,7 +291,7 @@ class ModelGeneratorPolymorphicTest { allOf = listOf(TypeRef.Reference("Dog")), ) - val files = generator.generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) + val files = generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) val extendedDogType = findType(files, "ExtendedDog") val constructor = assertNotNull(extendedDogType.primaryConstructor, "Expected primary constructor") @@ -323,7 +327,7 @@ class ModelGeneratorPolymorphicTest { allOf = listOf(TypeRef.Reference("Dog")), ) - val files = generator.generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) + val files = generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) val extendedDogType = findType(files, "ExtendedDog") val constructor = assertNotNull(extendedDogType.primaryConstructor) @@ -374,7 +378,7 @@ class ModelGeneratorPolymorphicTest { ), ) - val files = generator.generate( + val files = generate( spec(schemas = listOf(networkMeshSchema, extenderPropsSchema, ethernetPropsSchema)), ) val networkMeshType = findType(files, "NetworkMeshDevice") @@ -410,7 +414,7 @@ class ModelGeneratorPolymorphicTest { ), ) - val files = generator.generate(spec(schemas = listOf(networkMeshSchema, extenderPropsSchema))) + val files = generate(spec(schemas = listOf(networkMeshSchema, extenderPropsSchema))) val extenderType = findType(files, "ExtenderDeviceProperties") // Verify @SerialName uses wrapper property name @@ -444,7 +448,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("accountNumber"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val files = generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val paymentType = findType(files, "Payment") val serializableAnnotation = paymentType.annotations.find { @@ -474,7 +478,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("accountNumber"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val files = generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val serializerType = findType(files, "PaymentSerializer") assertEquals(TypeSpec.Kind.OBJECT, serializerType.kind, "PaymentSerializer should be an object") @@ -502,7 +506,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("accountNumber"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val files = generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val serializerType = findType(files, "PaymentSerializer") val selectDeserializer = serializerType.funSpecs.find { it.name == "selectDeserializer" } assertNotNull(selectDeserializer, "PaymentSerializer should have selectDeserializer function") @@ -536,7 +540,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("amount"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, typeASchema, typeBSchema))) + val files = generate(spec(schemas = listOf(unionSchema, typeASchema, typeBSchema))) val serializerType = findType(files, "PaymentSerializer") val selectDeserializer = serializerType.funSpecs.find { it.name == "selectDeserializer" } assertNotNull(selectDeserializer, "PaymentSerializer should have selectDeserializer function") @@ -565,7 +569,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("accountNumber"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val files = generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val creditCardType = findType(files, "CreditCard") val annotations = creditCardType.annotations.map { it.typeName.toString() } @@ -589,7 +593,7 @@ class ModelGeneratorPolymorphicTest { val circleSchema = schema(name = "Circle") val squareSchema = schema(name = "Square") - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") // Should have plain @Serializable, NOT @Serializable(with = ...) @@ -645,7 +649,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("lastSeen"), ) - val files = generator.generate( + val files = generate( spec(schemas = listOf(deviceStatusSchema, trueSchema, falseSchema)), ) @@ -698,7 +702,7 @@ class ModelGeneratorPolymorphicTest { ) } - val files = generator.generate( + val files = generate( spec(schemas = listOf(networkMeshSchema) + variantSchemas), ) @@ -759,7 +763,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("lastSeen"), ) - val files = generator.generate( + val files = generate( spec(schemas = listOf(deviceStatusSchema, trueSchema, falseSchema)), ) @@ -796,7 +800,7 @@ class ModelGeneratorPolymorphicTest { allOf = listOf(TypeRef.Reference("Pet")), ) - val files = generator.generate(spec(schemas = listOf(petSchema, dogSchema))) + val files = generate(spec(schemas = listOf(petSchema, dogSchema))) val dogType = findType(files, "Dog") val superinterfaces = dogType.superinterfaces.keys.map { it.toString() } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt index 9507b32..a58eace 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.model.ModelGenerator import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.EnumBackingType @@ -21,7 +22,10 @@ import kotlin.test.assertTrue class ModelGeneratorTest { private val modelPackage = "com.example.model" - private val generator = ModelGenerator(modelPackage) + + private fun generate(spec: ApiSpec) = context(ModelPackage("com.example.model")) { + ModelGenerator.generate(spec) + } private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", @@ -52,7 +56,7 @@ class ModelGeneratorTest { @Test fun `generates data class with DATA modifier`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) assertEquals(1, files.size) val typeSpec = files[0].members.filterIsInstance()[0] assertTrue(KModifier.DATA in typeSpec.modifiers, "Expected DATA modifier") @@ -60,7 +64,7 @@ class ModelGeneratorTest { @Test fun `generates class with Serializable annotation`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val typeSpec = files[0].members.filterIsInstance()[0] val annotations = typeSpec.annotations.map { it.typeName.toString() } assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable") @@ -68,7 +72,7 @@ class ModelGeneratorTest { @Test fun `required property is non-nullable in constructor`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor, "Expected primary constructor") val idParam = constructor.parameters.first { it.name == "id" } @@ -77,7 +81,7 @@ class ModelGeneratorTest { @Test fun `optional property is nullable with default null`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val tagParam = constructor.parameters.first { it.name == "tag" } @@ -88,7 +92,7 @@ class ModelGeneratorTest { @Test fun `every property has SerialName annotation with wire name`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val typeSpec = files[0].members.filterIsInstance()[0] for (prop in typeSpec.propertySpecs) { val serialName = @@ -115,7 +119,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] assertEquals("createdAt", prop.name) @@ -131,7 +135,7 @@ class ModelGeneratorTest { @Test fun `schema with description produces KDoc`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val typeSpec = files[0].members.filterIsInstance()[0] assertTrue(typeSpec.kdoc.toString().contains("A pet in the store"), "Expected KDoc from description") } @@ -152,7 +156,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val petProp = typeSpec.propertySpecs.first { it.name == "pet" } assertEquals("com.example.model.Pet", petProp.type.toString()) @@ -161,13 +165,13 @@ class ModelGeneratorTest { @Test fun `generate produces one FileSpec per schema`() { val schema2 = petSchema.copy(name = "Category", description = null) - val files = generator.generate(spec(schemas = listOf(petSchema, schema2))) + val files = generate(spec(schemas = listOf(petSchema, schema2))) assertEquals(2, files.size) } @Test fun `required properties ordered before optional in constructor`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } @@ -187,7 +191,7 @@ class ModelGeneratorTest { @Test fun `string enum has Serializable annotation`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) + val files = generate(spec(enums = listOf(statusEnum))) val typeSpec = files[0].members.filterIsInstance()[0] val annotations = typeSpec.annotations.map { it.typeName.toString() } assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable on enum") @@ -195,7 +199,7 @@ class ModelGeneratorTest { @Test fun `string enum constants have SerialName with wire value`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) + val files = generate(spec(enums = listOf(statusEnum))) val typeSpec = files[0].members.filterIsInstance()[0] assertEquals(3, typeSpec.enumConstants.size) for ((name, spec) in typeSpec.enumConstants) { @@ -209,7 +213,7 @@ class ModelGeneratorTest { @Test fun `enum constant names are UPPER_SNAKE_CASE`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) + val files = generate(spec(enums = listOf(statusEnum))) val typeSpec = files[0].members.filterIsInstance()[0] val names = typeSpec.enumConstants.keys.toList() assertEquals(listOf("AVAILABLE", "PENDING", "SOLD"), names) @@ -217,7 +221,7 @@ class ModelGeneratorTest { @Test fun `enum constants do not produce anonymous class body`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) + val files = generate(spec(enums = listOf(statusEnum))) val source = files[0].toString() // Assert no class body braces on enum constants assertFalse( @@ -245,7 +249,7 @@ class ModelGeneratorTest { type = EnumBackingType.INTEGER, values = listOf("1", "2", "3"), ) - val files = generator.generate(spec(enums = listOf(intEnum))) + val files = generate(spec(enums = listOf(intEnum))) val typeSpec = files[0].members.filterIsInstance()[0] val constants = typeSpec.enumConstants.entries.toList() assertEquals("1", constants[0].key) @@ -269,13 +273,13 @@ class ModelGeneratorTest { @Test fun `generate returns FileSpecs for schemas and enums combined`() { val schema2 = petSchema.copy(name = "Category", description = null) - val files = generator.generate(spec(schemas = listOf(petSchema, schema2), enums = listOf(statusEnum))) + val files = generate(spec(schemas = listOf(petSchema, schema2), enums = listOf(statusEnum))) assertEquals(3, files.size) } @Test fun `all FileSpecs have correct package name`() { - val files = generator.generate(spec(schemas = listOf(petSchema), enums = listOf(statusEnum))) + val files = generate(spec(schemas = listOf(petSchema), enums = listOf(statusEnum))) for (file in files) { assertEquals(modelPackage, file.packageName) } @@ -308,7 +312,7 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val files = generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) val paymentType = files.first { it.name == "Payment" }.members.filterIsInstance()[0] assertTrue(KModifier.SEALED in paymentType.modifiers, "Expected SEALED modifier on Payment") @@ -340,7 +344,7 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val files = generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) val creditCardType = files.first { it.name == "CreditCard" }.members.filterIsInstance()[0] val serialNameAnnotation = @@ -379,7 +383,7 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val files = generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) val paymentType = files.first { it.name == "Payment" }.members.filterIsInstance()[0] val discriminatorAnnotation = @@ -411,7 +415,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "name" } @@ -435,7 +439,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val ageParam = constructor.parameters.first { it.name == "age" } @@ -460,7 +464,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "active" } @@ -489,7 +493,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "createdAt" } @@ -519,7 +523,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "eventDate" } @@ -548,7 +552,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } @@ -572,7 +576,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "name" } @@ -604,7 +608,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema), enums = listOf(statusEnum))) + val files = generate(spec(schemas = listOf(schema), enums = listOf(statusEnum))) val typeSpec = files.first { it.name == "Task" }.members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "status" } @@ -629,7 +633,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = null, ) - val files = generator.generate(spec(schemas = listOf(groupIdSchema))) + val files = generate(spec(schemas = listOf(groupIdSchema))) assertEquals(1, files.size) val file = files[0] @@ -657,7 +661,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Primitive(PrimitiveType.INT), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -679,7 +683,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Primitive(PrimitiveType.BOOLEAN), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -701,7 +705,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Primitive(PrimitiveType.LONG), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -723,7 +727,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Primitive(PrimitiveType.DOUBLE), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -745,7 +749,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -767,7 +771,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Reference("OtherSchema"), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -788,7 +792,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(userIdSchema))) + val files = generate(spec(schemas = listOf(userIdSchema))) val typeAlias = files[0].members.filterIsInstance()[0] assertTrue( @@ -800,7 +804,7 @@ class ModelGeneratorTest { @Test fun `schema with properties generates data class not type alias`() { // Verify existing behavior unchanged - schemas with properties still get data classes - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val typeSpecs = files[0].members.filterIsInstance() val typeAliases = files[0].members.filterIsInstance() @@ -832,7 +836,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val source = files[0].toString() // Hard keyword: KotlinPoet should backtick-escape @@ -858,7 +862,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val source = files[0].toString() // Hard keyword: KotlinPoet should backtick-escape @@ -884,7 +888,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val source = files[0].toString() // Hard keyword: KotlinPoet should backtick-escape @@ -910,7 +914,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] @@ -934,7 +938,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] @@ -958,7 +962,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] @@ -982,7 +986,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] @@ -1028,7 +1032,7 @@ class ModelGeneratorTest { ) // Should complete without StackOverflowError - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) assertNotNull(files, "generate should return results without StackOverflowError") } @@ -1049,7 +1053,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) @@ -1090,7 +1094,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(baseSchema, composedSchema))) + val files = generate(spec(schemas = listOf(baseSchema, composedSchema))) val childFile = files.first { it.name == "Child" } val typeSpec = childFile.members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) @@ -1121,7 +1125,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files .first() .members @@ -1146,7 +1150,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files .first() .members @@ -1173,7 +1177,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files .first() .members @@ -1203,7 +1207,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val uuidSerializerFile = files.find { it.name == "UuidSerializer" } assertNotNull(uuidSerializerFile, "Expected UuidSerializer file to be generated") val content = uuidSerializerFile.toString() @@ -1225,7 +1229,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val deviceFile = files.find { it.name == "Device" } assertNotNull(deviceFile) val content = deviceFile.toString() @@ -1249,7 +1253,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val deviceFile = files.find { it.name == "Device" } assertNotNull(deviceFile) val content = deviceFile.toString() @@ -1278,7 +1282,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val deviceFile = files.find { it.name == "DeviceWithUuidList" } assertNotNull(deviceFile) val content = deviceFile.toString() @@ -1316,7 +1320,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val deviceFile = files.find { it.name == "DeviceWithUuidMap" } assertNotNull(deviceFile) val content = deviceFile.toString() @@ -1337,7 +1341,7 @@ class ModelGeneratorTest { @Test fun `data class without UUID property does not generate UuidSerializer`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val uuidSerializerFile = files.find { it.name == "UuidSerializer" } assertEquals(null, uuidSerializerFile, "Expected no UuidSerializer file without UUID properties") } @@ -1369,7 +1373,7 @@ class ModelGeneratorTest { schemas = emptyList(), enums = emptyList(), ) - val files = generator.generate(apiSpec) + val files = generate(apiSpec) val uuidSerializerFile = files.find { it.name == "UuidSerializer" } assertNotNull(uuidSerializerFile, "Expected UuidSerializer when UUID is used in endpoint parameter") } @@ -1389,7 +1393,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(composedSchema))) + val files = generate(spec(schemas = listOf(composedSchema))) val childFile = files.first { it.name == "Child" } val typeSpec = childFile.members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt index fbabf73..e2e7fdb 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt @@ -1,5 +1,8 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.model.ModelGenerator +import com.avsystem.justworks.core.gen.shared.SerializersModuleGenerator +import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.PropertySpec import kotlin.test.Test import kotlin.test.assertNotNull @@ -7,8 +10,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class SerializersModuleGeneratorTest { - private val modelPackage = "com.example.model" - private val generator = SerializersModuleGenerator(modelPackage) + private val modelPackage = ModelPackage("com.example.model") private fun hierarchyInfo( sealedHierarchies: Map>, @@ -20,10 +22,13 @@ class SerializersModuleGeneratorTest { schemas = emptyList(), ) + private fun generate(info: ModelGenerator.HierarchyInfo): FileSpec? = + context(info, modelPackage) { SerializersModuleGenerator.generate() } + @Test fun `generates SerializersModule with polymorphic registration`() { val hierarchies = mapOf("Shape" to listOf("Circle", "Square")) - val fileSpec = context(hierarchyInfo(hierarchies)) { generator.generate() } + val fileSpec = generate(hierarchyInfo(hierarchies)) assertNotNull(fileSpec, "Should generate a FileSpec for non-empty hierarchies") @@ -42,7 +47,7 @@ class SerializersModuleGeneratorTest { "Shape" to listOf("Circle", "Square"), "Animal" to listOf("Cat", "Dog"), ) - val fileSpec = context(hierarchyInfo(hierarchies)) { generator.generate() } + val fileSpec = generate(hierarchyInfo(hierarchies)) assertNotNull(fileSpec) val initializer = @@ -61,7 +66,7 @@ class SerializersModuleGeneratorTest { @Test fun `returns null for empty hierarchies`() { - val result = context(hierarchyInfo(emptyMap())) { generator.generate() } + val result = generate(hierarchyInfo(emptyMap())) assertNull(result, "Should return null for empty hierarchies") } @@ -72,7 +77,7 @@ class SerializersModuleGeneratorTest { "Pet" to listOf("Cat", "Dog"), ) val info = hierarchyInfo(hierarchies, anyOfWithoutDiscriminator = setOf("Pet")) - val fileSpec = context(info) { generator.generate() } + val fileSpec = generate(info) assertNotNull(fileSpec) val initializer = fileSpec.members @@ -89,7 +94,7 @@ class SerializersModuleGeneratorTest { fun `returns null when all hierarchies are anyOf without discriminator`() { val hierarchies = mapOf("Pet" to listOf("Cat", "Dog")) val info = hierarchyInfo(hierarchies, anyOfWithoutDiscriminator = setOf("Pet")) - val result = context(info) { generator.generate() } + val result = generate(info) assertNull(result, "Should return null when only non-discriminator anyOf hierarchies exist") } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt index ab79650..712eace 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt @@ -3,71 +3,76 @@ package com.avsystem.justworks.core.gen import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.PropertyModel import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.TypeName import kotlin.test.Test import kotlin.test.assertEquals class TypeMappingTest { - private val pkg = "com.example.model" + private val pkg = ModelPackage("com.example.model") + + private fun map(typeRef: TypeRef): TypeName = context(pkg) { + typeRef.toTypeName() + } // -- Primitive types -- @Test fun `maps STRING to kotlin String`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.STRING), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.STRING)) assertEquals("kotlin.String", result.toString()) } @Test fun `maps INT to kotlin Int`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.INT), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.INT)) assertEquals("kotlin.Int", result.toString()) } @Test fun `maps LONG to kotlin Long`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.LONG), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.LONG)) assertEquals("kotlin.Long", result.toString()) } @Test fun `maps DOUBLE to kotlin Double`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DOUBLE), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.DOUBLE)) assertEquals("kotlin.Double", result.toString()) } @Test fun `maps FLOAT to kotlin Float`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.FLOAT), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.FLOAT)) assertEquals("kotlin.Float", result.toString()) } @Test fun `maps BOOLEAN to kotlin Boolean`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.BOOLEAN), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.BOOLEAN)) assertEquals("kotlin.Boolean", result.toString()) } @Test fun `maps BYTE_ARRAY to kotlin ByteArray`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.BYTE_ARRAY)) assertEquals("kotlin.ByteArray", result.toString()) } @Test fun `maps DATE_TIME to kotlin time Instant`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DATE_TIME), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.DATE_TIME)) assertEquals("kotlin.time.Instant", result.toString()) } @Test fun `maps DATE to kotlinx datetime LocalDate`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DATE), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.DATE)) assertEquals("kotlinx.datetime.LocalDate", result.toString()) } @Test fun `maps UUID to kotlin uuid Uuid`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.UUID), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.UUID)) assertEquals("kotlin.uuid.Uuid", result.toString()) } @@ -76,7 +81,7 @@ class TypeMappingTest { @Test fun `maps Array of String to List of String`() { val ref = TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)) - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("kotlin.collections.List", result.toString()) } @@ -85,7 +90,7 @@ class TypeMappingTest { @Test fun `maps Map of String to Map with String key and String value`() { val ref = TypeRef.Map(TypeRef.Primitive(PrimitiveType.STRING)) - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("kotlin.collections.Map", result.toString()) } @@ -94,7 +99,7 @@ class TypeMappingTest { @Test fun `maps Reference to ClassName in model package`() { val ref = TypeRef.Reference("Pet") - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("com.example.model.Pet", result.toString()) } @@ -103,7 +108,7 @@ class TypeMappingTest { @Test fun `maps Array of Reference to List of model class`() { val ref = TypeRef.Array(TypeRef.Reference("Pet")) - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("kotlin.collections.List", result.toString()) } @@ -116,7 +121,7 @@ class TypeMappingTest { requiredProperties = setOf("name"), contextHint = "Pet.Address", ) - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("com.example.model.Pet_Address", result.toString()) } @@ -132,19 +137,19 @@ class TypeMappingTest { @Test fun `maps Unknown to kotlinx serialization json JsonElement`() { - val result = TypeMapping.toTypeName(TypeRef.Unknown, pkg) + val result = map(TypeRef.Unknown) assertEquals("kotlinx.serialization.json.JsonElement", result.toString()) } @Test fun `maps Array of Unknown to List of JsonElement`() { - val result = TypeMapping.toTypeName(TypeRef.Array(TypeRef.Unknown), pkg) + val result = map(TypeRef.Array(TypeRef.Unknown)) assertEquals("kotlin.collections.List", result.toString()) } @Test fun `maps Map of Unknown to Map with JsonElement value`() { - val result = TypeMapping.toTypeName(TypeRef.Map(TypeRef.Unknown), pkg) + val result = map(TypeRef.Map(TypeRef.Unknown)) assertEquals("kotlin.collections.Map", result.toString()) } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index cae9d75..cb7eaad 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -1,6 +1,7 @@ package com.avsystem.justworks.core.parser import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.ContentType import com.avsystem.justworks.core.model.EnumBackingType import com.avsystem.justworks.core.model.HttpMethod import com.avsystem.justworks.core.model.ParameterLocation @@ -129,7 +130,7 @@ class SpecParserTest : SpecParserTestBase() { val body = assertNotNull(createPet.requestBody, "createPet should have a request body") assertTrue(body.required, "Request body should be required") - assertEquals("application/json", body.contentType) + assertEquals(ContentType.JSON_CONTENT_TYPE, body.contentType) val bodyType = assertIs(body.schema) assertEquals("NewPet", bodyType.schemaName)