From 9229cea4fb0a856f8d73ba07c2959437c3088fab Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Tue, 13 Jan 2026 09:06:18 +0100 Subject: [PATCH] Add multipart/form-data support for Micronaut server Implements multipart file upload for Micronaut controller using CompletedFileUpload and Publisher for async file arrays. Handles the Micronaut-specific requirement of reactive types for array uploads. --- end2end-tests/micronaut/build.gradle.kts | 92 ++++++++++++++++++ .../micronaut/MicronautMultipartServerTest.kt | 96 +++++++++++++++++++ .../micronaut/MultipartControllerImpl.kt | 58 +++++++++++ settings.gradle.kts | 1 + .../MicronautControllerInterfaceGenerator.kt | 43 ++++++++- .../controller/metadata/Micronaut.kt | 4 + .../MicronautControllerGeneratorTest.kt | 1 + .../controllers/micronaut/Controllers.kt | 51 ++++++++++ 8 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 end2end-tests/micronaut/build.gradle.kts create mode 100644 end2end-tests/micronaut/src/test/kotlin/com/cjbooms/fabrikt/servers/micronaut/MicronautMultipartServerTest.kt create mode 100644 end2end-tests/micronaut/src/test/kotlin/com/cjbooms/fabrikt/servers/micronaut/MultipartControllerImpl.kt create mode 100644 src/test/resources/examples/multipartFormData/controllers/micronaut/Controllers.kt diff --git a/end2end-tests/micronaut/build.gradle.kts b/end2end-tests/micronaut/build.gradle.kts new file mode 100644 index 00000000..4f438824 --- /dev/null +++ b/end2end-tests/micronaut/build.gradle.kts @@ -0,0 +1,92 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +val generationDir = "$buildDir/generated" + +sourceSets { + main { java.srcDirs("$generationDir/src/main/kotlin") } + test { java.srcDirs("$generationDir/src/test/kotlin") } +} + +plugins { + kotlin("jvm") + kotlin("kapt") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +val jacksonVersion: String by rootProject.extra +val junitVersion: String by rootProject.extra +val micronautVersion = "4.2.0" + +dependencies { + implementation(platform("com.fasterxml.jackson:jackson-bom:$jacksonVersion")) + implementation("jakarta.validation:jakarta.validation-api:3.0.2") + implementation("javax.validation:validation-api:2.0.1.Final") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-annotations") + + // Micronaut + implementation(platform("io.micronaut.platform:micronaut-platform:$micronautVersion")) + implementation("io.micronaut:micronaut-http-server-netty") + implementation("io.micronaut:micronaut-jackson-databind") + kapt(platform("io.micronaut.platform:micronaut-platform:$micronautVersion")) + kapt("io.micronaut:micronaut-inject-java") + + // Reactive streams + implementation("io.projectreactor:reactor-core:3.6.0") + + // Test dependencies + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") + testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion") + testImplementation("org.assertj:assertj-core:3.24.2") + testImplementation("io.micronaut.test:micronaut-test-junit5") + testImplementation("io.micronaut:micronaut-http-client") + kaptTest(platform("io.micronaut.platform:micronaut-platform:$micronautVersion")) + kaptTest("io.micronaut:micronaut-inject-java") +} + +tasks { + val generateMicronautMultipartCode = createCodeGenerationTask( + "generateMicronautMultipartCode", + "src/test/resources/examples/multipartFormData/api.yaml", + "com.example.multipart" + ) + + withType { + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) + dependsOn(generateMicronautMultipartCode) + } + + withType { + useJUnitPlatform() + jvmArgs = listOf("--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.util=ALL-UNNAMED") + } +} + +fun TaskContainer.createCodeGenerationTask( + name: String, apiFilePath: String, basePackage: String +): TaskProvider = register(name, JavaExec::class) { + val apiFile = "${rootProject.projectDir}/$apiFilePath" + inputs.files(apiFile) + 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", basePackage, + "--api-file", apiFile, + "--targets", "http_models", + "--targets", "controllers", + "--http-controller-target", "MICRONAUT", + ) + dependsOn(":jar") + dependsOn(":shadowJar") +} diff --git a/end2end-tests/micronaut/src/test/kotlin/com/cjbooms/fabrikt/servers/micronaut/MicronautMultipartServerTest.kt b/end2end-tests/micronaut/src/test/kotlin/com/cjbooms/fabrikt/servers/micronaut/MicronautMultipartServerTest.kt new file mode 100644 index 00000000..8bcdc942 --- /dev/null +++ b/end2end-tests/micronaut/src/test/kotlin/com/cjbooms/fabrikt/servers/micronaut/MicronautMultipartServerTest.kt @@ -0,0 +1,96 @@ +package com.cjbooms.fabrikt.servers.micronaut + +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.multipart.MultipartBody +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import jakarta.inject.Inject +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@MicronautTest +class MicronautMultipartServerTest { + + @Inject + @field:Client("/") + lateinit var client: HttpClient + + @Inject + lateinit var fileCapture: FileCapture + + @BeforeEach + fun setUp() { + fileCapture.reset() + } + + @Test + fun `single file upload is parsed correctly with file metadata`() { + val fileContent = "Hello, World!".toByteArray() + + val body = MultipartBody.builder() + .addPart("file", "test.txt", MediaType.TEXT_PLAIN_TYPE, fileContent) + .addPart("description", "Test description") + .build() + + val request = HttpRequest.POST("/files/upload", body) + .contentType(MediaType.MULTIPART_FORM_DATA_TYPE) + + val response = client.toBlocking().retrieve(request, String::class.java) + + assertThat(response).contains("file-123") + assertThat(fileCapture.capturedFile).isNotNull + assertThat(fileCapture.capturedFile!!.bytes).isEqualTo(fileContent) + assertThat(fileCapture.capturedFile!!.filename).isEqualTo("test.txt") + assertThat(fileCapture.capturedFile!!.contentType.get().toString()).isEqualTo(MediaType.TEXT_PLAIN) + assertThat(fileCapture.capturedDescription).isEqualTo("Test description") + } + + @Test + fun `single file upload works without optional description`() { + val fileContent = "Content without description".toByteArray() + + val body = MultipartBody.builder() + .addPart("file", "nodesc.txt", MediaType.TEXT_PLAIN_TYPE, fileContent) + .build() + + val request = HttpRequest.POST("/files/upload", body) + .contentType(MediaType.MULTIPART_FORM_DATA_TYPE) + + val response = client.toBlocking().retrieve(request, String::class.java) + + assertThat(response).contains("file-123") + assertThat(fileCapture.capturedFile).isNotNull + assertThat(fileCapture.capturedFile!!.bytes).isEqualTo(fileContent) + assertThat(fileCapture.capturedDescription).isNull() + } + + @Test + fun `multiple files upload is parsed correctly with file metadata`() { + val file1Content = "File 1 content".toByteArray() + val file2Content = "File 2 content".toByteArray() + + val body = MultipartBody.builder() + .addPart("files", "file1.txt", MediaType.TEXT_PLAIN_TYPE, file1Content) + .addPart("files", "file2.pdf", MediaType.APPLICATION_PDF_TYPE, file2Content) + .addPart("category", "documents") + .build() + + val request = HttpRequest.POST("/files/upload-multiple", body) + .contentType(MediaType.MULTIPART_FORM_DATA_TYPE) + + val response = client.toBlocking().retrieve(request, String::class.java) + + assertThat(response).contains("file-multi") + assertThat(fileCapture.capturedFiles).hasSize(2) + assertThat(fileCapture.capturedFiles!![0].bytes).isEqualTo(file1Content) + assertThat(fileCapture.capturedFiles!![0].filename).isEqualTo("file1.txt") + assertThat(fileCapture.capturedFiles!![0].contentType.get().toString()).isEqualTo(MediaType.TEXT_PLAIN) + assertThat(fileCapture.capturedFiles!![1].bytes).isEqualTo(file2Content) + assertThat(fileCapture.capturedFiles!![1].filename).isEqualTo("file2.pdf") + assertThat(fileCapture.capturedFiles!![1].contentType.get().toString()).isEqualTo(MediaType.APPLICATION_PDF) + assertThat(fileCapture.capturedCategory).isEqualTo("documents") + } +} diff --git a/end2end-tests/micronaut/src/test/kotlin/com/cjbooms/fabrikt/servers/micronaut/MultipartControllerImpl.kt b/end2end-tests/micronaut/src/test/kotlin/com/cjbooms/fabrikt/servers/micronaut/MultipartControllerImpl.kt new file mode 100644 index 00000000..7b094b22 --- /dev/null +++ b/end2end-tests/micronaut/src/test/kotlin/com/cjbooms/fabrikt/servers/micronaut/MultipartControllerImpl.kt @@ -0,0 +1,58 @@ +package com.cjbooms.fabrikt.servers.micronaut + +import com.example.multipart.controllers.FilesUploadController +import com.example.multipart.controllers.FilesUploadMultipleController +import com.example.multipart.models.UploadResponse +import io.micronaut.http.HttpResponse +import io.micronaut.http.annotation.Controller +import io.micronaut.http.multipart.CompletedFileUpload +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@Controller +class FilesUploadControllerImpl( + private val fileCapture: FileCapture +) : FilesUploadController { + override fun uploadFile(file: CompletedFileUpload, description: String?): HttpResponse { + fileCapture.capturedFile = file + fileCapture.capturedDescription = description + return HttpResponse.ok(UploadResponse(id = "file-123")) + } +} + +@Controller +class FilesUploadMultipleControllerImpl( + private val fileCapture: FileCapture +) : FilesUploadMultipleController { + override fun uploadMultipleFiles(files: Publisher, category: String): Mono> { + fileCapture.capturedCategory = category + val collectedFiles = CopyOnWriteArrayList() + + return Flux.from(files) + .doOnNext { file -> collectedFiles.add(file) } + .then(Mono.fromCallable { + fileCapture.capturedFiles = collectedFiles.toList() + HttpResponse.ok(UploadResponse(id = "file-multi")) + }) + } +} + +@Singleton +class FileCapture { + var capturedFile: CompletedFileUpload? = null + var capturedFiles: List? = null + var capturedDescription: String? = null + var capturedCategory: String? = null + + fun reset() { + capturedFile = null + capturedFiles = null + capturedDescription = null + capturedCategory = null + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c4579250..25845bc9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( "end2end-tests:ktor-client-kotlinx", "end2end-tests:ktor-client-jackson", "end2end-tests:spring-http-interface", + "end2end-tests:micronaut", "end2end-tests:models-jackson", "end2end-tests:models-kotlinx", "playground", diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/MicronautControllerInterfaceGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/MicronautControllerInterfaceGenerator.kt index bb80c032..7877c0a2 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/MicronautControllerInterfaceGenerator.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/MicronautControllerInterfaceGenerator.kt @@ -34,6 +34,7 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asClassName class MicronautControllerInterfaceGenerator( private val packages: Packages, @@ -75,10 +76,20 @@ class MicronautControllerInterfaceGenerator( verb: String, ): FunSpec { val methodName = methodName(op, verb, path.pathString.isSingleResource()) - val returnType = MicronautImports.RESPONSE.parameterizedBy(op.happyPathResponse(packages.base)) val parameters = op.toIncomingParameters(packages.base, path.parameters, emptyList()) val globalSecurity = this.api.openApi3.securityRequirements.securitySupport() + // Check if any multipart parameter uses Publisher (array file uploads). + // When accepting Publisher parameters, Micronaut requires a reactive return type (Mono/Flux) + // to avoid blocking the Netty event loop, which would cause deadlocks. + val hasPublisherParam = parameters.any { it is MultipartParameter && it.isFile && it.schema.type == "array" } + val baseReturnType = MicronautImports.RESPONSE.parameterizedBy(op.happyPathResponse(packages.base)) + val returnType = if (hasPublisherParam) { + MicronautImports.MONO.parameterizedBy(baseReturnType) + } else { + baseReturnType + } + // Main method builder val funcSpec = FunSpec .builder(methodName) @@ -106,6 +117,9 @@ class MicronautControllerInterfaceGenerator( .maybeAddAnnotation(validationAnnotations.parameterValid()) .build() + is MultipartParameter -> + it.toMultipartParameterSpec() + is RequestParameter -> it .toParameterSpecBuilder() @@ -234,6 +248,33 @@ class MicronautControllerInterfaceGenerator( } this.addAnnotation(it.build()) } + + private fun MultipartParameter.toMultipartParameterSpec(): ParameterSpec { + // Micronaut requires reactive types (Publisher) for multiple files with same part name. + // This is because Micronaut's Netty-based server processes multipart parts as they arrive, + // and using Publisher allows processing each file as it completes rather than buffering all. + // Note: There are known edge-case bugs with this approach, see: + // https://github.com/micronaut-projects/micronaut-core/issues/9892 + val fileType = if (schema.type == "array") { + MicronautImports.PUBLISHER.parameterizedBy(MicronautImports.COMPLETED_FILE_UPLOAD) + } else { + MicronautImports.COMPLETED_FILE_UPLOAD + } + + val paramType = if (isFile) { + if (isRequired) fileType else fileType.copy(nullable = true) + } else { + type + } + + return ParameterSpec.builder(name, paramType) + .addAnnotation( + AnnotationSpec.builder(MicronautImports.PART) + .addMember("value = %S", oasName) + .build() + ) + .build() + } } data class MicronautControllers( diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Micronaut.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Micronaut.kt index 58bfb28e..68ca282a 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Micronaut.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Micronaut.kt @@ -37,6 +37,10 @@ object MicronautImports { val HEADER = ClassName(Packages.MICRONAUT_HTTP_ANNOTATION, "Header") val QUERY_VALUE = ClassName(Packages.MICRONAUT_HTTP_ANNOTATION, "QueryValue") val PATH_VARIABLE = ClassName(Packages.MICRONAUT_HTTP_ANNOTATION, "PathVariable") + val PART = ClassName(Packages.MICRONAUT_HTTP_ANNOTATION, "Part") + val COMPLETED_FILE_UPLOAD = ClassName("io.micronaut.http.multipart", "CompletedFileUpload") + val PUBLISHER = ClassName("org.reactivestreams", "Publisher") + val MONO = ClassName("reactor.core.publisher", "Mono") object HttpMethods { diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/MicronautControllerGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/MicronautControllerGeneratorTest.kt index 76c28f02..add42465 100644 --- a/src/test/kotlin/com/cjbooms/fabrikt/generators/MicronautControllerGeneratorTest.kt +++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/MicronautControllerGeneratorTest.kt @@ -45,6 +45,7 @@ class MicronautControllerGeneratorTest { "parameterNameClash", "jakartaValidationAnnotations", "modelSuffix", + "multipartFormData", ) private fun setupGithubApiTestEnv(validationAnnotations: ValidationAnnotations = JavaxValidationAnnotations) { diff --git a/src/test/resources/examples/multipartFormData/controllers/micronaut/Controllers.kt b/src/test/resources/examples/multipartFormData/controllers/micronaut/Controllers.kt new file mode 100644 index 00000000..3bbda415 --- /dev/null +++ b/src/test/resources/examples/multipartFormData/controllers/micronaut/Controllers.kt @@ -0,0 +1,51 @@ +package examples.multipartFormData.controllers + +import examples.multipartFormData.models.UploadResponse +import io.micronaut.http.HttpResponse +import io.micronaut.http.`annotation`.Consumes +import io.micronaut.http.`annotation`.Controller +import io.micronaut.http.`annotation`.Part +import io.micronaut.http.`annotation`.Post +import io.micronaut.http.`annotation`.Produces +import io.micronaut.http.multipart.CompletedFileUpload +import io.micronaut.security.rules.SecurityRule +import org.reactivestreams.Publisher +import reactor.core.publisher.Mono +import kotlin.String + +@Controller +public interface FilesUploadController { + /** + * Upload a single file + * + * @param file The file to upload + * @param description Optional description of the file + */ + @Post(uri = "/files/upload") + @Consumes(value = ["multipart/form-data"]) + @Produces(value = ["application/json"]) + public fun uploadFile( + @Part(value = "file") `file`: CompletedFileUpload, + @Part( + value = + "description", + ) description: String?, + ): HttpResponse +} + +@Controller +public interface FilesUploadMultipleController { + /** + * Upload multiple files + * + * @param files The files to upload + * @param category Category for the uploaded files + */ + @Post(uri = "/files/upload-multiple") + @Consumes(value = ["multipart/form-data"]) + @Produces(value = ["application/json"]) + public fun uploadMultipleFiles( + @Part(value = "files") files: Publisher, + @Part(value = "category") category: String, + ): Mono> +} \ No newline at end of file