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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion end2end-tests/okhttp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down Expand Up @@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
dependsOn(generateCode)
dependsOn(generateMultipartCode)
}


Expand Down
Original file line number Diff line number Diff line change
@@ -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()
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -187,10 +188,11 @@ class Resilience4jClientOperationStatement(
}

private fun CodeBlock.Builder.addClientCallStatement(parameters: List<IncomingParameter>): 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
}
Expand Down
Loading