feat: multipart/form-data and form-urlencoded content type generation#23
feat: multipart/form-data and form-urlencoded content type generation#23halotukozak wants to merge 5 commits intomasterfrom
Conversation
…multipart constants - SpecParser resolves requestBody content type with priority: multipart > form-urlencoded > json - Add MULTIPART_FORM_DATA and FORM_URL_ENCODED constants to SpecParser - Add Ktor Forms & Multipart MemberName/ClassName constants to Names.kt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Test 201 Created with schema returns typed response (not Unit) - Test mixed 200/204 responses uses 200 schema type Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- multipart endpoint generates submitFormWithBinaryData call - ChannelProvider param for binary fields - text fields use simple append - binary fields include ContentDisposition header - existing JSON requestBody still generates setBody pattern Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Branch buildFunctionBody on content type (multipart/form/json) - buildMultipartBody generates submitFormWithBinaryData with formData builder - Binary fields generate ChannelProvider + fileName + contentType params - Text fields use simple append(key, value) pattern - ContentDisposition header with filename for binary parts - Extract buildUrlString, addHeaderParams, addQueryParams helpers - Add HEADERS_CLASS constant to Names.kt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- form-urlencoded endpoint generates submitForm with parameters builder - non-string params use toString() conversion - string params do NOT use toString() - optional fields generate nullable params with null guard Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds multipart/form-data and application/x-www-form-urlencoded support to the OpenAPI → Ktor client generation pipeline, spanning parsing (content-type selection), name constants, client codegen, and tests.
Changes:
- Update
SpecParserrequestBody content-type resolution with priority multipart > form-urlencoded > JSON. - Add Ktor forms/multipart KotlinPoet name constants in
Names.kt. - Extend
ClientGeneratorto generatesubmitFormWithBinaryData/submitFormrequest bodies and corresponding parameters; add tests for the new behaviors.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt | Adds tests for response code typing and basic multipart/form-urlencoded generation behavior. |
| core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt | Chooses requestBody media type by priority and stores the chosen contentType on RequestBody. |
| core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt | Introduces KotlinPoet MemberName/ClassName constants for Ktor forms/multipart APIs. |
| core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt | Implements multipart and form-urlencoded request generation and parameter shaping; refactors URL/header/query helpers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return code.build() | ||
| } | ||
|
|
||
| private fun buildUrlString(endpoint: Endpoint, params: Map<ParameterLocation, List<Parameter>>,): CodeBlock { |
There was a problem hiding this comment.
Trailing comma in the parameter list (params: Map<...>,) violates this repo's ktlint setting with ktlint_standard_trailing-comma-on-declaration-site = disabled and will fail ./gradlew ktlintCheck in CI. Remove the trailing comma from the function signature.
| private fun buildUrlString(endpoint: Endpoint, params: Map<ParameterLocation, List<Parameter>>,): CodeBlock { | |
| private fun buildUrlString(endpoint: Endpoint, params: Map<ParameterLocation, List<Parameter>>): CodeBlock { |
| @Test | ||
| fun `form-urlencoded endpoint generates submitForm call`() { | ||
| val ep = endpoint( | ||
| method = HttpMethod.POST, | ||
| operationId = "createUser", | ||
| requestBody = RequestBody( | ||
| required = true, | ||
| contentType = "application/x-www-form-urlencoded", | ||
| schema = TypeRef.Inline( | ||
| properties = listOf( | ||
| PropertyModel("username", TypeRef.Primitive(PrimitiveType.STRING), null, false), | ||
| PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), | ||
| ), | ||
| requiredProperties = setOf("username", "age"), | ||
| contextHint = "request", | ||
| ), | ||
| ), | ||
| ) | ||
| val cls = clientClass(listOf(ep)) | ||
| val funSpec = cls.funSpecs.first { it.name == "createUser" } | ||
| val body = funSpec.body.toString() | ||
| assertTrue(body.contains("submitForm"), "Expected submitForm call") | ||
| assertTrue(body.contains("parameters"), "Expected parameters builder") | ||
|
|
||
| val paramTypes = funSpec.parameters.associate { it.name to it.type.toString() } | ||
| assertEquals("kotlin.String", paramTypes["username"]) | ||
| assertEquals("kotlin.Int", paramTypes["age"]) | ||
| } |
There was a problem hiding this comment.
The new form/multipart tests only cover inline request body schemas. Since OpenAPI request bodies are often $referenced, add coverage for RequestBody(schema = TypeRef.Reference(...)) to ensure multipart/form-urlencoded generation still produces parameters and form parts (or fails with a clear error). This would catch the current behavior where non-inline schemas result in an empty parameter list/body.
| code.endControlFlow() // formData | ||
| code.add(")\n") | ||
| code.beginControlFlow("") | ||
| code.addStatement("$APPLY_AUTH()") | ||
| addHeaderParams(code, params) | ||
| addQueryParams(code, params) | ||
| code.endControlFlow() | ||
| code.unindent() |
There was a problem hiding this comment.
buildMultipartBody uses code.add(")\n") followed by code.beginControlFlow(""), which will generate a standalone block on the next line instead of a trailing lambda () { ... }) for submitFormWithBinaryData. That output is not valid Kotlin and will break generated clients. Rework the CodeBlock construction so the request-builder lambda is attached directly to the submitFormWithBinaryData(...) call (no newline/statement separator between ) and {).
| code.endControlFlow() // parameters | ||
| code.add(")\n") | ||
| code.beginControlFlow("") | ||
| code.addStatement("$APPLY_AUTH()") | ||
| addHeaderParams(code, params) | ||
| addQueryParams(code, params) | ||
|
|
||
| if (endpoint.method != HttpMethod.POST) { | ||
| code.addStatement("method = %T.%L", HTTP_METHOD_CLASS, endpoint.method.name.toPascalCase()) | ||
| } | ||
|
|
||
| code.endControlFlow() | ||
| code.unindent() | ||
| code.add("}.%M()\n", resultFun) |
There was a problem hiding this comment.
Same issue in buildFormUrlEncodedBody: code.add(")\n") then code.beginControlFlow("") will emit a separate block statement rather than the trailing lambda for submitForm(...). This will produce invalid Kotlin in the generated client. Build the code so the { ... } block is syntactically part of the submitForm(...) call (e.g., ) { on the same statement).
| private fun extractInlineProperties(requestBody: RequestBody): List<PropertyModel> = | ||
| when (val schema = requestBody.schema) { | ||
| is TypeRef.Inline -> schema.properties | ||
| else -> emptyList() | ||
| } | ||
|
|
||
| private fun extractRequiredProperties(requestBody: RequestBody): Set<String> = | ||
| when (val schema = requestBody.schema) { | ||
| is TypeRef.Inline -> schema.requiredProperties | ||
| else -> emptySet() | ||
| } |
There was a problem hiding this comment.
extractInlineProperties/extractRequiredProperties return empty for any non-TypeRef.Inline request body schema. For multipart/form-url-encoded request bodies defined via $ref (common in OpenAPI), SpecParser will produce TypeRef.Reference(...), so this generator will emit no parameters and an empty form body. Consider resolving TypeRef.Reference to its SchemaModel (via the ApiSpec passed into generate) or having the parser inline referenced object schemas specifically for form content types.
| private fun buildMultipartParameters(requestBody: RequestBody): List<ParameterSpec> { | ||
| val properties = extractInlineProperties(requestBody) | ||
| return properties.flatMap { prop -> | ||
| if (prop.type.isBinaryUpload()) { | ||
| listOf( | ||
| ParameterSpec(prop.name.toCamelCase(), CHANNEL_PROVIDER), | ||
| ParameterSpec("${prop.name.toCamelCase()}Name", STRING), | ||
| ParameterSpec("${prop.name.toCamelCase()}ContentType", CONTENT_TYPE_CLASS), | ||
| ) | ||
| } else { | ||
| listOf( | ||
| ParameterSpec( | ||
| prop.name.toCamelCase(), | ||
| TypeMapping.toTypeName(prop.type, modelPackage), | ||
| ), | ||
| ) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
buildMultipartParameters ignores required/optional information from the inline schema (e.g., requiredProperties / PropertyModel.nullable). As a result, optional multipart fields (including optional file uploads) would still be generated as non-null function parameters, forcing callers to always provide values. Use the schema's required set to make parameters nullable with defaults where appropriate, and align the body generation to conditionally append only when non-null.
| for (prop in properties) { | ||
| val paramName = prop.name.toCamelCase() | ||
| if (prop.type.isBinaryUpload()) { | ||
| code.beginControlFlow( | ||
| "append(%S, %L, %T.build", | ||
| prop.name, | ||
| paramName, | ||
| HEADERS_CLASS, | ||
| ) | ||
| code.addStatement( | ||
| "append(%T.ContentType, %L.toString())", | ||
| HTTP_HEADERS_OBJECT, | ||
| "${paramName}ContentType", | ||
| ) | ||
| code.addStatement( | ||
| "append(%T.ContentDisposition, %P)", | ||
| HTTP_HEADERS_OBJECT, | ||
| CodeBlock.of("filename=\"\${%L}\"", "${paramName}Name"), | ||
| ) | ||
| code.endControlFlow() | ||
| code.add(")\n") | ||
| } else { | ||
| code.addStatement("append(%S, %L)", prop.name, paramName) | ||
| } |
There was a problem hiding this comment.
buildMultipartBody appends every multipart part unconditionally. If any multipart property is optional (nullable), the generated code will still try to append it, which either forces non-null parameters or risks appending invalid values. Mirror the optionalGuard approach used for form-urlencoded so optional parts are only appended when present.
| code.beginControlFlow("") | ||
| code.addStatement("$APPLY_AUTH()") | ||
| addHeaderParams(code, params) | ||
| addQueryParams(code, params) |
There was a problem hiding this comment.
buildMultipartBody always uses submitFormWithBinaryData without overriding the HTTP method. submitFormWithBinaryData defaults to POST, so multipart endpoints declared as PUT/PATCH/DELETE would be generated incorrectly. Add the same non-POST method override logic you already have in buildFormUrlEncodedBody (or pass method = ... to the submit call).
| addQueryParams(code, params) | |
| addQueryParams(code, params) | |
| if (endpoint.method != HttpMethod.POST) { | |
| code.addStatement("method = %T.%L", HTTP_METHOD_CLASS, endpoint.method.name.toPascalCase()) | |
| } |
Summary
SpecParserto resolve content types with priority: multipart > form-urlencoded > JSONNames.ktbuildMultipartBodyinClientGenerator:submitFormWithBinaryData+formData {}builder,ChannelProviderfor file params,ContentDispositionheadersbuildFormUrlEncodedBody:submitForm+parameters {}builder, individual typed params withtoString()conversionTest plan
🤖 Generated with Claude Code