diff --git a/README.md b/README.md index 2d78f60..569a778 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,9 @@ registered spec). build/generated/justworks/ ├── shared/kotlin/ │ └── com/avsystem/justworks/ -│ ├── ApiClientBase.kt # Abstract base class + helper extensions -│ ├── HttpError.kt # HttpErrorType enum + HttpError data class +│ ├── ApiClientBase.kt # Abstract base class + auth handling + helper extensions +│ ├── HttpResult.kt # HttpResult sealed interface +│ ├── HttpError.kt # HttpError sealed class hierarchy │ └── HttpSuccess.kt # HttpSuccess data class │ └── specName/ @@ -162,8 +163,7 @@ build/generated/justworks/ ├── model/ │ ├── Pet.kt # @Serializable data class │ ├── PetStatus.kt # @Serializable enum class - │ ├── Shape.kt # sealed interface (oneOf/anyOf) - │ ├── Circle.kt # variant data class : Shape + │ ├── Shape.kt # sealed interface + nested variants (oneOf/anyOf) │ ├── UuidSerializer.kt # (if spec uses UUID fields) │ └── SerializersModule.kt # (if spec has polymorphic types) └── api/ @@ -174,7 +174,7 @@ build/generated/justworks/ - **Data classes** -- one per named schema. Properties annotated with `@SerialName`, sorted required-first. - **Enums** -- constants in `UPPER_SNAKE_CASE` with `@SerialName` for the wire value. -- **Sealed interfaces** -- for `oneOf`/`anyOf` schemas. Variants are separate data classes implementing the interface. +- **Sealed interfaces** -- for `oneOf`/`anyOf` schemas. Discriminated variants are nested inside the sealed interface file. - **SerializersModule** -- top-level `val generatedSerializersModule` registering all polymorphic hierarchies. Only generated when needed. @@ -182,7 +182,8 @@ build/generated/justworks/ One client class per OpenAPI tag (e.g. `pets` tag -> `PetsApi`). Untagged endpoints go to `DefaultApi`. -Each endpoint becomes a `suspend` function with `context(Raise)` that returns `HttpSuccess`. +Each endpoint becomes a `suspend` function that returns `HttpResult` -- a sealed interface implemented by +`HttpError` (for failures) and `HttpSuccess` (for successes). No Arrow or other external runtime dependencies are required. ### Gradle Tasks @@ -312,13 +313,20 @@ The client implements `Closeable` -- call `client.close()` when done to release ### Making Requests -Every endpoint becomes a `suspend` function on the client that returns `HttpSuccess` on success and throws -`HttpError` on failure: +Every endpoint becomes a `suspend` function on the client that returns `HttpResult`: ```kotlin -val result: HttpSuccess> = client.listPets(limit = 10) -println(result.body) // the deserialized response body -println(result.code) // the HTTP status code +val result: HttpResult> = client.listPets(limit = 10) + +when (result) { + is HttpSuccess -> { + println(result.body) // the deserialized response body + println(result.code) // the HTTP status code + } + is HttpError -> { + println("Error ${result.code}: ${result.body}") + } +} ``` Path, query, and header parameters map to function arguments. Optional parameters default to `null`: @@ -329,42 +337,50 @@ val result = client.findPets(status = "available", limit = 20) ### Error Handling -Generated endpoints throw `HttpError` (a `RuntimeException` subclass) for non-2xx responses and network failures. -Use standard `try`/`catch` to handle errors: +`HttpResult` is a sealed interface with two branches: + +- `HttpSuccess` -- successful response (2xx) with a deserialized body +- `HttpError` -- sealed class hierarchy for all error cases + +`HttpError` provides typed subtypes for common HTTP error codes: + +| Subtype | HTTP status | Description | +|---------------------------------|-------------|------------------------| +| `HttpError.BadRequest` | 400 | Bad request | +| `HttpError.Unauthorized` | 401 | Unauthorized | +| `HttpError.Forbidden` | 403 | Forbidden | +| `HttpError.NotFound` | 404 | Not found | +| `HttpError.MethodNotAllowed` | 405 | Method not allowed | +| `HttpError.RequestTimeout` | 408 | Request timeout | +| `HttpError.Conflict` | 409 | Conflict | +| `HttpError.Gone` | 410 | Gone | +| `HttpError.PayloadTooLarge` | 413 | Payload too large | +| `HttpError.UnsupportedMediaType`| 415 | Unsupported media type | +| `HttpError.UnprocessableEntity` | 422 | Unprocessable entity | +| `HttpError.TooManyRequests` | 429 | Too many requests | +| `HttpError.InternalServerError` | 500 | Internal server error | +| `HttpError.BadGateway` | 502 | Bad gateway | +| `HttpError.ServiceUnavailable` | 503 | Service unavailable | +| `HttpError.GatewayTimeout` | 504 | Gateway timeout | +| `HttpError.Redirect` | 3xx | Redirect | +| `HttpError.Other` | *any other* | Catchall with code | +| `HttpError.Network` | -- | I/O or timeout | + +Each error subtype carries a nullable `body: E?` with the deserialized error response (or `null` if deserialization +failed), plus an `code: Int` property. ```kotlin -try { - val success = client.getPet(petId = 123) - println("Found: ${success.body.name}") -} catch (e: HttpError) { - when (e.type) { - HttpErrorType.Client -> println("Client error ${e.code}: ${e.message}") - HttpErrorType.Server -> println("Server error ${e.code}: ${e.message}") - HttpErrorType.Redirect -> println("Redirect ${e.code}") - HttpErrorType.Network -> println("Connection failed: ${e.message}") - } +when (result) { + is HttpSuccess -> println("Pet: ${result.body.name}") + is HttpError.NotFound -> println("Pet not found") + is HttpError.Unauthorized -> println("Please log in") + is HttpError.Network -> println("Connection failed: ${result.cause}") + is HttpError -> println("HTTP ${result.code}: ${result.body}") } ``` -`HttpError` is a data class with the following fields: - -| Field | Type | Description | -|-----------|-----------------|----------------------------------------------| -| `code` | `Int` | HTTP status code (or `0` for network errors) | -| `message` | `String` | Response body text or exception message | -| `type` | `HttpErrorType` | Category of the error | - -`HttpErrorType` categorizes errors: - -| `HttpErrorType` value | Covered statuses / scenario | -|-----------------------|------------------------------------| -| `Client` | HTTP 4xx client errors | -| `Server` | HTTP 5xx server errors | -| `Redirect` | HTTP 3xx redirect responses | -| `Network` | I/O failures, timeouts, DNS issues | - -Network errors (connection timeouts, DNS failures) are caught and reported as -`HttpError(code = 0, ..., type = HttpErrorType.Network)` instead of propagating raw exceptions. +Network errors (connection timeouts, DNS failures) are caught and reported as `HttpError.Network` instead of +propagating exceptions. ## Publishing 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 7ab14cd..a2a3b24 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 @@ -13,7 +13,6 @@ val HTTP_HEADERS = ClassName("io.ktor.http", "HttpHeaders") val JSON_FUN = MemberName("io.ktor.serialization.kotlinx.json", "json") val BODY_FUN = MemberName("io.ktor.client.call", "body") -val BODY_AS_TEXT_FUN = MemberName("io.ktor.client.statement", "bodyAsText") val SET_BODY_FUN = MemberName("io.ktor.client.request", "setBody") val CONTENT_TYPE_FUN = MemberName("io.ktor.http", "contentType") val CONTENT_TYPE_APPLICATION = ClassName("io.ktor.http", "ContentType", "Application") @@ -84,11 +83,10 @@ val EXPERIMENTAL_UUID_API = ClassName("kotlin.uuid", "ExperimentalUuidApi") // Error Handling // ============================================================================ -val RUNTIME_EXCEPTION = ClassName("kotlin", "RuntimeException") - val HTTP_ERROR = ClassName("com.avsystem.justworks", "HttpError") -val HTTP_ERROR_TYPE = ClassName("com.avsystem.justworks", "HttpErrorType") val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess") +val HTTP_RESULT = ClassName("com.avsystem.justworks", "HttpResult") +val DESERIALIZE_ERROR_BODY_FUN = MemberName("com.avsystem.justworks", "deserializeErrorBody") // ============================================================================ // Kotlin stdlib diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt index af8bc2f..133ab02 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt @@ -45,19 +45,26 @@ internal object BodyGenerator { 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() + ): CodeBlock { + val resultFun = if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN + val code = CodeBlock.builder() + + code.beginControlFlow("return $SAFE_CALL") + + val urlString = buildUrlString(endpoint, params) + when (endpoint.requestBody?.contentType) { + ContentType.MULTIPART_FORM_DATA -> code.buildMultipartBody(endpoint, params, urlString) + ContentType.FORM_URL_ENCODED -> code.buildFormUrlEncodedBody(endpoint, params, urlString) + ContentType.JSON_CONTENT_TYPE, null -> code.buildJsonBody(endpoint, params, urlString) + } + + // Close the HTTP call block and chain .toResult() / .toEmptyResult() + code.unindent() + code.add("}.%M()\n", resultFun) + code.endControlFlow() // safeCall + + return code.build() + } private fun CodeBlock.Builder.buildJsonBody( endpoint: Endpoint, @@ -80,7 +87,7 @@ internal object BodyGenerator { addStatement("%M(%L)", SET_BODY_FUN, BODY) } - endControlFlow() // client.METHOD + // Don't endControlFlow here — the outer buildFunctionBody closes with .toResult() } private fun CodeBlock.Builder.buildMultipartBody( @@ -173,7 +180,7 @@ internal object BodyGenerator { beginControlFlow(")") addCommonRequestParts(params) addHttpMethodIfNeeded(endpoint.method) - endControlFlow() + // Don't endControlFlow here — the outer buildFunctionBody closes with .toResult() } private fun CodeBlock.Builder.addCommonRequestParts(params: Map>) { diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index c9bc6bd..a513071 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -12,8 +12,10 @@ import com.avsystem.justworks.core.gen.HEADERS_FUN import com.avsystem.justworks.core.gen.HTTP_CLIENT import com.avsystem.justworks.core.gen.HTTP_HEADERS import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER +import com.avsystem.justworks.core.gen.HTTP_RESULT import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.Hierarchy +import com.avsystem.justworks.core.gen.JSON_ELEMENT import com.avsystem.justworks.core.gen.NameRegistry import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.client.BodyGenerator.buildFunctionBody @@ -222,7 +224,8 @@ internal object ClientGenerator { private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { val functionName = methodRegistry.register(endpoint.operationId.toCamelCase()) val returnBodyType = resolveReturnType(endpoint) - val returnType = HTTP_SUCCESS.parameterizedBy(returnBodyType) + val errorType = resolveErrorType(endpoint) + val returnType = HTTP_RESULT.parameterizedBy(errorType, returnBodyType) val funBuilder = FunSpec .builder(functionName) @@ -273,6 +276,22 @@ internal object ClientGenerator { return funBuilder.build() } + context(_: Hierarchy) + private fun resolveErrorType(endpoint: Endpoint): TypeName { + val errorSchemas = endpoint.responses.entries + .asSequence() + .filter { !it.key.startsWith("2") } + .mapNotNull { it.value.schema } + .map { it.toTypeName() } + .distinct() + .toList() + + return when { + errorSchemas.size == 1 -> errorSchemas.single() + else -> JSON_ELEMENT + } + } + context(_: Hierarchy) private fun resolveReturnType(endpoint: Endpoint): TypeName = endpoint.responses.entries .asSequence() diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index 78eb3db..60894f3 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -3,26 +3,27 @@ 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.DESERIALIZE_ERROR_BODY_FUN import com.avsystem.justworks.core.gen.ENCODE_PARAM_FUN import com.avsystem.justworks.core.gen.ENCODE_TO_STRING_FUN import com.avsystem.justworks.core.gen.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_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_RESULT 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.SAFE_CALL import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE +import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier @@ -38,9 +39,10 @@ import com.squareup.kotlinpoet.UNIT /** * Generates the shared `ApiClientBase.kt` file containing: * - `encodeParam()` top-level utility function - * - `HttpResponse.mapToResult()` private extension with response mapping logic - * - `HttpResponse.toResult()` extension for typed response mapping - * - `HttpResponse.toEmptyResult()` extension for Unit response mapping + * - `HttpResponse.deserializeErrorBody()` internal helper for error body deserialization + * - `HttpResponse.mapToResult()` private extension with response mapping logic + * - `HttpResponse.toResult()` extension for typed response mapping + * - `HttpResponse.toEmptyResult()` extension for Unit response mapping * - `ApiClientBase` abstract class with common client infrastructure */ internal object ApiClientBaseGenerator { @@ -48,17 +50,18 @@ internal object ApiClientBaseGenerator { private const val SUCCESS_BODY = "successBody" private const val MAP_TO_RESULT = "mapToResult" private const val BLOCK = "block" - private const val NETWORK_ERROR = "Network error" fun generate(): FileSpec { val t = TypeVariableName("T").copy(reified = true) + val e = TypeVariableName("E").copy(reified = true) return FileSpec .builder(API_CLIENT_BASE) .addFunction(buildEncodeParam(t)) - .addFunction(buildMapToResult(t)) - .addFunction(buildToResult(t)) - .addFunction(buildToEmptyResult()) + .addFunction(buildDeserializeErrorBody(e)) + .addFunction(buildMapToResult(e, t)) + .addFunction(buildToResult(e, t)) + .addFunction(buildToEmptyResult(e)) .addType(buildApiClientBaseClass()) .build() } @@ -72,48 +75,70 @@ internal object ApiClientBaseGenerator { .addStatement("return %T.%M(value).trim('\"')", JSON_CLASS, ENCODE_TO_STRING_FUN) .build() - private fun buildMapToResult(t: TypeVariableName): FunSpec = FunSpec + private fun buildDeserializeErrorBody(e: TypeVariableName): FunSpec = FunSpec + .builder("deserializeErrorBody") + .addAnnotation(PublishedApi::class) + .addModifiers(KModifier.INTERNAL, KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) + .receiver(HTTP_RESPONSE) + .returns(TypeVariableName("E").copy(nullable = true)) + .beginControlFlow("return try") + .addStatement("%M()", BODY_FUN) + .nextControlFlow("catch (e: %T)", Exception::class) + .addStatement("if (e is %T) throw e", ClassName("kotlinx.coroutines", "CancellationException")) + .addStatement("null") + .endControlFlow() + .build() + + private fun buildMapToResult(e: TypeVariableName, t: TypeVariableName): FunSpec = FunSpec .builder(MAP_TO_RESULT) .addAnnotation(PublishedApi::class) .addModifiers(KModifier.INTERNAL, KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) .addTypeVariable(t) .receiver(HTTP_RESPONSE) .addParameter(SUCCESS_BODY, LambdaTypeName.get(returnType = TypeVariableName("T"))) - .returns(HTTP_SUCCESS.parameterizedBy(TypeVariableName("T"))) + .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), TypeVariableName("T"))) .beginControlFlow("return when (status.value)") - .addStatement("in 200..299 -> %T(status.value, %L())", HTTP_SUCCESS, SUCCESS_BODY) .addStatement( - "in 300..399 -> throw %T(status.value, %M(), %T.Redirect)", - HTTP_ERROR, - BODY_AS_TEXT_FUN, - HTTP_ERROR_TYPE, + "in 200..299 -> %T(status.value, %L())", + HTTP_SUCCESS, + SUCCESS_BODY, ).addStatement( - "in 400..499 -> throw %T(status.value, %M(), %T.Client)", + "in 300..399 -> %T.Redirect(status.value, %M())", HTTP_ERROR, - BODY_AS_TEXT_FUN, - HTTP_ERROR_TYPE, - ).addStatement( - "else -> throw %T(status.value, %M(), %T.Server)", + DESERIALIZE_ERROR_BODY_FUN, + ).apply { + for ((name, code) in ApiResponseGenerator.HTTP_ERROR_SUBTYPES) { + addStatement( + "$code -> %T.$name(%M())", + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ) + } + }.addStatement( + "else -> %T.Other(status.value, %M())", HTTP_ERROR, - BODY_AS_TEXT_FUN, - HTTP_ERROR_TYPE, + DESERIALIZE_ERROR_BODY_FUN, ).endControlFlow() .build() - private fun buildToResult(t: TypeVariableName): FunSpec = FunSpec + private fun buildToResult(e: TypeVariableName, t: TypeVariableName): FunSpec = FunSpec .builder("toResult") .addModifiers(KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) .addTypeVariable(t) .receiver(HTTP_RESPONSE) - .returns(HTTP_SUCCESS.parameterizedBy(TypeVariableName("T"))) + .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), TypeVariableName("T"))) .addStatement("return %L { %M() }", MAP_TO_RESULT, BODY_FUN) .build() - private fun buildToEmptyResult(): FunSpec = FunSpec + private fun buildToEmptyResult(e: TypeVariableName): FunSpec = FunSpec .builder("toEmptyResult") - .addModifiers(KModifier.SUSPEND) + .addModifiers(KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) .receiver(HTTP_RESPONSE) - .returns(HTTP_SUCCESS.parameterizedBy(UNIT)) + .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), UNIT)) .addStatement("return %L { Unit }", MAP_TO_RESULT) .build() @@ -160,27 +185,28 @@ internal object ApiClientBaseGenerator { .receiver(HTTP_REQUEST_BUILDER) .build() - private fun buildSafeCall(): FunSpec = FunSpec - .builder(SAFE_CALL) - .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND) - .addParameter(BLOCK, LambdaTypeName.get(returnType = HTTP_RESPONSE).copy(suspending = true)) - .returns(HTTP_RESPONSE) - .beginControlFlow("return try") - .addStatement("%L()", BLOCK) - .nextControlFlow("catch (e: %T)", IO_EXCEPTION) - .addStatement( - "throw %T(0, e.message ?: %S, %T.Network)", - HTTP_ERROR, - NETWORK_ERROR, - HTTP_ERROR_TYPE, - ).nextControlFlow("catch (e: %T)", HTTP_REQUEST_TIMEOUT_EXCEPTION) - .addStatement( - "throw %T(0, e.message ?: %S, %T.Network)", - HTTP_ERROR, - NETWORK_ERROR, - HTTP_ERROR_TYPE, - ).endControlFlow() - .build() + private fun buildSafeCall(): FunSpec { + val e = TypeVariableName("E").copy(reified = true) + val t = TypeVariableName("T").copy(reified = true) + val resultType = HTTP_RESULT.parameterizedBy(TypeVariableName("E"), TypeVariableName("T")) + val blockType = LambdaTypeName.get(returnType = resultType).copy(suspending = true) + + return FunSpec + .builder(SAFE_CALL) + .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) + .addTypeVariable(t) + .addParameter(BLOCK, blockType) + .returns(resultType) + .beginControlFlow("return try") + .addStatement("%L()", BLOCK) + .nextControlFlow("catch (e: %T)", IO_EXCEPTION) + .addStatement("%T.Network(e)", HTTP_ERROR) + .nextControlFlow("catch (e: %T)", HTTP_REQUEST_TIMEOUT_EXCEPTION) + .addStatement("%T.Network(e)", HTTP_ERROR) + .endControlFlow() + .build() + } private fun buildCreateHttpClient(): FunSpec = FunSpec .builder(CREATE_HTTP_CLIENT) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt index 02e5543..be2fc66 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt @@ -2,78 +2,206 @@ 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_RESULT import com.avsystem.justworks.core.gen.HTTP_SUCCESS -import com.avsystem.justworks.core.gen.RUNTIME_EXCEPTION import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.INT import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.NOTHING +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.THROWABLE import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName /** - * Generates [com.squareup.kotlinpoet.FileSpec]s containing: - * - `HttpErrorType` enum class with Client, Server, Redirect, Network values - * - `HttpError` data class with code, message, type fields + * Generates [FileSpec]s containing: + * - `HttpResult` sealed interface for typed API responses + * - `HttpError` sealed class hierarchy with predefined HTTP error subtypes * - `HttpSuccess` data class wrapping successful responses */ internal object ApiResponseGenerator { private const val CODE = "code" - private const val MESSAGE = "message" - private const val TYPE = "type" - fun generate(): List = listOf(generateHttpError(), generateHttpSuccess()) + internal val HTTP_ERROR_SUBTYPES = listOf( + "BadRequest" to 400, + "Unauthorized" to 401, + "Forbidden" to 403, + "NotFound" to 404, + "MethodNotAllowed" to 405, + "RequestTimeout" to 408, + "Conflict" to 409, + "Gone" to 410, + "PayloadTooLarge" to 413, + "UnsupportedMediaType" to 415, + "UnprocessableEntity" to 422, + "TooManyRequests" to 429, + "InternalServerError" to 500, + "BadGateway" to 502, + "ServiceUnavailable" to 503, + "GatewayTimeout" to 504, + ) - fun generateHttpError(): FileSpec { - val enumType = TypeSpec - .enumBuilder(HTTP_ERROR_TYPE) - .addEnumConstant("Client") - .addEnumConstant("Server") - .addEnumConstant("Redirect") - .addEnumConstant("Network") + fun generate(): List = listOf(generateHttpResult(), generateHttpError(), generateHttpSuccess()) + + fun generateHttpResult(): FileSpec { + val e = TypeVariableName("E", variance = KModifier.OUT) + val t = TypeVariableName("T", variance = KModifier.OUT) + + val sealedInterface = TypeSpec + .interfaceBuilder(HTTP_RESULT) + .addModifiers(KModifier.SEALED) + .addTypeVariable(e) + .addTypeVariable(t) .build() - val primaryConstructor = FunSpec - .constructorBuilder() - .addParameter(CODE, INT) - .addParameter(MESSAGE, STRING) - .addParameter(TYPE, HTTP_ERROR_TYPE) + return FileSpec + .builder(HTTP_RESULT) + .addType(sealedInterface) .build() + } + + fun generateHttpError(): FileSpec { + val b = TypeVariableName("B", variance = KModifier.OUT) - val dataClassType = TypeSpec + val sealedClass = TypeSpec .classBuilder(HTTP_ERROR) - .addModifiers(KModifier.DATA) - .superclass(RUNTIME_EXCEPTION) - .addSuperclassConstructorParameter(MESSAGE) - .primaryConstructor(primaryConstructor) + .addModifiers(KModifier.SEALED) + .addTypeVariable(b) + .addSuperinterface(HTTP_RESULT.parameterizedBy(b, NOTHING)) .addProperty( PropertySpec .builder(CODE, INT) - .initializer(CODE) + .addModifiers(KModifier.ABSTRACT) .build(), ).addProperty( PropertySpec - .builder(MESSAGE, STRING) - .addModifiers(KModifier.OVERRIDE) - .initializer(MESSAGE) + .builder(BODY, b.copy(nullable = true)) + .addModifiers(KModifier.ABSTRACT) + .build(), + ) + + // Predefined HTTP error subtypes with body + for ((name, statusCode) in HTTP_ERROR_SUBTYPES) { + sealedClass.addType(buildBodySubtype(name, statusCode)) + } + + // Redirect: 3xx range, both code and body in constructor + sealedClass.addType(buildRangeSubtype("Redirect")) + + // Other: both code and body in constructor + sealedClass.addType(buildRangeSubtype("Other")) + + // Network: no type variable, extends HttpError + sealedClass.addType(buildNetworkSubtype()) + + return FileSpec + .builder(HTTP_ERROR) + .addType(sealedClass.build()) + .build() + } + + private fun buildBodySubtype(name: String, statusCode: Int): TypeSpec { + val b = TypeVariableName("B", variance = KModifier.OUT) + return TypeSpec + .classBuilder(name) + .addModifiers(KModifier.DATA) + .addTypeVariable(b) + .superclass(HTTP_ERROR.parameterizedBy(b)) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter(BODY, b.copy(nullable = true)) .build(), ).addProperty( PropertySpec - .builder(TYPE, HTTP_ERROR_TYPE) - .initializer(TYPE) + .builder(BODY, b.copy(nullable = true)) + .initializer(BODY) + .addModifiers(KModifier.OVERRIDE) .build(), + ).addProperty( + PropertySpec + .builder(CODE, INT) + .addModifiers(KModifier.OVERRIDE) + .getter( + FunSpec + .getterBuilder() + .addStatement("return %L", statusCode) + .build(), + ).build(), ).build() + } - return FileSpec - .builder(HTTP_ERROR) - .addType(enumType) - .addType(dataClassType) - .build() + private fun buildRangeSubtype(name: String): TypeSpec { + val b = TypeVariableName("B", variance = KModifier.OUT) + return TypeSpec + .classBuilder(name) + .addModifiers(KModifier.DATA) + .addTypeVariable(b) + .superclass(HTTP_ERROR.parameterizedBy(b)) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter(CODE, INT) + .addParameter(BODY, b.copy(nullable = true)) + .build(), + ).addProperty( + PropertySpec + .builder(CODE, INT) + .initializer(CODE) + .addModifiers(KModifier.OVERRIDE) + .build(), + ).addProperty( + PropertySpec + .builder(BODY, b.copy(nullable = true)) + .initializer(BODY) + .addModifiers(KModifier.OVERRIDE) + .build(), + ).build() } + private fun buildNetworkSubtype(): TypeSpec = TypeSpec + .classBuilder("Network") + .addModifiers(KModifier.DATA) + .superclass(HTTP_ERROR.parameterizedBy(NOTHING)) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter( + ParameterSpec + .builder("cause", THROWABLE.copy(nullable = true)) + .defaultValue("null") + .build(), + ).build(), + ).addProperty( + PropertySpec + .builder("cause", THROWABLE.copy(nullable = true)) + .initializer("cause") + .build(), + ).addProperty( + PropertySpec + .builder(CODE, INT) + .addModifiers(KModifier.OVERRIDE) + .getter( + FunSpec + .getterBuilder() + .addStatement("return 0") + .build(), + ).build(), + ).addProperty( + PropertySpec + .builder(BODY, NOTHING.copy(nullable = true)) + .addModifiers(KModifier.OVERRIDE) + .getter( + FunSpec + .getterBuilder() + .addStatement("return null") + .build(), + ).build(), + ).build() + fun generateHttpSuccess(): FileSpec { val t = TypeVariableName("T") @@ -87,6 +215,7 @@ internal object ApiResponseGenerator { .classBuilder(HTTP_SUCCESS) .addModifiers(KModifier.DATA) .addTypeVariable(t) + .addSuperinterface(HTTP_RESULT.parameterizedBy(NOTHING, t)) .primaryConstructor(primaryConstructor) .addProperty(PropertySpec.builder(CODE, INT).initializer(CODE).build()) .addProperty(PropertySpec.builder(BODY, t).initializer(BODY).build()) 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 3f74b97..65df199 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 @@ -65,16 +65,18 @@ class ApiClientBaseGeneratorTest { @OptIn(ExperimentalKotlinPoetApi::class) @Test - fun `ApiClientBase has safeCall function`() { + fun `ApiClientBase has safeCall function with no context parameters`() { val safeCall = classSpec.funSpecs.first { it.name == "safeCall" } assertTrue(KModifier.PROTECTED in safeCall.modifiers) assertTrue(KModifier.SUSPEND in safeCall.modifiers) - assertTrue(safeCall.contextParameters.isEmpty(), "safeCall should not have context parameters") + assertTrue(KModifier.INLINE in safeCall.modifiers) + assertTrue(safeCall.contextParameters.isEmpty(), "Expected no context parameters") + assertEquals(2, safeCall.typeVariables.size, "Expected E and T type variables") + assertTrue(safeCall.typeVariables.all { it.isReified }, "Expected reified type variables") val body = safeCall.body.toString() assertTrue(body.contains("IOException"), "Expected IOException catch") assertTrue(body.contains("HttpRequestTimeoutException"), "Expected HttpRequestTimeoutException catch") - assertTrue(body.contains("throw"), "Expected throw for error handling") - assertTrue(body.contains("Network error"), "Expected Network error message") + assertTrue(body.contains("HttpError.Network"), "Expected HttpError.Network in body") } @Test @@ -101,35 +103,74 @@ class ApiClientBaseGeneratorTest { @OptIn(ExperimentalKotlinPoetApi::class) @Test - fun `toResult is suspend inline with reified T and no context parameters`() { + fun `toResult is suspend inline with reified E and T, no context parameter`() { val fn = topLevelFun("toResult") assertTrue(KModifier.SUSPEND in fn.modifiers) assertTrue(KModifier.INLINE in fn.modifiers) - val typeVar = fn.typeVariables.first() - assertTrue(typeVar.isReified, "Expected reified type variable") + assertEquals(2, fn.typeVariables.size, "Expected E and T type variables") + assertTrue(fn.typeVariables.all { it.isReified }, "Expected reified type variables") assertNotNull(fn.receiverType, "Expected HttpResponse receiver") - assertTrue(fn.contextParameters.isEmpty(), "toResult should not have context parameters") + assertTrue(fn.contextParameters.isEmpty(), "Expected no context parameters") + val returnType = fn.returnType as ParameterizedTypeName + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) } @OptIn(ExperimentalKotlinPoetApi::class) @Test - fun `toEmptyResult is suspend with no context parameters and returns HttpSuccess Unit`() { + fun `toEmptyResult returns HttpResult E Unit with no context parameter`() { val fn = topLevelFun("toEmptyResult") assertTrue(KModifier.SUSPEND in fn.modifiers) + assertTrue(KModifier.INLINE in fn.modifiers) + assertEquals(1, fn.typeVariables.size, "Expected E type variable") + assertTrue(fn.typeVariables.first().isReified, "Expected reified type variable") assertNotNull(fn.receiverType, "Expected HttpResponse receiver") - assertTrue(fn.contextParameters.isEmpty(), "toEmptyResult should not have context parameters") + assertTrue(fn.contextParameters.isEmpty(), "Expected no context parameters") val returnType = fn.returnType as ParameterizedTypeName - assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) - assertEquals("kotlin.Unit", returnType.typeArguments.first().toString()) + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) } - @OptIn(ExperimentalKotlinPoetApi::class) @Test - fun `mapToResult uses throw for error responses and has no context parameters`() { + fun `mapToResult branches on specific status codes`() { val fn = topLevelFun("mapToResult") - assertTrue(fn.contextParameters.isEmpty(), "mapToResult should not have context parameters") val body = fn.body.toString() - assertTrue(body.contains("throw"), "Expected throw for non-2xx responses") + assertTrue(body.contains("in 200..299"), "Expected 2xx success range") + assertTrue(body.contains("HttpSuccess"), "Expected HttpSuccess for success") + assertTrue(body.contains("in 300..399"), "Expected 3xx redirect range") + assertTrue(body.contains("HttpError.Redirect"), "Expected HttpError.Redirect") + assertTrue(body.contains("400 ->"), "Expected 400 branch") + assertTrue(body.contains("401 ->"), "Expected 401 branch") + assertTrue(body.contains("403 ->"), "Expected 403 branch") + assertTrue(body.contains("404 ->"), "Expected 404 branch") + assertTrue(body.contains("405 ->"), "Expected 405 branch") + assertTrue(body.contains("408 ->"), "Expected 408 branch") + assertTrue(body.contains("409 ->"), "Expected 409 branch") + assertTrue(body.contains("410 ->"), "Expected 410 branch") + assertTrue(body.contains("413 ->"), "Expected 413 branch") + assertTrue(body.contains("415 ->"), "Expected 415 branch") + assertTrue(body.contains("422 ->"), "Expected 422 branch") + assertTrue(body.contains("429 ->"), "Expected 429 branch") + assertTrue(body.contains("500 ->"), "Expected 500 branch") + assertTrue(body.contains("502 ->"), "Expected 502 branch") + assertTrue(body.contains("503 ->"), "Expected 503 branch") + assertTrue(body.contains("504 ->"), "Expected 504 branch") + assertTrue(body.contains("HttpError.BadRequest"), "Expected HttpError.BadRequest") + assertTrue(body.contains("HttpError.NotFound"), "Expected HttpError.NotFound") + assertTrue(body.contains("HttpError.InternalServerError"), "Expected HttpError.InternalServerError") + assertTrue(body.contains("HttpError.Other"), "Expected HttpError.Other catchall") + } + + @Test + fun `deserializeErrorBody helper function exists`() { + val fn = topLevelFun("deserializeErrorBody") + assertTrue(KModifier.INTERNAL in fn.modifiers) + assertTrue(KModifier.INLINE in fn.modifiers) + assertTrue(KModifier.SUSPEND in fn.modifiers) + assertEquals(1, fn.typeVariables.size, "Expected E type variable") + assertTrue(fn.typeVariables.first().isReified, "Expected reified type variable") + assertNotNull(fn.receiverType, "Expected HttpResponse receiver") + val body = fn.body.toString() + assertTrue(body.contains("body"), "Expected body() call") + assertTrue(body.contains("catch"), "Expected catch block for fallback") } @Test 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 651d779..eb06d24 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 @@ -10,82 +10,198 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class ApiResponseGeneratorTest { - private fun httpErrorClass(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) - val httpErrorFile = files.first { it.name == "HttpError" } - return httpErrorFile.members.filterIsInstance().first { it.name == "HttpError" } - } + private val files = ApiResponseGenerator.generate() + private val httpErrorFile = files.first { it.name == "HttpError" } + private val httpResultFile = files.first { it.name == "HttpResult" } - private fun httpErrorTypeEnum(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) - val httpErrorFile = files.first { it.name == "HttpError" } - return httpErrorFile.members.filterIsInstance().first { it.name == "HttpErrorType" } - } + private fun httpErrorClass(): TypeSpec = + httpErrorFile.members.filterIsInstance().first { it.name == "HttpError" } + + private fun httpResultInterface(): TypeSpec = + httpResultFile.members.filterIsInstance().first { it.name == "HttpResult" } private fun successClass(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) val successFile = files.first { it.name == "HttpSuccess" } return successFile.members.filterIsInstance().first() } @Test - fun `generates data class HttpError extending RuntimeException`() { + fun `HttpError is a sealed class`() { val typeSpec = httpErrorClass() assertEquals("HttpError", typeSpec.name) - assertTrue(KModifier.DATA in typeSpec.modifiers, "Expected DATA modifier") - assertEquals("kotlin.RuntimeException", typeSpec.superclass.toString(), "Expected RuntimeException superclass") - assertTrue( - typeSpec.superclassConstructorParameters.isNotEmpty(), - "Expected superclass constructor parameter for message", - ) - assertTrue( - typeSpec.superclassConstructorParameters - .first() - .toString() - .contains("message"), - "Expected message passed to RuntimeException constructor", + assertTrue(KModifier.SEALED in typeSpec.modifiers, "Expected SEALED modifier") + assertTrue(KModifier.DATA !in typeSpec.modifiers, "Should NOT have DATA modifier") + } + + @Test + fun `HttpError has type variable B with out variance`() { + val typeSpec = httpErrorClass() + assertEquals(1, typeSpec.typeVariables.size) + val typeVar = typeSpec.typeVariables.first() + assertEquals("B", typeVar.name) + assertTrue(typeVar.variance == KModifier.OUT, "Expected OUT variance on B") + } + + @Test + fun `HttpError has abstract code and body properties`() { + val typeSpec = httpErrorClass() + val codeProp = typeSpec.propertySpecs.first { it.name == "code" } + assertTrue(KModifier.ABSTRACT in codeProp.modifiers, "code should be abstract") + assertEquals("kotlin.Int", codeProp.type.toString()) + + val bodyProp = typeSpec.propertySpecs.first { it.name == "body" } + assertTrue(KModifier.ABSTRACT in bodyProp.modifiers, "body should be abstract") + assertEquals("B?", bodyProp.type.toString()) + } + + @Test + fun `HttpError has all predefined subtypes`() { + val typeSpec = httpErrorClass() + val subtypeNames = typeSpec.typeSpecs.mapNotNull { it.name }.sorted() + val expected = listOf( + "BadGateway", + "BadRequest", + "Conflict", + "Forbidden", + "GatewayTimeout", + "Gone", + "InternalServerError", + "MethodNotAllowed", + "Network", + "NotFound", + "Other", + "PayloadTooLarge", + "Redirect", + "RequestTimeout", + "ServiceUnavailable", + "TooManyRequests", + "Unauthorized", + "UnprocessableEntity", + "UnsupportedMediaType", ) + assertEquals(expected, subtypeNames) + assertEquals(19, subtypeNames.size) + } + + @Test + fun `predefined subtypes are data classes`() { + val typeSpec = httpErrorClass() + val badRequest = typeSpec.typeSpecs.first { it.name == "BadRequest" } + assertTrue(KModifier.DATA in badRequest.modifiers, "BadRequest should be DATA") + + val other = typeSpec.typeSpecs.first { it.name == "Other" } + assertTrue(KModifier.DATA in other.modifiers, "Other should be DATA") + + val network = typeSpec.typeSpecs.first { it.name == "Network" } + assertTrue(KModifier.DATA in network.modifiers, "Network should be DATA") + } + + @Test + fun `BadRequest subtype has body parameter and code 400`() { + val typeSpec = httpErrorClass() + val badRequest = typeSpec.typeSpecs.first { it.name == "BadRequest" } + + val constructor = assertNotNull(badRequest.primaryConstructor) + assertEquals(1, constructor.parameters.size) + assertEquals("body", constructor.parameters.first().name) + + val codeProp = badRequest.propertySpecs.first { it.name == "code" } + assertTrue(KModifier.OVERRIDE in codeProp.modifiers) + assertNotNull(codeProp.getter, "code should have a getter") + assertTrue(codeProp.getter.toString().contains("400"), "code getter should return 400") + } + + @Test + fun `Other subtype has both code and body in constructor`() { + val typeSpec = httpErrorClass() + val other = typeSpec.typeSpecs.first { it.name == "Other" } + + val constructor = assertNotNull(other.primaryConstructor) + assertEquals(2, constructor.parameters.size) + val paramNames = constructor.parameters.map { it.name } + assertTrue("code" in paramNames, "Other should have code param") + assertTrue("body" in paramNames, "Other should have body param") + } + + @Test + fun `Network subtype has cause parameter and no type variable`() { + val typeSpec = httpErrorClass() + val network = typeSpec.typeSpecs.first { it.name == "Network" } + + assertTrue(network.typeVariables.isEmpty(), "Network should have no type variables") + + val constructor = assertNotNull(network.primaryConstructor) + assertEquals(1, constructor.parameters.size) + val causeParam = constructor.parameters.first() + assertEquals("cause", causeParam.name) + assertTrue(causeParam.type.toString().contains("Throwable"), "cause should be Throwable?") + assertTrue(causeParam.type.isNullable, "cause should be nullable") + + val codeProp = network.propertySpecs.first { it.name == "code" } + assertTrue(KModifier.OVERRIDE in codeProp.modifiers) + assertNotNull(codeProp.getter, "code should have a getter") + assertTrue(codeProp.getter.toString().contains("0"), "code getter should return 0") + + val bodyProp = network.propertySpecs.first { it.name == "body" } + assertTrue(KModifier.OVERRIDE in bodyProp.modifiers) + assertNotNull(bodyProp.getter, "body should have a getter") + assertTrue(bodyProp.getter.toString().contains("null"), "body getter should return null") + } + + @Test + fun `HttpResult is a sealed interface with E and T type variables`() { + val typeSpec = httpResultInterface() + assertEquals("HttpResult", typeSpec.name) + assertTrue(KModifier.SEALED in typeSpec.modifiers, "Expected SEALED modifier") + assertEquals(2, typeSpec.typeVariables.size) + assertEquals("E", typeSpec.typeVariables[0].name) + assertTrue(typeSpec.typeVariables[0].variance == KModifier.OUT, "Expected OUT variance on E") + assertEquals("T", typeSpec.typeVariables[1].name) + assertTrue(typeSpec.typeVariables[1].variance == KModifier.OUT, "Expected OUT variance on T") } @Test - fun `HttpError data class has code message and type fields`() { + fun `HttpError implements HttpResult`() { val typeSpec = httpErrorClass() - val constructor = assertNotNull(typeSpec.primaryConstructor) - assertEquals(3, constructor.parameters.size) - val codeParam = constructor.parameters.first { it.name == "code" } - assertEquals("kotlin.Int", codeParam.type.toString()) - val messageParam = constructor.parameters.first { it.name == "message" } - assertEquals("kotlin.String", messageParam.type.toString()) - val typeParam = constructor.parameters.first { it.name == "type" } - assertEquals("com.avsystem.justworks.HttpErrorType", typeParam.type.toString()) + val superinterfaces = typeSpec.superinterfaces.keys.map { it.toString() } + assertTrue(superinterfaces.any { it.contains("HttpResult") }, "HttpError should implement HttpResult") } @Test - fun `generates HttpErrorType enum with four values`() { - val typeSpec = httpErrorTypeEnum() - assertEquals("HttpErrorType", typeSpec.name) - val constantNames = typeSpec.enumConstants.keys.sorted() - assertEquals(listOf("Client", "Network", "Redirect", "Server"), constantNames) + fun `HttpSuccess implements HttpResult`() { + val typeSpec = successClass() + val superinterfaces = typeSpec.superinterfaces.keys.map { it.toString() } + assertTrue(superinterfaces.any { it.contains("HttpResult") }, "HttpSuccess should implement HttpResult") } @Test - fun `Success is a data class with body and statusCode`() { + fun `HttpErrorType enum is not generated`() { + val allTypes = httpErrorFile.members.filterIsInstance() + assertTrue(allTypes.none { it.name == "HttpErrorType" }, "HttpErrorType should not exist") + } + + @Test + fun `HttpSuccess is unchanged`() { val success = successClass() assertEquals("HttpSuccess", success.name) - assertTrue(KModifier.DATA in success.modifiers, "Expected DATA modifier on Success") + assertTrue(KModifier.DATA in success.modifiers, "Expected DATA modifier on HttpSuccess") + + assertEquals(1, success.typeVariables.size) + assertEquals("T", success.typeVariables.first().name) + val constructor = assertNotNull(success.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertTrue("body" in paramNames, "Expected 'body' parameter") assertTrue("code" in paramNames, "Expected 'code' parameter") + val bodyParam = constructor.parameters.first { it.name == "body" } assertTrue(bodyParam.type is TypeVariableName, "body should be type variable T") } @Test - fun `generates two files`() { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) - assertEquals(2, files.size) + fun `generates three files`() { + assertEquals(3, files.size) val fileNames = files.map { it.name }.sorted() - assertEquals(listOf("HttpError", "HttpSuccess"), fileNames) + assertEquals(listOf("HttpError", "HttpResult", "HttpSuccess"), fileNames) } } 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 01debe9..5f295ec 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 @@ -222,29 +222,36 @@ class ClientGeneratorTest { assertEquals("com.example.model.Pet", bodyParam.type.toString()) } - // -- CLNT-08: Return type is Success parameterized -- + // -- CLNT-08: Return type is HttpResult parameterized -- @Test - fun `return type is Success parameterized`() { + fun `return type is HttpResult parameterized`() { val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType assertNotNull(returnType) assertTrue(returnType is ParameterizedTypeName, "Expected ParameterizedTypeName") - assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) - assertEquals("com.example.model.Pet", returnType.typeArguments.first().toString()) + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) + assertEquals( + "kotlinx.serialization.json.JsonElement", + returnType.typeArguments[0].toString(), + "Expected JsonElement as error type", + ) + assertEquals( + "com.example.model.Pet", + returnType.typeArguments[1].toString(), + "Expected Pet as success body type", + ) } - // -- Error handling: endpoint functions throw HttpError (no Arrow dependency) -- + // -- ERR-01: No Raise context on endpoint functions -- + @OptIn(ExperimentalKotlinPoetApi::class) @Test - fun `endpoint functions do not have context parameters`() { + fun `endpoint functions have no context parameters`() { val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } - - @OptIn(ExperimentalKotlinPoetApi::class) - val contextParameters = funSpec.contextParameters - assertTrue(contextParameters.isEmpty(), "Expected no context parameters (Arrow removed)") + assertTrue(funSpec.contextParameters.isEmpty(), "Expected no context parameters") } // -- CLNT-09: Header parameters become function parameters -- @@ -331,7 +338,7 @@ class ClientGeneratorTest { // -- Pitfall 5: Void response uses Unit type parameter -- @Test - fun `void response uses Unit type parameter`() { + fun `void response uses HttpResult with Unit type parameter`() { val ep = endpoint( method = HttpMethod.DELETE, operationId = "deletePet", @@ -340,8 +347,17 @@ class ClientGeneratorTest { 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()) + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) + assertEquals( + "kotlinx.serialization.json.JsonElement", + returnType.typeArguments[0].toString(), + "Expected JsonElement as error type", + ) + assertEquals( + "kotlin.Unit", + returnType.typeArguments[1].toString(), + "Expected Unit as success body type", + ) } // -- CONT-03: Response code handling -- @@ -358,8 +374,8 @@ class ClientGeneratorTest { 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()) + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) + assertEquals("com.example.model.Pet", returnType.typeArguments[1].toString()) } @Test @@ -375,7 +391,7 @@ class ClientGeneratorTest { 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()) + assertEquals("com.example.model.Pet", returnType.typeArguments[1].toString()) } // -- Client class extends ApiClientBase -- @@ -733,6 +749,82 @@ class ClientGeneratorTest { ) } + // -- ERR-01: Error type resolution from OpenAPI error response schemas -- + + @Test + fun `single error response schema generates typed error in HttpResult`() { + val ep = endpoint( + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "400" to Response("400", "Bad request", TypeRef.Reference("ValidationError")), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals( + "com.example.model.ValidationError", + returnType.typeArguments[0].toString(), + "Expected typed error for single error schema", + ) + } + + @Test + fun `multiple error responses with same schema generates typed error`() { + val ep = endpoint( + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "400" to Response("400", "Bad request", TypeRef.Reference("ValidationError")), + "422" to Response("422", "Unprocessable", TypeRef.Reference("ValidationError")), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals( + "com.example.model.ValidationError", + returnType.typeArguments[0].toString(), + "Expected typed error when all error schemas are the same", + ) + } + + @Test + fun `multiple error responses with different schemas falls back to JsonElement`() { + val ep = endpoint( + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "400" to Response("400", "Bad request", TypeRef.Reference("ValidationError")), + "404" to Response("404", "Not found", TypeRef.Reference("NotFoundError")), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals( + "kotlinx.serialization.json.JsonElement", + returnType.typeArguments[0].toString(), + "Expected JsonElement fallback for different error schemas", + ) + } + + @Test + fun `error response with null schema falls back to JsonElement`() { + val ep = endpoint( + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "401" to Response("401", "Unauthorized", null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals( + "kotlinx.serialization.json.JsonElement", + returnType.typeArguments[0].toString(), + "Expected JsonElement fallback for null error schema", + ) + } + @Test fun `single Bearer scheme uses plain token param (no prefix)`() { val cls = clientClass( diff --git a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt index 566691f..762f67d 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -102,7 +102,6 @@ class JustworksPluginFunctionalTest { implementation("io.ktor:ktor-client-core:3.1.1") implementation("io.ktor:ktor-client-content-negotiation:3.1.1") implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") - implementation("io.arrow-kt:arrow-core:2.2.1.1") } kotlin { @@ -555,7 +554,6 @@ class JustworksPluginFunctionalTest { implementation("io.ktor:ktor-client-core:3.1.1") implementation("io.ktor:ktor-client-content-negotiation:3.1.1") implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") - implementation("io.arrow-kt:arrow-core:2.2.1.1") } kotlin {