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
92 changes: 92 additions & 0 deletions end2end-tests/micronaut/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
dependsOn(generateMicronautMultipartCode)
}

withType<Test> {
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<JavaExec> = 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")
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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<UploadResponse> {
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<CompletedFileUpload>, category: String): Mono<HttpResponse<UploadResponse>> {
fileCapture.capturedCategory = category
val collectedFiles = CopyOnWriteArrayList<CompletedFileUpload>()

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<CompletedFileUpload>? = null
var capturedDescription: String? = null
var capturedCategory: String? = null

fun reset() {
capturedFile = null
capturedFiles = null
capturedDescription = null
capturedCategory = null
}
}
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -106,6 +117,9 @@ class MicronautControllerInterfaceGenerator(
.maybeAddAnnotation(validationAnnotations.parameterValid())
.build()

is MultipartParameter ->
it.toMultipartParameterSpec()

is RequestParameter ->
it
.toParameterSpecBuilder()
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class MicronautControllerGeneratorTest {
"parameterNameClash",
"jakartaValidationAnnotations",
"modelSuffix",
"multipartFormData",
)

private fun setupGithubApiTestEnv(validationAnnotations: ValidationAnnotations = JavaxValidationAnnotations) {
Expand Down
Loading