From a3d4c8c77ab9f3be0de9ebf224e54046520e8fce Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Thu, 29 Oct 2026 09:27:08 +0000 Subject: [PATCH] [kotlin][jvm-retrofit2] Use @HTTP with hasBody for DELETE operations with a request body Retrofit rejects @Body, @FormUrlEncoded and @Multipart on @DELETE, so a DELETE operation with a requestBody failed on its first call. Emit @HTTP(method, path, hasBody = true) only when the method does not allow a body and the operation has body or form params. Other operations keep the short annotation, so existing output does not change. Fixes #25454 --- .../libraries/jvm-retrofit2/api.mustache | 10 +++ .../kotlin/KotlinClientCodegenApiTest.java | 23 +++++++ .../kotlin/issue25454-delete-with-body.yaml | 64 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_0/kotlin/issue25454-delete-with-body.yaml diff --git a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-retrofit2/api.mustache b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-retrofit2/api.mustache index 9cd7ef3fea02..66c78f29844f 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-retrofit2/api.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-retrofit2/api.mustache @@ -155,7 +155,17 @@ import okhttp3.MultipartBody {{/-first}} {{/prioritizedContentTypes}} {{/formParams}} + {{^isBodyAllowed}} + {{#hasBodyOrFormParams}} + @HTTP(method = "{{httpMethod}}", path = "{{{path}}}", hasBody = true) + {{/hasBodyOrFormParams}} + {{^hasBodyOrFormParams}} @{{httpMethod}}("{{{path}}}") + {{/hasBodyOrFormParams}} + {{/isBodyAllowed}} + {{#isBodyAllowed}} + @{{httpMethod}}("{{{path}}}") + {{/isBodyAllowed}} {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}{{^doNotUseRxAndCoroutines}}{{#useCoroutines}}suspend {{/useCoroutines}}{{/doNotUseRxAndCoroutines}}fun {{operationId}}({{^allParams}}){{/allParams}}{{#allParams}}{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>bodyParams}}{{>formParams}}{{^-last}}, {{/-last}}{{#-last}}){{/-last}}{{/allParams}}{{#returnType}}: {{/returnType}}{{^returnType}}{{#useResponseAsReturnType}}: {{/useResponseAsReturnType}}{{/returnType}}{{^doNotUseRxAndCoroutines}}{{#useRxJava}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{^returnType}}Unit{{/returnType}}{{/isResponseFile}}>{{/useRxJava}}{{#useRxJava2}}{{#returnType}}Single<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Single<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{#useCoroutines}}{{#useResponseAsReturnType}}Response<{{/useResponseAsReturnType}}{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{^returnType}}{{#useResponseAsReturnType}}Unit{{/useResponseAsReturnType}}{{/returnType}}{{/isResponseFile}}{{#useResponseAsReturnType}}>{{/useResponseAsReturnType}}{{/useCoroutines}}{{/doNotUseRxAndCoroutines}}{{#doNotUseRxAndCoroutines}}Call<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{^returnType}}Unit{{/returnType}}{{/isResponseFile}}>{{/doNotUseRxAndCoroutines}} {{/operation}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java index da4faa356004..51208d4130e8 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java @@ -103,6 +103,29 @@ public void testUseResponseAsReturnType(Object useResponseAsReturnType, String e assertFileContainsLine(lines, "suspend fun deletePet(@Path(\"petId\") petId: kotlin.Long, @Header(\"api_key\") apiKey: kotlin.String? = null)" + expectedUnitResponse); } + @Test + public void testRetrofit2DeleteWithBodyUsesHttpAnnotation_25454() throws IOException { + OpenAPI openAPI = readOpenAPI("src/test/resources/3_0/kotlin/issue25454-delete-with-body.yaml"); + + KotlinClientCodegen codegen = createCodegen(ClientLibrary.JVM_RETROFIT2); + + ClientOptInput input = createClientOptInput(openAPI, codegen); + + DefaultGenerator generator = new DefaultGenerator(); + enableOnlyApiGeneration(generator); + + List files = generator.opts(input).generate(); + File defaultApi = files.stream().filter(file -> file.getName().equals("DefaultApi.kt")).findAny().orElseThrow(); + List lines = Files.readAllLines(defaultApi.toPath()).stream().map(String::trim).collect(Collectors.toList()); + + // Retrofit rejects @Body, @FormUrlEncoded and @Multipart on @DELETE, so these need @HTTP with hasBody + assertFileContainsLine(lines, "@HTTP(method = \"DELETE\", path = \"items\", hasBody = true)"); + assertFileContainsLine(lines, "@HTTP(method = \"DELETE\", path = \"items/form\", hasBody = true)"); + // operations without a body, and methods that allow a body, keep the short annotation + assertFileContainsLine(lines, "@DELETE(\"items/{id}\")"); + assertFileContainsLine(lines, "@POST(\"items\")"); + } + @Test public void testOptionalParamsHaveDefaultNullJvmKtor() throws IOException { OpenAPI openAPI = readOpenAPI("3_0/kotlin/petstore.yaml"); diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/issue25454-delete-with-body.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/issue25454-delete-with-body.yaml new file mode 100644 index 000000000000..7b83206e856f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/issue25454-delete-with-body.yaml @@ -0,0 +1,64 @@ +openapi: 3.0.3 +info: + title: Repro + version: 1.0.0 +paths: + /items: + delete: + operationId: deleteItems + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteItemsRequest" + responses: + "204": + description: Deleted + post: + operationId: createItems + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteItemsRequest" + responses: + "204": + description: Created + /items/form: + delete: + operationId: deleteItemsForm + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + id: + type: string + responses: + "204": + description: Deleted + /items/{id}: + delete: + operationId: deleteItem + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: Deleted +components: + schemas: + DeleteItemsRequest: + type: object + required: [ids] + properties: + ids: + type: array + items: + type: string