From 0ec2b06afd61d533483ade0b43ba52503dfc79b4 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Tue, 13 Jan 2026 09:02:26 +0100 Subject: [PATCH] Add multipart/form-data support for OkHttp client Implements multipart file upload handling for OkHttp simple and enhanced clients using MultipartBody. Includes FileUpload wrapper for file parameters and proper content type handling. --- end2end-tests/okhttp/build.gradle.kts | 22 +- .../fabrikt/clients/MultipartFormDataTest.kt | 310 ++++++++++++++++++ .../client/OkHttpEnhancedClientGenerator.kt | 6 +- .../client/OkHttpSimpleClientGenerator.kt | 97 +++++- .../generators/OkHttpClientGeneratorTest.kt | 1 + .../multipartFormData/client/ApiClient.kt | 130 ++++++++ .../multipartFormData/client/ApiService.kt | 77 +++++ .../client/HttpResilience4jUtil.kt | 13 + .../multipartFormData/client/HttpUtil.kt | 81 +++++ 9 files changed, 723 insertions(+), 14 deletions(-) create mode 100644 end2end-tests/okhttp/src/test/kotlin/com/cjbooms/fabrikt/clients/MultipartFormDataTest.kt create mode 100644 src/test/resources/examples/multipartFormData/client/ApiClient.kt create mode 100644 src/test/resources/examples/multipartFormData/client/ApiService.kt create mode 100644 src/test/resources/examples/multipartFormData/client/HttpResilience4jUtil.kt create mode 100644 src/test/resources/examples/multipartFormData/client/HttpUtil.kt diff --git a/end2end-tests/okhttp/build.gradle.kts b/end2end-tests/okhttp/build.gradle.kts index a7b8cd3b..5cca2c53 100644 --- a/end2end-tests/okhttp/build.gradle.kts +++ b/end2end-tests/okhttp/build.gradle.kts @@ -4,6 +4,7 @@ val fabrikt: Configuration by configurations.creating val generationDir = "$buildDir/generated" val apiFile = "${rootProject.projectDir}/src/test/resources/examples/okHttpClient/api.yaml" +val multipartApiFile = "${rootProject.projectDir}/src/test/resources/examples/multipartFormData/api.yaml" sourceSets { main { java.srcDirs("$generationDir/src/main/kotlin") } @@ -62,9 +63,28 @@ tasks { dependsOn(":shadowJar") } + val generateMultipartCode by creating(JavaExec::class) { + inputs.files(multipartApiFile) + outputs.dir(generationDir) + outputs.cacheIf { true } + classpath = rootProject.files("./build/libs/fabrikt-${rootProject.version}.jar") + mainClass.set("com.cjbooms.fabrikt.cli.CodeGen") + args = listOf( + "--output-directory", generationDir, + "--base-package", "com.example.multipart", + "--api-file", multipartApiFile, + "--targets", "http_models", + "--targets", "client", + "--http-client-opts", "resilience4j" + ) + dependsOn(":jar") + dependsOn(":shadowJar") + dependsOn(generateCode) + } + withType { compilerOptions.jvmTarget.set(JvmTarget.JVM_17) - dependsOn(generateCode) + dependsOn(generateMultipartCode) } diff --git a/end2end-tests/okhttp/src/test/kotlin/com/cjbooms/fabrikt/clients/MultipartFormDataTest.kt b/end2end-tests/okhttp/src/test/kotlin/com/cjbooms/fabrikt/clients/MultipartFormDataTest.kt new file mode 100644 index 00000000..ad9d6393 --- /dev/null +++ b/end2end-tests/okhttp/src/test/kotlin/com/cjbooms/fabrikt/clients/MultipartFormDataTest.kt @@ -0,0 +1,310 @@ +package com.cjbooms.fabrikt.clients + +import com.example.multipart.client.FileUpload +import com.example.multipart.client.FilesUploadClient +import com.example.multipart.client.FilesUploadMultipleClient +import com.example.multipart.models.UploadResponse +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.aMultipart +import com.github.tomakehurst.wiremock.client.WireMock.containing +import com.github.tomakehurst.wiremock.client.WireMock.equalTo +import com.github.tomakehurst.wiremock.client.WireMock.ok +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo +import com.github.tomakehurst.wiremock.common.ConsoleNotifier +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options +import okhttp3.OkHttpClient +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.net.ServerSocket + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MultipartFormDataTest { + private val port: Int = ServerSocket(0).use { socket -> socket.localPort } + private val wiremock: WireMockServer = WireMockServer(options().port(port).notifier(ConsoleNotifier(true))) + private val mapper = ObjectMapper().registerKotlinModule() + private val httpClient = OkHttpClient.Builder().build() + + private val uploadClient = FilesUploadClient(mapper, "http://localhost:$port", httpClient) + private val uploadMultipleClient = FilesUploadMultipleClient(mapper, "http://localhost:$port", httpClient) + + @BeforeEach + fun setUp() { + wiremock.start() + } + + @AfterEach + fun afterEach() { + wiremock.resetAll() + wiremock.stop() + } + + @Test + fun `single file upload sends multipart request correctly`() { + val expectedResponse = UploadResponse(id = "file-123") + + wiremock.stubFor( + post(urlEqualTo("/files/upload")) + .withMultipartRequestBody( + aMultipart() + .withName("file") + ) + .willReturn( + ok() + .withHeader("Content-Type", "application/json") + .withBody(mapper.writeValueAsString(expectedResponse)) + ) + ) + + val fileContent = "Hello, World!".toByteArray() + val result = uploadClient.uploadFile( + file = FileUpload(fileContent), + description = "Test file" + ) + + assertThat(result.statusCode).isEqualTo(200) + assertThat(result.data).isEqualTo(expectedResponse) + + wiremock.verify( + postRequestedFor(urlEqualTo("/files/upload")) + .withHeader("Content-Type", containing("multipart/form-data")) + .withRequestBodyPart( + aMultipart() + .withName("file") + .withBody(equalTo("Hello, World!")) + .build() + ) + .withRequestBodyPart( + aMultipart() + .withName("description") + .withBody(equalTo("\"Test file\"")) + .build() + ) + ) + } + + @Test + fun `multiple files upload sends multipart request correctly`() { + val expectedResponse = UploadResponse(id = "file-multi") + + wiremock.stubFor( + post(urlEqualTo("/files/upload-multiple")) + .withMultipartRequestBody( + aMultipart() + .withName("files") + ) + .willReturn( + ok() + .withHeader("Content-Type", "application/json") + .withBody(mapper.writeValueAsString(expectedResponse)) + ) + ) + + val file1Content = "File 1 content".toByteArray() + val file2Content = "File 2 content".toByteArray() + + val result = uploadMultipleClient.uploadMultipleFiles( + files = listOf(FileUpload(file1Content), FileUpload(file2Content)), + category = "documents" + ) + + assertThat(result.statusCode).isEqualTo(200) + assertThat(result.data).isEqualTo(expectedResponse) + + wiremock.verify( + postRequestedFor(urlEqualTo("/files/upload-multiple")) + .withHeader("Content-Type", containing("multipart/form-data")) + .withRequestBodyPart( + aMultipart() + .withName("files") + .withBody(equalTo("File 1 content")) + .build() + ) + .withRequestBodyPart( + aMultipart() + .withName("files") + .withBody(equalTo("File 2 content")) + .build() + ) + .withRequestBodyPart( + aMultipart() + .withName("category") + .withBody(equalTo("\"documents\"")) + .build() + ) + ) + } + + @Test + fun `single file upload uses custom filename when provided`() { + val expectedResponse = UploadResponse(id = "file-custom") + + wiremock.stubFor( + post(urlEqualTo("/files/upload")) + .withMultipartRequestBody( + aMultipart() + .withName("file") + .withHeader("Content-Disposition", containing("filename=\"my-image.png\"")) + ) + .willReturn( + ok() + .withHeader("Content-Type", "application/json") + .withBody(mapper.writeValueAsString(expectedResponse)) + ) + ) + + val fileContent = "PNG image bytes".toByteArray() + val result = uploadClient.uploadFile( + file = FileUpload(fileContent, "my-image.png"), + description = "An image file" + ) + + assertThat(result.statusCode).isEqualTo(200) + assertThat(result.data).isEqualTo(expectedResponse) + + wiremock.verify( + postRequestedFor(urlEqualTo("/files/upload")) + .withHeader("Content-Type", containing("multipart/form-data")) + .withRequestBodyPart( + aMultipart() + .withName("file") + .withHeader("Content-Disposition", containing("filename=\"my-image.png\"")) + .withBody(equalTo("PNG image bytes")) + .build() + ) + .withRequestBodyPart( + aMultipart() + .withName("description") + .withBody(equalTo("\"An image file\"")) + .build() + ) + ) + } + + @Test + fun `multiple files upload uses custom filenames when provided`() { + val expectedResponse = UploadResponse(id = "file-multi-custom") + + wiremock.stubFor( + post(urlEqualTo("/files/upload-multiple")) + .withMultipartRequestBody( + aMultipart() + .withName("files") + .withHeader("Content-Disposition", containing("filename=\"doc1.pdf\"")) + ) + .withMultipartRequestBody( + aMultipart() + .withName("files") + .withHeader("Content-Disposition", containing("filename=\"doc2.pdf\"")) + ) + .willReturn( + ok() + .withHeader("Content-Type", "application/json") + .withBody(mapper.writeValueAsString(expectedResponse)) + ) + ) + + val file1Content = "PDF content 1".toByteArray() + val file2Content = "PDF content 2".toByteArray() + + val result = uploadMultipleClient.uploadMultipleFiles( + files = listOf( + FileUpload(file1Content, "doc1.pdf"), + FileUpload(file2Content, "doc2.pdf") + ), + category = "documents" + ) + + assertThat(result.statusCode).isEqualTo(200) + assertThat(result.data).isEqualTo(expectedResponse) + + wiremock.verify( + postRequestedFor(urlEqualTo("/files/upload-multiple")) + .withHeader("Content-Type", containing("multipart/form-data")) + .withRequestBodyPart( + aMultipart() + .withName("files") + .withHeader("Content-Disposition", containing("filename=\"doc1.pdf\"")) + .withBody(equalTo("PDF content 1")) + .build() + ) + .withRequestBodyPart( + aMultipart() + .withName("files") + .withHeader("Content-Disposition", containing("filename=\"doc2.pdf\"")) + .withBody(equalTo("PDF content 2")) + .build() + ) + .withRequestBodyPart( + aMultipart() + .withName("category") + .withBody(equalTo("\"documents\"")) + .build() + ) + ) + } + + @Test + fun `multiple files upload allows partial filename specification`() { + val expectedResponse = UploadResponse(id = "file-partial") + + wiremock.stubFor( + post(urlEqualTo("/files/upload-multiple")) + .withMultipartRequestBody( + aMultipart() + .withName("files") + .withHeader("Content-Disposition", containing("filename=\"custom.txt\"")) + ) + .willReturn( + ok() + .withHeader("Content-Type", "application/json") + .withBody(mapper.writeValueAsString(expectedResponse)) + ) + ) + + val file1Content = "File 1".toByteArray() + val file2Content = "File 2".toByteArray() + + // Only provide filename for first file, second uses default + val result = uploadMultipleClient.uploadMultipleFiles( + files = listOf( + FileUpload(file1Content, "custom.txt"), + FileUpload(file2Content) // Uses default filename + ), + category = "mixed" + ) + + assertThat(result.statusCode).isEqualTo(200) + assertThat(result.data).isEqualTo(expectedResponse) + + wiremock.verify( + postRequestedFor(urlEqualTo("/files/upload-multiple")) + .withHeader("Content-Type", containing("multipart/form-data")) + .withRequestBodyPart( + aMultipart() + .withName("files") + .withHeader("Content-Disposition", containing("filename=\"custom.txt\"")) + .withBody(equalTo("File 1")) + .build() + ) + .withRequestBodyPart( + aMultipart() + .withName("files") + .withBody(equalTo("File 2")) + .build() + ) + .withRequestBodyPart( + aMultipart() + .withName("category") + .withBody(equalTo("\"mixed\"")) + .build() + ) + ) + } +} diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpEnhancedClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpEnhancedClientGenerator.kt index ca3e9ebd..7d121764 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpEnhancedClientGenerator.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpEnhancedClientGenerator.kt @@ -18,6 +18,7 @@ import com.cjbooms.fabrikt.model.Destinations import com.cjbooms.fabrikt.model.GeneratedFile import com.cjbooms.fabrikt.model.HandlebarsTemplates import com.cjbooms.fabrikt.model.IncomingParameter +import com.cjbooms.fabrikt.model.MultipartParameter import com.cjbooms.fabrikt.model.SimpleFile import com.cjbooms.fabrikt.model.SourceApi import com.cjbooms.fabrikt.util.KaizenParserExtensions.routeToPaths @@ -57,7 +58,7 @@ class OkHttpEnhancedClientGenerator( AnnotationSpec.builder(Throws::class) .addMember("%T::class", "ApiException".toClassName(packages.client)).build() ) - .addIncomingParameters(parameters) + .addIncomingParameters(parameters, useFileUploadType = packages.client) .addParameter( ParameterSpec.builder( ADDITIONAL_HEADERS_PARAMETER_NAME, @@ -187,10 +188,11 @@ class Resilience4jClientOperationStatement( } private fun CodeBlock.Builder.addClientCallStatement(parameters: List): CodeBlock.Builder { + val paramNames = parameters.map { it.name } + ADDITIONAL_HEADERS_PARAMETER_NAME this.add( "apiClient.%N(%L)", functionName(operation, resource, verb), - (parameters.map { it.name } + ADDITIONAL_HEADERS_PARAMETER_NAME).joinToString(","), + paramNames.joinToString(","), ) return this } diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt index 4ea62146..d1a0bc54 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt @@ -23,6 +23,7 @@ import com.cjbooms.fabrikt.model.HandlebarsTemplates import com.cjbooms.fabrikt.model.HeaderParam import com.cjbooms.fabrikt.model.IncomingParameter import com.cjbooms.fabrikt.model.KotlinTypeInfo +import com.cjbooms.fabrikt.model.MultipartParameter import com.cjbooms.fabrikt.model.PathParam import com.cjbooms.fabrikt.model.QueryParam import com.cjbooms.fabrikt.model.RequestParameter @@ -61,7 +62,7 @@ class OkHttpSimpleClientGenerator( AnnotationSpec.builder(Throws::class) .addMember("%T::class", "ApiException".toClassName(packages.client)).build() ) - .addIncomingParameters(parameters) + .addIncomingParameters(parameters, useFileUploadType = packages.client) .addParameter( ParameterSpec.builder( ADDITIONAL_HEADERS_PARAMETER_NAME, @@ -239,15 +240,89 @@ data class SimpleClientOperationStatement( private fun CodeBlock.Builder.addRequestSerializerStatement(verb: String) { val requestBody = operation.requestBody val toRequestBody = "toRequestBody".toClassName("okhttp3.RequestBody.Companion") - parameters.filterIsInstance().firstOrNull()?.let { - this.add( - "\n.%N(objectMapper.writeValueAsString(%N).%T(%S.%T()))", - verb, - it.name, - toRequestBody, - requestBody.getPrimaryContentMediaType()?.key, - "toMediaType".toClassName("okhttp3.MediaType.Companion") - ) - } ?: this.add("\n.%N(ByteArray(0).%T())", verb, toRequestBody) + val multipartParams = parameters.filterIsInstance() + + if (multipartParams.isNotEmpty()) { + addMultipartRequestBody(verb, multipartParams) + } else { + parameters.filterIsInstance().firstOrNull()?.let { + this.add( + "\n.%N(objectMapper.writeValueAsString(%N).%T(%S.%T()))", + verb, + it.name, + toRequestBody, + requestBody.getPrimaryContentMediaType()?.key, + "toMediaType".toClassName("okhttp3.MediaType.Companion") + ) + } ?: this.add("\n.%N(ByteArray(0).%T())", verb, toRequestBody) + } + } + + private fun CodeBlock.Builder.addMultipartRequestBody(verb: String, multipartParams: List) { + val multipartBody = "MultipartBody".toClassName("okhttp3") + val toRequestBody = "toRequestBody".toClassName("okhttp3.RequestBody.Companion") + val toMediaType = "toMediaType".toClassName("okhttp3.MediaType.Companion") + + this.add("\n.%N(%T.Builder()", verb, multipartBody) + this.add("\n.setType(%T.FORM)", multipartBody) + + for (param in multipartParams) { + if (param.isFile) { + if (param.schema.type == "array") { + // Array of files - use FileUpload.filename if provided, otherwise default to index-based name + this.add( + "\n.also { builder -> %N%L.forEachIndexed { index, fileUpload -> builder.addFormDataPart(%S, fileUpload.filename ?: \"%L_\$index\", fileUpload.content.%T(%S.%T())) } }", + param.name, + if (!param.isRequired) "?" else "", + param.oasName, + param.oasName, + toRequestBody, + "application/octet-stream", + toMediaType + ) + } else { + // Single file - use FileUpload.filename if provided, otherwise default to param name + if (param.isRequired) { + this.add( + "\n.addFormDataPart(%S, %N.filename ?: %S, %N.content.%T(%S.%T()))", + param.oasName, + param.name, + param.oasName, + param.name, + toRequestBody, + "application/octet-stream", + toMediaType + ) + } else { + this.add( + "\n.also { builder -> %N?.let { builder.addFormDataPart(%S, it.filename ?: %S, it.content.%T(%S.%T())) } }", + param.name, + param.oasName, + param.oasName, + toRequestBody, + "application/octet-stream", + toMediaType + ) + } + } + } else { + // Non-file part - serialize as JSON + if (param.isRequired) { + this.add( + "\n.addFormDataPart(%S, objectMapper.writeValueAsString(%N))", + param.oasName, + param.name + ) + } else { + this.add( + "\n.also { builder -> %N?.let { builder.addFormDataPart(%S, objectMapper.writeValueAsString(it)) } }", + param.name, + param.oasName + ) + } + } + } + + this.add("\n.build())") } } diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt index 45fac5c3..bd07186d 100644 --- a/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt +++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt @@ -40,6 +40,7 @@ class OkHttpClientGeneratorTest { "pathLevelParameters", "parameterNameClash", "byteArrayStream", + "multipartFormData", ) @BeforeEach diff --git a/src/test/resources/examples/multipartFormData/client/ApiClient.kt b/src/test/resources/examples/multipartFormData/client/ApiClient.kt new file mode 100644 index 00000000..d4d2b4ec --- /dev/null +++ b/src/test/resources/examples/multipartFormData/client/ApiClient.kt @@ -0,0 +1,130 @@ +package examples.multipartFormData.client + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonTypeRef +import examples.multipartFormData.models.UploadResponse +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import kotlin.String +import kotlin.Suppress +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.jvm.Throws + +@Suppress("unused") +public class FilesUploadClient( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val okHttpClient: OkHttpClient, +) { + /** + * Upload a single file + * + * @param file The file to upload + * @param description Optional description of the file + */ + @Throws(ApiException::class) + public fun uploadFile( + `file`: FileUpload, + description: String? = null, + additionalHeaders: Map = emptyMap(), + additionalQueryParameters: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = + "$baseUrl/files/upload" + .toHttpUrl() + .newBuilder() + .also { builder -> additionalQueryParameters.forEach { builder.queryParam(it.key, it.value) } } + .build() + + val headerBuilder = Headers.Builder() + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = + Request + .Builder() + .url(httpUrl) + .headers(httpHeaders) + .post( + MultipartBody + .Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + `file`.filename ?: "file", + `file`.content.toRequestBody("application/octet-stream".toMediaType()), + ).also { builder -> + description?.let { + builder.addFormDataPart( + "description", + objectMapper.writeValueAsString(it), + ) + } + }.build(), + ).build() + + return request.execute(okHttpClient, objectMapper, jacksonTypeRef()) + } +} + +@Suppress("unused") +public class FilesUploadMultipleClient( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val okHttpClient: OkHttpClient, +) { + /** + * Upload multiple files + * + * @param files The files to upload + * @param category Category for the uploaded files + */ + @Throws(ApiException::class) + public fun uploadMultipleFiles( + files: List, + category: String, + additionalHeaders: Map = emptyMap(), + additionalQueryParameters: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = + "$baseUrl/files/upload-multiple" + .toHttpUrl() + .newBuilder() + .also { builder -> additionalQueryParameters.forEach { builder.queryParam(it.key, it.value) } } + .build() + + val headerBuilder = Headers.Builder() + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = + Request + .Builder() + .url(httpUrl) + .headers(httpHeaders) + .post( + MultipartBody + .Builder() + .setType(MultipartBody.FORM) + .also { builder -> + files.forEachIndexed { index, fileUpload -> + builder.addFormDataPart( + "files", + fileUpload.filename ?: "files_$index", + fileUpload.content.toRequestBody("application/octet-stream".toMediaType()), + ) + } + }.addFormDataPart("category", objectMapper.writeValueAsString(category)) + .build(), + ).build() + + return request.execute(okHttpClient, objectMapper, jacksonTypeRef()) + } +} diff --git a/src/test/resources/examples/multipartFormData/client/ApiService.kt b/src/test/resources/examples/multipartFormData/client/ApiService.kt new file mode 100644 index 00000000..b23bfc73 --- /dev/null +++ b/src/test/resources/examples/multipartFormData/client/ApiService.kt @@ -0,0 +1,77 @@ +package examples.multipartFormData.client + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonTypeRef +import examples.multipartFormData.models.UploadResponse +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry +import okhttp3.OkHttpClient +import kotlin.String +import kotlin.Suppress +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.jvm.Throws + +/** + * The circuit breaker registry should have the proper configuration to correctly action on circuit + * breaker transitions based on the client exceptions [ApiClientException], [ApiServerException] and + * [IOException]. + * + * @see ApiClientException + * @see ApiServerException + */ +@Suppress("unused") +public class FilesUploadService( + private val circuitBreakerRegistry: CircuitBreakerRegistry, + objectMapper: ObjectMapper, + baseUrl: String, + okHttpClient: OkHttpClient, +) { + public var circuitBreakerName: String = "filesUploadClient" + + private val apiClient: FilesUploadClient = FilesUploadClient(objectMapper, baseUrl, okHttpClient) + + @Throws(ApiException::class) + public fun uploadFile( + `file`: FileUpload, + description: String? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.uploadFile(file, description, additionalHeaders) + } +} + +/** + * The circuit breaker registry should have the proper configuration to correctly action on circuit + * breaker transitions based on the client exceptions [ApiClientException], [ApiServerException] and + * [IOException]. + * + * @see ApiClientException + * @see ApiServerException + */ +@Suppress("unused") +public class FilesUploadMultipleService( + private val circuitBreakerRegistry: CircuitBreakerRegistry, + objectMapper: ObjectMapper, + baseUrl: String, + okHttpClient: OkHttpClient, +) { + public var circuitBreakerName: String = "filesUploadMultipleClient" + + private val apiClient: FilesUploadMultipleClient = + FilesUploadMultipleClient( + objectMapper, + baseUrl, + okHttpClient, + ) + + @Throws(ApiException::class) + public fun uploadMultipleFiles( + files: List, + category: String, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.uploadMultipleFiles(files, category, additionalHeaders) + } +} diff --git a/src/test/resources/examples/multipartFormData/client/HttpResilience4jUtil.kt b/src/test/resources/examples/multipartFormData/client/HttpResilience4jUtil.kt new file mode 100644 index 00000000..07826ccd --- /dev/null +++ b/src/test/resources/examples/multipartFormData/client/HttpResilience4jUtil.kt @@ -0,0 +1,13 @@ +package examples.multipartFormData.client + +import io.github.resilience4j.circuitbreaker.CircuitBreaker +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry + +fun withCircuitBreaker( + circuitBreakerRegistry: CircuitBreakerRegistry, + apiClientName: String, + apiCall: () -> ApiResponse +): ApiResponse { + val circuitBreaker = circuitBreakerRegistry.circuitBreaker(apiClientName) + return CircuitBreaker.decorateSupplier(circuitBreaker, apiCall).get() +} diff --git a/src/test/resources/examples/multipartFormData/client/HttpUtil.kt b/src/test/resources/examples/multipartFormData/client/HttpUtil.kt new file mode 100644 index 00000000..1478b981 --- /dev/null +++ b/src/test/resources/examples/multipartFormData/client/HttpUtil.kt @@ -0,0 +1,81 @@ +package examples.multipartFormData.client + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody + +@Suppress("unused") +fun HttpUrl.Builder.queryParam(key: String, value: T?): HttpUrl.Builder = this.apply { + if (value != null) this.addQueryParameter(key, value.toString()) +} + +@Suppress("unused") +fun FormBody.Builder.formParam(key: String, value: T?): FormBody.Builder = this.apply { + if (value != null) this.add(key, value.toString()) +} + +@Suppress("unused") +fun HttpUrl.Builder.queryParam(key: String, values: List?, explode: Boolean = true) = this.apply { + if (values != null) { + if (explode) values.forEach { addQueryParameter(key, it.toString()) } + else addQueryParameter(key, values.joinToString(",")) + } +} + +@Suppress("unused") +fun Headers.Builder.header(key: String, value: String?): Headers.Builder = this.apply { + if (value != null) this.add(key, value) +} + +@Throws(ApiException::class) +fun Request.execute(client: OkHttpClient, objectMapper: ObjectMapper, typeRef: TypeReference): ApiResponse = + doRequest(client) { + responseBody -> responseBody?.deserialize(objectMapper, typeRef) + } + +@Throws(ApiException::class) +fun Request.execute(client: OkHttpClient): ApiResponse = + doRequest(client) { + responseBody -> responseBody?.deserialize() + } + +private fun Request.doRequest(client: OkHttpClient, bodyReader: (ResponseBody?) -> T?): ApiResponse = + client.newCall(this).execute().use { response -> + when { + response.isSuccessful -> + ApiResponse(response.code, response.headers, bodyReader(response.body)) + response.isRedirection() -> + throw ApiRedirectException(response.code, response.headers, response.errorMessage()) + response.isBadRequest() -> + throw ApiClientException(response.code, response.headers, response.errorMessage()) + response.isServerError() -> + throw ApiServerException(response.code, response.headers, response.errorMessage()) + else -> throw ApiException("[${response.code}]: ${response.errorMessage()}") + } + } + +@Suppress("unused") +fun String.pathParam(vararg params: Pair): String = params.fold(this) { acc, param -> + acc.replace(param.first, param.second.toString()) +} + +fun ResponseBody.deserialize(objectMapper: ObjectMapper, typeRef: TypeReference): T? = + this.string().isNotBlankOrNull()?.let { objectMapper.readValue(it, typeRef) } + +fun ResponseBody.deserialize(): ByteArray? = this.byteStream().readAllBytes() + +fun String?.isNotBlankOrNull() = if (this.isNullOrBlank()) null else this + +private fun Response.errorMessage(): String = this.body?.string() ?: this.message + +private fun Response.isBadRequest(): Boolean = this.code in 400..499 + +private fun Response.isServerError(): Boolean = this.code in 500..599 + +private fun Response.isRedirection(): Boolean = this.code in 300..399