Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.quati.ogen.client.ktor

public class FileUpload(
public val fileName: String,
public val content: ByteArray,
public val contentType: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ internal object Poet {
val prepareRequest = ClassName("io.ktor.client.request", "prepareRequest")
val httpRequestBuilder = ClassName("io.ktor.client.request", "HttpRequestBuilder")
val setBody = ClassName("io.ktor.client.request", "setBody")
val multiPartFormDataContent =
ClassName("io.ktor.client.request.forms", "MultiPartFormDataContent")
val formData = ClassName("io.ktor.client.request.forms", "formData")
}

object Call {
Expand All @@ -62,6 +65,8 @@ internal object Poet {

val url = ClassName("io.ktor.http", "Url")
val httpMethod = ClassName("io.ktor.http", "HttpMethod")
val headers = ClassName("io.ktor.http", "Headers")
val httpHeaders = ClassName("io.ktor.http", "HttpHeaders")
val contentType = ClassName("io.ktor.http", "ContentType")
fun contentTypeCodeBlock(type: String) = when (type) {
"application/json" -> CodeBlock.of("%T.Application.Json", contentType)
Expand Down Expand Up @@ -103,6 +108,7 @@ internal object Poet {
private val packageName get() = PackageName("de.quati.ogen.client.ktor")

val httpResponseTyped = packageName.className("HttpResponseTyped")
val fileUpload = packageName.className("FileUpload")
val httpClientOgen = packageName.className("HttpClientOgen")
val bodyAsFlow = packageName.className("bodyAsFlow")
val toTyped = (packageName + "HttpResponseTyped" + "Companion").className("toTyped")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import de.quati.kotlin.util.poet.NameConflictResolver
import de.quati.ogen.plugin.intern.DirectorySyncService
import de.quati.ogen.plugin.intern.codegen.CodeGenContext
import de.quati.ogen.plugin.intern.codegen.Poet
import de.quati.ogen.plugin.intern.codegen.getTypeName
import de.quati.ogen.plugin.intern.codegen.toParameterMapCodeBlock
import de.quati.ogen.plugin.intern.model.ContentType
import de.quati.ogen.plugin.intern.model.Endpoint
Expand All @@ -49,6 +50,27 @@ private data class RequestBodyInfo(
val body: Endpoint.RequestBodyResolved,
)

private data class PartInfo(
val name: String,
val prettyName: String,
val typeName: TypeName,
val required: Boolean,
val file: Endpoint.Part.File,
)

context(_: CodeGenContext)
private fun Endpoint.Part.toPartInfo(reservedNames: Set<String>) = PartInfo(
name = name,
prettyName = prettyName.makeDifferent(reservedNames),
typeName = when (file) {
Endpoint.Part.File.ONE -> Poet.Lib.Client.Ktor.fileUpload
Endpoint.Part.File.MANY -> List::class.asClassName().parameterizedBy(Poet.Lib.Client.Ktor.fileUpload)
Endpoint.Part.File.NONE -> schema.getTypeName(withFlow = false).poet
},
required = required,
file = file,
)

private val streamContentTypes = setOf("application/x-ndjson")

private data class ResponseBodyInfo(
Expand Down Expand Up @@ -155,7 +177,12 @@ private fun TypeSpec.Builder.addEndpoint(
) else null
info to streamInfo
}
val requestBodyInfo = endpoint.requestBodyResolved?.let { body ->
val requestBodyParts = run {
val parts = endpoint.requestBodyResolved?.parts ?: emptyList()
val taken = reservedNames.toMutableSet()
parts.map { part -> part.toPartInfo(taken).also { taken += it.prettyName } }
}
val requestBodyInfo = endpoint.requestBodyResolved?.takeIf { requestBodyParts.isEmpty() }?.let { body ->
val name = body.prettyBodyName.makeDifferent(reservedNames)
val type = when (body.contentType) {
null, is ContentType.Unknown, is ContentType.Multipart -> null
Expand All @@ -168,7 +195,7 @@ private fun TypeSpec.Builder.addEndpoint(
typeInfoName = "bodyType".takeIf { type == null }?.makeDifferent(reservedNames),
)
}
val paramNames = parameters.map { it.prettyName } + listOfNotNull(
val paramNames = parameters.map { it.prettyName } + requestBodyParts.map { it.prettyName } + listOfNotNull(
requestBodyInfo?.name,
requestBodyInfo?.typeInfoName,
blockName,
Expand All @@ -184,6 +211,14 @@ private fun TypeSpec.Builder.addEndpoint(
if (param.nullable) defaultValue("null")
}
}
requestBodyParts.forEach { part ->
addParameter(
name = part.prettyName,
type = part.typeName.copy(nullable = !part.required),
) {
if (!part.required) defaultValue("null")
}
}
requestBodyInfo?.also { bodyInfo ->
addParameter(
name = bodyInfo.name,
Expand Down Expand Up @@ -275,6 +310,8 @@ private fun TypeSpec.Builder.addEndpoint(
addParam(param)
}
addStatement("%L?.also { this.%T(it) }", acceptParamName, Poet.Ktor.Request.accept)
if (requestBodyParts.isNotEmpty())
addMultipartRequestBody(requestBodyParts)
if (requestBodyInfo != null)
addRequestBody(requestBodyInfo)
addStatement("this@$controllerName.client.baseModifier(this)")
Expand Down Expand Up @@ -316,6 +353,56 @@ private fun CodeBlock.Builder.addPath(
add(").also(this::%T)\n", Poet.Ktor.Request.url)
}

context(_: CodeGenContext)
private fun CodeBlock.Builder.addMultipartRequestBody(parts: List<PartInfo>) {
addStatement(
"this.%T(%T(%T {",
Poet.Ktor.Request.setBody,
Poet.Ktor.Request.multiPartFormDataContent,
Poet.Ktor.Request.formData,
)
indent {
parts.forEach { part -> addPart(part) }
}
addStatement("}))")
}

context(_: CodeGenContext)
private fun CodeBlock.Builder.addPart(part: PartInfo) {
val access = part.prettyName
when (part.file) {
Endpoint.Part.File.NONE -> {
if (!part.required) add("if (%L != null) ", part.prettyName)
val isString = part.typeName.copy(nullable = false) == String::class.asClassName()
addStatement("append(%S, %L%L)", part.name, access, if (isString) "" else ".toString()")
}

Endpoint.Part.File.ONE -> {
if (!part.required) add("if (%L != null) ", part.prettyName)
addFileAppend(wireName = part.name, access = access)
}

Endpoint.Part.File.MANY -> {
addStatement("%L%L.forEach { file ->", part.prettyName, if (part.required) "" else "?")
indent { addFileAppend(wireName = part.name, access = "file") }
addStatement("}")
}
}
}

private fun CodeBlock.Builder.addFileAppend(wireName: String, access: String) {
addStatement("append(%S, %L.content, %T.build {", wireName, access, Poet.Ktor.headers)
indent {
addStatement(
"append(%T.ContentDisposition, %P)",
Poet.Ktor.httpHeaders,
"filename=\"\${$access.fileName}\"",
)
addStatement("%L.contentType?.also { append(%T.ContentType, it) }", access, Poet.Ktor.httpHeaders)
}
addStatement("})")
}

private fun CodeBlock.Builder.addRequestBody(info: RequestBodyInfo) {
val contentType = info.body.contentType?.values?.firstOrNull()
if (contentType != null)
Expand Down
34 changes: 34 additions & 0 deletions tests/server-client/src/test/kotlin/KtorClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import de.quati.ogen.gen.model.LocaleDto
import de.quati.ogen.gen.model.TenantIdDto
import de.quati.ogen.gen.model.UserCreateDto
import de.quati.ogen.gen.model.UserDto
import de.quati.ogen.gen.model.UserFileDto
import de.quati.ogen.gen.model.UserUpdateDto
import de.quati.ogen.client.ktor.FileUpload
import de.quati.ogen.client.ktor.HttpClientOgen
import de.quati.ogen.client.ktor.getOgenAuthNotes
import io.kotest.matchers.shouldBe
Expand Down Expand Up @@ -193,6 +195,38 @@ class KtorClientTest {
)
}

@Test
fun `test uploadUserFile`(): TestResult = runTest {
val userId = UserId(Uuid.parse("75897dbc-8dea-4d14-82c6-dd0ee2243cb3"))

clientUser.usersApi.uploadUserFile(
userId = userId,
file = FileUpload(fileName = "notes.txt", content = "file content".encodeToByteArray()),
name = "report",
description = "q3",
).check(
expectedInput = "testUser|$userId|notes.txt|report|q3|file content",
expectedBody = BodyData(
status = 201,
type = "application/json",
content = UserFileDto(fileId = "notes.txt", name = "report", size = 12),
)
)

clientUser.usersApi.uploadUserFile(
userId = userId,
file = FileUpload(fileName = "notes.txt", content = "file content".encodeToByteArray()),
name = "report",
).check(
expectedInput = "testUser|$userId|notes.txt|report|null|file content",
expectedBody = BodyData(
status = 201,
type = "application/json",
content = UserFileDto(fileId = "notes.txt", name = "report", size = 12),
)
)
}

@Test
fun `test getUser`(): TestResult = runTest {
clientUser.usersApi.getUser(UserId("75897dbc-8dea-4d14-82c6-dd0ee2243cb3")).check(
Expand Down
Loading