From 6ce8f0939d212c9e17a21272430addf29d2c1f4f Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Thu, 17 Sep 2026 18:19:04 +0100 Subject: [PATCH 1/5] resolve implementation --- README.md | 7 +- build.gradle.kts | 1 + docs/getting-started.md | 8 +- docs/index.md | 5 +- docs/retrofit.md | 69 ++++++++++++++++++ docs/supported-types.md | 4 + gradle/libs.versions.toml | 2 + retrofit/build.gradle.kts | 69 ++++++++++++++++++ .../some/retrofit/RetrofitResolverProvider.kt | 17 +++++ .../retrofit/resolver/ResponseResolver.kt | 21 ++++++ .../dev.appoutlet.some.core.ResolverProvider | 1 + .../some/retrofit/RetrofitIntegrationTest.kt | 19 +++++ .../retrofit/RetrofitResolverProviderTest.kt | 16 ++++ .../retrofit/resolver/ResponseResolverTest.kt | 73 +++++++++++++++++++ settings.gradle.kts | 1 + zensical.toml | 1 + 16 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 docs/retrofit.md create mode 100644 retrofit/build.gradle.kts create mode 100644 retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/RetrofitResolverProvider.kt create mode 100644 retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/resolver/ResponseResolver.kt create mode 100644 retrofit/src/main/resources/META-INF/services/dev.appoutlet.some.core.ResolverProvider create mode 100644 retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/RetrofitIntegrationTest.kt create mode 100644 retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/RetrofitResolverProviderTest.kt create mode 100644 retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/resolver/ResponseResolverTest.kt diff --git a/README.md b/README.md index b23dc37e..346e716b 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,12 @@ val user = some() ## Installation -Some is published as three artifacts: +Some is published as four artifacts: - `some-core` for Java and Kotlin/JVM projects - `some-android` for Android projects. It re-exports the core API, so you do not need to add `some-core` separately. - `some-kotest` for Kotest `Arb` integration. Add it alongside either `some-core` or `some-android`. +- `some-retrofit` for Retrofit response fixtures. Add it alongside `some-core` and Retrofit. ```kotlin dependencies { @@ -56,6 +57,10 @@ dependencies { // Optional: Kotest property testing integration. // Add this alongside either some-core or some-android. testImplementation("dev.appoutlet:some-kotest:{version}") + + // Optional: Retrofit response integration. + // Retrofit must be declared directly by the consuming project. + testImplementation("dev.appoutlet:some-retrofit:{version}") } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 663fb117..a3c78e5b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { dokka(projects.android) dokka(projects.core) dokka(projects.kotest) + dokka(projects.retrofit) } tasks.named("prepareKotlinBuildScriptModel") { diff --git a/docs/getting-started.md b/docs/getting-started.md index 61642cbb..49735d60 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ icon: lucide/graduation-cap ## Installation -Some is published as three artifacts. Pick one base artifact for your platform. Add `some-kotest` only if you use Kotest property testing. +Some is published as four artifacts. Pick one base artifact for your platform. Add `some-kotest` only if you use Kotest property testing and `some-retrofit` only if you use Retrofit. !!! info "Upgrading from an older version?" @@ -24,6 +24,10 @@ dependencies { // Optional: Kotest property testing integration. // Add this alongside either some-core or some-android. testImplementation("dev.appoutlet:some-kotest:{version}") + + // Optional: Retrofit response integration. + // Retrofit must be declared directly by the consuming project. + testImplementation("dev.appoutlet:some-retrofit:{version}") } ``` @@ -105,6 +109,8 @@ val users = some> { For more configuration use cases and details, see the [Configuration overview](configuration.md), [Strategies](strategies/index.md), and [Supported Types](supported-types.md). +For Retrofit response fixtures, see the [Retrofit2 Integration](retrofit.md) guide. + ## Requirements - **Kotlin 2.3.0+** — required for `typeOf()` and other type introspection APIs diff --git a/docs/index.md b/docs/index.md index 18615ce4..80d6c681 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ icon: lucide/package-open ![Kotlin Android](https://img.shields.io/badge/android-kotlin?style=for-the-badge&logo=android&label=kotlin&color=%231FA95C) ![Maven Central Version](https://img.shields.io/maven-central/v/dev.appoutlet/some-core?style=for-the-badge) -A Kotlin test data generation library for JVM and Android tests, with optional Kotest `Arb` integration. +A Kotlin test data generation library for JVM and Android tests, with optional Kotest `Arb` and Retrofit integrations. ## The Problem @@ -70,11 +70,12 @@ Writing tests means creating test data — lots of it. Constructing data classes ## Modules -Some is published as three user-facing artifacts: +Some is published as four user-facing artifacts: - `some-core` for Java and Kotlin/JVM projects. - `some-android` for Android projects. It re-exports the shared core API, so you do not need to add `some-core` separately. - `some-kotest` for Kotest `Arb` integration. Add it alongside either `some-core` or `some-android`. +- `some-retrofit` for Retrofit response fixtures. Add it alongside `some-core` and Retrofit. Use [Getting Started](getting-started.md) to install Some and learn the shared API. diff --git a/docs/retrofit.md b/docs/retrofit.md new file mode 100644 index 00000000..430fef02 --- /dev/null +++ b/docs/retrofit.md @@ -0,0 +1,69 @@ +--- +icon: lucide/network +--- +# Retrofit2 integration + +Use `dev.appoutlet:some-retrofit` to generate Retrofit `Response` fixtures for JVM tests. +The integration supports both successful and error responses and is used alongside a direct Retrofit dependency. + +## Installation + +```kotlin +dependencies { + testImplementation("com.squareup.retrofit2:retrofit:3.0.0") + testImplementation("dev.appoutlet:some-core:{version}") + testImplementation("dev.appoutlet:some-retrofit:{version}") +} +``` + +`some-retrofit` compiles against Retrofit but does not provide Retrofit transitively. Declare Retrofit directly in the consuming project. + +## Generating successful responses + +The Retrofit resolver is discovered automatically when `some-retrofit` is on the classpath: + +```kotlin +data class User(val id: Int, val name: String) + +val response: Response = some() + +check(response.isSuccessful) +check(response.code() == 200) +check(response.body() != null) +``` + +Use `someResponse()` when response metadata matters: + +```kotlin +val response: Response = someResponse( + code = 201, + message = "Created", + headers = Headers.Builder().add("X-Test", "value").build(), + request = Request.Builder().url("https://example.com/users").build(), +) +``` + +The second overload accepts an existing successful `okhttp3.Response`: + +```kotlin +val response: Response = someResponse(rawResponse) +``` + +Successful response codes must be in the `200..299` range, matching Retrofit's `Response.success` contract. + +## Generating error responses + +Use `someErrorResponse()` for HTTP error handling tests: + +```kotlin +val response: Response = someErrorResponse( + code = 404, + message = "Not Found", +) + +check(response.body() == null) +check(response.errorBody() != null) +``` + +The raw-response overload accepts an existing `okhttp3.Response`. Error response codes must be `400` or higher. +The generated error body is empty; add application-specific error-body handling in the test when needed. diff --git a/docs/supported-types.md b/docs/supported-types.md index 63ec434e..a58312ba 100644 --- a/docs/supported-types.md +++ b/docs/supported-types.md @@ -9,6 +9,8 @@ These supported types are part of the shared API consumed through both `some-cor For Android-specific support, see [Android](android/index.md). +For Retrofit response support, see [Retrofit2 Integration](retrofit.md). + For types not listed here, register a [custom factory](custom-factories.md) or ship a [custom resolver](custom-resolvers.md). ## Reference @@ -47,6 +49,8 @@ For types not listed here, register a [custom factory](custom-factories.md) or s | `Map` | `some>()` | | | `MutableMap` | `some>()` | | | `Array` | `some>()` | | +| **Retrofit** | | | +| `retrofit2.Response` | `some>()` | Requires `some-retrofit` and Retrofit | | **Android & Compose** | | | | Android framework | `some()`, `some()`, ... | See [Android](android/index.md) | | Jetpack Compose | `some()`, `some()`, ... | See [Compose](android/compose.md) | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a0aee253..1a2e2e50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ kotlin = "2.4.10" kotest = "6.2.4" mavenPublish = "0.37.0" robolectric = "4.16.1" +retrofit = "3.0.0" [libraries] androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" } @@ -21,6 +22,7 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } [plugins] android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/retrofit/build.gradle.kts b/retrofit/build.gradle.kts new file mode 100644 index 00000000..d9c7a219 --- /dev/null +++ b/retrofit/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + alias(libs.plugins.detekt) + alias(libs.plugins.dokka) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.mavenPublish) +} + +group = rootProject.group +version = rootProject.version + +repositories { + mavenCentral() +} + +dependencies { + implementation(projects.core) + compileOnly(libs.retrofit) + + testImplementation(libs.retrofit) + testImplementation(libs.kotlin.test) + + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(17) +} + +tasks.test { + useJUnitPlatform() +} + +mavenPublishing { + publishToMavenCentral(automaticRelease = true) + signAllPublications() + + coordinates(artifactId = "some-retrofit") + + pom { + name.set("Some Retrofit") + description.set("Retrofit response integration for Some, a Kotlin test data generation library.") + inceptionYear.set("2026") + url.set("https://github.com/MessiasLima/Some") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + + developers { + developer { + id.set("MessiasLima") + name.set("Messias Lima") + url.set("https://github.com/MessiasLima") + } + } + + scm { + url.set("https://github.com/MessiasLima/Some") + connection.set("scm:git:git://github.com/MessiasLima/Some.git") + developerConnection.set("scm:git:ssh://git@github.com/MessiasLima/Some.git") + } + } +} + +detekt { autoCorrect = true } diff --git a/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/RetrofitResolverProvider.kt b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/RetrofitResolverProvider.kt new file mode 100644 index 00000000..0c7e8eb6 --- /dev/null +++ b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/RetrofitResolverProvider.kt @@ -0,0 +1,17 @@ +package dev.appoutlet.some.retrofit + +import dev.appoutlet.some.core.Resolver +import dev.appoutlet.some.core.ResolverProvider +import dev.appoutlet.some.core.StrategyProvider +import dev.appoutlet.some.retrofit.resolver.ResponseResolver +import kotlin.random.Random + +/** + * Contributes Retrofit-specific resolvers discovered through [java.util.ServiceLoader]. + */ +class RetrofitResolverProvider : ResolverProvider { + override fun createResolvers( + strategyProvider: StrategyProvider, + random: Random, + ): List = listOf(ResponseResolver()) +} diff --git a/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/resolver/ResponseResolver.kt b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/resolver/ResponseResolver.kt new file mode 100644 index 00000000..bc608e7c --- /dev/null +++ b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/resolver/ResponseResolver.kt @@ -0,0 +1,21 @@ +package dev.appoutlet.some.retrofit.resolver + +import dev.appoutlet.some.core.Resolver +import dev.appoutlet.some.core.ResolverChain +import retrofit2.Response +import kotlin.reflect.KType + +/** + * Resolves [Response] values by delegating their body type to the active resolver chain. + */ +class ResponseResolver : Resolver { + override fun canResolve(type: KType): Boolean = type.classifier == Response::class + + override fun resolve(type: KType, chain: ResolverChain): Any { + val bodyType = requireNotNull(type.arguments.firstOrNull()?.type) { + "Star projection not supported in Response" + } + + return Response.success(chain.resolve(bodyType)) + } +} diff --git a/retrofit/src/main/resources/META-INF/services/dev.appoutlet.some.core.ResolverProvider b/retrofit/src/main/resources/META-INF/services/dev.appoutlet.some.core.ResolverProvider new file mode 100644 index 00000000..3a3ccddc --- /dev/null +++ b/retrofit/src/main/resources/META-INF/services/dev.appoutlet.some.core.ResolverProvider @@ -0,0 +1 @@ +dev.appoutlet.some.retrofit.RetrofitResolverProvider diff --git a/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/RetrofitIntegrationTest.kt b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/RetrofitIntegrationTest.kt new file mode 100644 index 00000000..18b1f374 --- /dev/null +++ b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/RetrofitIntegrationTest.kt @@ -0,0 +1,19 @@ +package dev.appoutlet.some.retrofit + +import dev.appoutlet.some.some +import retrofit2.Response +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class RetrofitIntegrationTest { + data class User(val name: String, val age: Int) + + @Test + fun `top-level some discovers Retrofit Response resolver`() { + val result: Response = some() + + assertEquals(200, result.code()) + assertNotNull(result.body()) + } +} diff --git a/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/RetrofitResolverProviderTest.kt b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/RetrofitResolverProviderTest.kt new file mode 100644 index 00000000..6630c3db --- /dev/null +++ b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/RetrofitResolverProviderTest.kt @@ -0,0 +1,16 @@ +package dev.appoutlet.some.retrofit + +import dev.appoutlet.some.config.DefaultStrategyProvider +import dev.appoutlet.some.retrofit.resolver.ResponseResolver +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class RetrofitResolverProviderTest { + @Test + fun `createResolvers returns the Retrofit resolver list`() { + val resolvers = RetrofitResolverProvider().createResolvers(DefaultStrategyProvider(), Random.Default) + + assertEquals(listOf(ResponseResolver::class), resolvers.map { it::class }) + } +} diff --git a/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/resolver/ResponseResolverTest.kt b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/resolver/ResponseResolverTest.kt new file mode 100644 index 00000000..a834c1ee --- /dev/null +++ b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/resolver/ResponseResolverTest.kt @@ -0,0 +1,73 @@ +package dev.appoutlet.some.retrofit.resolver + +import dev.appoutlet.some.config.NullableStrategy +import dev.appoutlet.some.core.Resolver +import dev.appoutlet.some.core.ResolverChain +import retrofit2.Response +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ResponseResolverTest { + private val resolver = ResponseResolver() + + @Test + fun `ResponseResolver generates Response by delegating body type to chain`() { + val resolvedTypes = mutableListOf() + val chain = ResolverChain( + listOf(TrackingResolver(typeOf(), "body", resolvedTypes)), + NullableStrategy.NullOnCircularReference, + ) + + val result = resolver.resolve(typeOf>(), chain) + + assertIs>(result) + assertEquals("body", result.body()) + assertEquals(listOf(typeOf()), resolvedTypes) + assertTrue(result.isSuccessful) + assertEquals(200, result.code()) + } + + @Test + fun `ResponseResolver canResolve detects Retrofit Response types`() { + assertTrue(resolver.canResolve(typeOf>())) + } + + @Test + fun `ResponseResolver rejects non-Response types`() { + assertFalse(resolver.canResolve(typeOf())) + assertFalse(resolver.canResolve(typeOf())) + assertFalse(resolver.canResolve(typeOf())) + } + + @Test + fun `ResponseResolver throws error on star projection`() { + assertFailsWith { + resolver.resolve( + typeOf>(), + ResolverChain( + emptyList(), + NullableStrategy.NullOnCircularReference, + ), + ) + } + } + + private class TrackingResolver( + private val supportedType: KType, + private val value: Any, + private val resolvedTypes: MutableList, + ) : Resolver { + override fun canResolve(type: KType): Boolean = type == supportedType + + override fun resolve(type: KType, chain: ResolverChain): Any { + resolvedTypes += type + return value + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 977d9726..3906e1a5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,6 +28,7 @@ rootProject.name = "Some" include(":core") include(":android") include(":kotest") +include(":retrofit") kover { enableCoverage() diff --git a/zensical.toml b/zensical.toml index 93ae2b65..b342784c 100644 --- a/zensical.toml +++ b/zensical.toml @@ -31,6 +31,7 @@ nav = [ "android/color.md" ] }, "kotest.md", + "retrofit.md", "custom-factories.md", "custom-resolvers.md", { "API Reference" = "https://some.appoutlet.dev/reference/latest/" } From 03c5ea09935ea76d165175ab007e608b24ac2c8c Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Thu, 17 Sep 2026 18:26:12 +0100 Subject: [PATCH 2/5] some response --- .../appoutlet/some/retrofit/SomeResponse.kt | 53 +++++++++++ .../some/retrofit/SomeResponseTest.kt | 89 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt create mode 100644 retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/SomeResponseTest.kt diff --git a/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt new file mode 100644 index 00000000..dd1ab1b9 --- /dev/null +++ b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt @@ -0,0 +1,53 @@ +package dev.appoutlet.some.retrofit + +import dev.appoutlet.some.some +import okhttp3.Headers +import okhttp3.Protocol +import okhttp3.Request +import retrofit2.Response + +private const val DEFAULT_URL = "http://localhost/" + +/** + * Generates a successful Retrofit response containing a generated [T] body. + * + * @param T Type of the generated response body. + * @param code Successful HTTP status code. + * @param message HTTP status message. + * @param protocol HTTP protocol used by the raw response. + * @param headers HTTP headers used by the raw response. + * @param request Request associated with the raw response. + * @return A successful Retrofit response containing a generated body. + * @throws IllegalArgumentException If [code] is outside the successful HTTP status range. + */ +inline fun someResponse( + code: Int = 200, + message: String = "OK", + protocol: Protocol = Protocol.HTTP_1_1, + headers: Headers = Headers.Builder().build(), + request: Request = defaultRequest(), +): Response { + val rawResponse = okhttp3.Response.Builder() + .code(code) + .message(message) + .protocol(protocol) + .headers(headers) + .request(request) + .build() + + return Response.success(some(), rawResponse) +} + +/** + * Generates a successful Retrofit response containing a generated [T] body and the supplied raw response metadata. + * + * @param T Type of the generated response body. + * @param rawResponse Successful OkHttp response to wrap. + * @return A successful Retrofit response containing a generated body. + * @throws IllegalArgumentException If [rawResponse] is not successful. + */ +inline fun someResponse(rawResponse: okhttp3.Response): Response = + Response.success(some(), rawResponse) + +@PublishedApi +internal fun defaultRequest(): Request = Request.Builder().url(DEFAULT_URL).build() diff --git a/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/SomeResponseTest.kt b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/SomeResponseTest.kt new file mode 100644 index 00000000..3451514a --- /dev/null +++ b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/SomeResponseTest.kt @@ -0,0 +1,89 @@ +package dev.appoutlet.some.retrofit + +import okhttp3.Headers +import okhttp3.Protocol +import okhttp3.Request +import retrofit2.Response +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SomeResponseTest { + data class User(val name: String, val age: Int) + + @Test + fun `someResponse generates a successful response with generated body and defaults`() { + val result: Response = someResponse() + + assertTrue(result.isSuccessful) + assertEquals(200, result.code()) + assertEquals("OK", result.message()) + assertEquals(Protocol.HTTP_1_1, result.raw().protocol) + assertEquals(0, result.headers().size) + assertEquals("http://localhost/", result.raw().request.url.toString()) + assertNotNull(result.body()) + } + + @Test + fun `someResponse preserves custom response metadata`() { + val headers = Headers.Builder().add("X-Test", "value").build() + val request = Request.Builder().url("https://example.com/users").build() + + val result: Response = someResponse( + code = 201, + message = "Created", + protocol = Protocol.HTTP_2, + headers = headers, + request = request, + ) + + assertEquals(201, result.code()) + assertEquals("Created", result.message()) + assertEquals(Protocol.HTTP_2, result.raw().protocol) + assertEquals(headers, result.headers()) + assertEquals(request, result.raw().request) + assertNotNull(result.body()) + } + + @Test + fun `someResponse wraps a supplied raw response`() { + val rawResponse = okhttp3.Response.Builder() + .code(202) + .message("Accepted") + .protocol(Protocol.HTTP_1_1) + .headers(Headers.Builder().add("X-Test", "value").build()) + .request(Request.Builder().url("https://example.com").build()) + .build() + + val result: Response = someResponse(rawResponse) + + assertEquals(rawResponse, result.raw()) + assertEquals(202, result.code()) + assertNotNull(result.body()) + } + + @Test + fun `someResponse rejects a supplied unsuccessful raw response`() { + val rawResponse = okhttp3.Response.Builder() + .code(404) + .message("Not Found") + .protocol(Protocol.HTTP_1_1) + .request(Request.Builder().url("https://example.com/missing").build()) + .build() + + val exception = assertFailsWith { + someResponse(rawResponse) + } + + assertEquals("rawResponse must be successful response", exception.message) + } + + @Test + fun `someResponse rejects non-success response codes`() { + assertFailsWith { + someResponse(code = 404) + } + } +} From 984f127d821fc62648318b230466ca45b4658356 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Thu, 17 Sep 2026 18:29:16 +0100 Subject: [PATCH 3/5] some error response --- .../some/retrofit/SomeErrorResponse.kt | 61 ++++++++++++++ .../some/retrofit/SomeErrorResponseTest.kt | 83 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponse.kt create mode 100644 retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponseTest.kt diff --git a/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponse.kt b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponse.kt new file mode 100644 index 00000000..b964da86 --- /dev/null +++ b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponse.kt @@ -0,0 +1,61 @@ +package dev.appoutlet.some.retrofit + +import okhttp3.Headers +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.Response + +/** + * Generates an error Retrofit response with an empty error body. + * + * @param T Expected response body type for the endpoint under test. + * @param code Error HTTP status code. + * @param message HTTP status message. + * @param protocol HTTP protocol used by the raw response. + * @param headers HTTP headers used by the raw response. + * @param request Request associated with the raw response. + * @return An error Retrofit response with a `null` body and empty error body. + * @throws IllegalArgumentException If the generated response is successful. + */ +@JvmOverloads +fun someErrorResponse( + code: Int = 400, + message: String = "Bad Request", + protocol: Protocol = Protocol.HTTP_1_1, + headers: Headers = Headers.Builder().build(), + request: Request = defaultRequest(), +): Response { + val rawResponse = okhttp3.Response.Builder() + .code(code) + .message(message) + .protocol(protocol) + .headers(headers) + .request(request) + .build() + + if (rawResponse.isSuccessful) { + throw IllegalArgumentException("rawResponse must be an error response") + } + + return Response.error(emptyErrorBody(), rawResponse) +} + +/** + * Generates an error Retrofit response with an empty error body and supplied raw response metadata. + * + * @param T Expected response body type for the endpoint under test. + * @param rawResponse Non-successful OkHttp response to wrap. + * @return An error Retrofit response with a `null` body and empty error body. + * @throws IllegalArgumentException If [rawResponse] is successful. + */ +fun someErrorResponse(rawResponse: okhttp3.Response): Response { + if (rawResponse.isSuccessful) { + throw IllegalArgumentException("rawResponse must be an error response") + } + + return Response.error(emptyErrorBody(), rawResponse) +} + +private fun emptyErrorBody(): ResponseBody = ByteArray(0).toResponseBody() diff --git a/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponseTest.kt b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponseTest.kt new file mode 100644 index 00000000..8614a775 --- /dev/null +++ b/retrofit/src/test/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponseTest.kt @@ -0,0 +1,83 @@ +package dev.appoutlet.some.retrofit + +import okhttp3.Headers +import okhttp3.Protocol +import okhttp3.Request +import retrofit2.Response +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SomeErrorResponseTest { + data class User(val name: String, val age: Int) + + @Test + fun `someErrorResponse generates an error response with empty error body`() { + val result: Response = someErrorResponse() + + assertEquals(400, result.code()) + assertEquals("Bad Request", result.message()) + assertNull(result.body()) + assertNotNull(result.errorBody()) + assertEquals("http://localhost/", result.raw().request.url.toString()) + } + + @Test + fun `someErrorResponse preserves custom response metadata`() { + val headers = Headers.Builder().add("X-Test", "value").build() + val request = Request.Builder().url("https://example.com/users").build() + + val result: Response = someErrorResponse( + code = 500, + message = "Server Error", + protocol = Protocol.HTTP_2, + headers = headers, + request = request, + ) + + assertEquals(500, result.code()) + assertEquals("Server Error", result.message()) + assertEquals(Protocol.HTTP_2, result.raw().protocol) + assertEquals(headers, result.headers()) + assertEquals(request, result.raw().request) + assertNull(result.body()) + assertNotNull(result.errorBody()) + } + + @Test + fun `someErrorResponse wraps a supplied raw response`() { + val rawResponse = okhttp3.Response.Builder() + .code(404) + .message("Not Found") + .protocol(Protocol.HTTP_1_1) + .request(Request.Builder().url("https://example.com/missing").build()) + .build() + + val result: Response = someErrorResponse(rawResponse) + + assertEquals(rawResponse, result.raw()) + assertEquals(404, result.code()) + assertNull(result.body()) + assertNotNull(result.errorBody()) + } + + @Test + fun `someErrorResponse rejects successful responses`() { + assertFailsWith { + someErrorResponse(code = 200) + } + + val successfulRawResponse = okhttp3.Response.Builder() + .code(200) + .message("OK") + .protocol(Protocol.HTTP_1_1) + .request(Request.Builder().url("http://localhost/").build()) + .build() + + assertFailsWith { + someErrorResponse(successfulRawResponse) + } + } +} From 30f4f6e2a459c9ae199395fb0fd175ccf802ed76 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Thu, 17 Sep 2026 18:29:55 +0100 Subject: [PATCH 4/5] JvmOverloads for better Java compatibility --- .../src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt index dd1ab1b9..d6862f67 100644 --- a/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt +++ b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeResponse.kt @@ -20,6 +20,7 @@ private const val DEFAULT_URL = "http://localhost/" * @return A successful Retrofit response containing a generated body. * @throws IllegalArgumentException If [code] is outside the successful HTTP status range. */ +@JvmOverloads inline fun someResponse( code: Int = 200, message: String = "OK", From b596cb80924d80449c4a507380177663787a0648 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Thu, 17 Sep 2026 18:36:19 +0100 Subject: [PATCH 5/5] generate error body --- .../some/retrofit/SomeErrorResponse.kt | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponse.kt b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponse.kt index b964da86..b8d9f061 100644 --- a/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponse.kt +++ b/retrofit/src/main/kotlin/dev/appoutlet/some/retrofit/SomeErrorResponse.kt @@ -1,9 +1,9 @@ package dev.appoutlet.some.retrofit +import dev.appoutlet.some.some import okhttp3.Headers import okhttp3.Protocol import okhttp3.Request -import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody import retrofit2.Response @@ -20,7 +20,7 @@ import retrofit2.Response * @throws IllegalArgumentException If the generated response is successful. */ @JvmOverloads -fun someErrorResponse( +inline fun someErrorResponse( code: Int = 400, message: String = "Bad Request", protocol: Protocol = Protocol.HTTP_1_1, @@ -35,11 +35,7 @@ fun someErrorResponse( .request(request) .build() - if (rawResponse.isSuccessful) { - throw IllegalArgumentException("rawResponse must be an error response") - } - - return Response.error(emptyErrorBody(), rawResponse) + return someErrorResponse(rawResponse) } /** @@ -50,12 +46,7 @@ fun someErrorResponse( * @return An error Retrofit response with a `null` body and empty error body. * @throws IllegalArgumentException If [rawResponse] is successful. */ -fun someErrorResponse(rawResponse: okhttp3.Response): Response { - if (rawResponse.isSuccessful) { - throw IllegalArgumentException("rawResponse must be an error response") - } - - return Response.error(emptyErrorBody(), rawResponse) +inline fun someErrorResponse(rawResponse: okhttp3.Response): Response { + require(rawResponse.isSuccessful.not()) { "rawResponse must be an error response" } + return Response.error(some().toString().toResponseBody(), rawResponse) } - -private fun emptyErrorBody(): ResponseBody = ByteArray(0).toResponseBody()