From b94b3c8dac98bccc79ad69f1444e9ce28820654b Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Mon, 2 Nov 2026 16:25:10 -0800 Subject: [PATCH 1/2] fix(java): serialize Feign Jackson JSON headers --- .../codegen/languages/JavaClientCodegen.java | 17 +++++++ .../Java/libraries/feign/ApiClient.mustache | 51 +++++++++++++++++++ .../Java/libraries/feign/api.mustache | 8 +-- .../codegen/java/JavaClientCodegenTest.java | 19 +++++++ .../java/FeignJacksonHeaderRuntimeTest.java | 50 ++++++++++++++++++ .../3_1/java/feign-jackson-json-header.yaml | 32 ++++++++++++ .../org/openapitools/client/ApiClient.java | 1 + .../org/openapitools/client/ApiClient.java | 1 + .../org/openapitools/client/ApiClient.java | 1 + 9 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java create mode 100644 modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java index c2b1a9aa93b0..2156718fd494 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java @@ -17,7 +17,9 @@ package org.openapitools.codegen.languages; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.servers.Server; import lombok.AccessLevel; @@ -1141,6 +1143,21 @@ private static boolean isMultipartType(List> consumes) { return false; } + @Override + public void processOpenAPI(OpenAPI openAPI) { + super.processOpenAPI(openAPI); + additionalProperties.remove("hasFeignJsonHeader"); + } + + @Override + public CodegenParameter fromParameter(Parameter parameter, Set imports) { + CodegenParameter result = super.fromParameter(parameter, imports); + if (isLibrary(FEIGN) && jackson && result.isHeaderParam && result.headerIsJsonMimeType) { + additionalProperties.put("hasFeignJsonHeader", true); + } + return result; + } + @Override public void postProcessModelProperty(CodegenModel model, CodegenProperty property) { super.postProcessModelProperty(model, property); diff --git a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiClient.mustache b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiClient.mustache index 3e8a99abd201..dd3a856d2842 100644 --- a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiClient.mustache +++ b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiClient.mustache @@ -61,6 +61,53 @@ import feign.Retryer; {{>generatedAnnotation}} public class ApiClient { + {{#hasFeignJsonHeader}} + /** Serializes JSON-content headers with the client's Jackson configuration. */ + public static class JsonHeaderParamExpander implements feign.Param.Expander { + private final ApiClient client; + + public JsonHeaderParamExpander() { + this(new ApiClient()); + } + + private JsonHeaderParamExpander(ApiClient client) { + this.client = client; + } + + @Override + public String expand(Object value) { + try { + return client.objectMapper.writer() + .with(com.fasterxml.jackson.core.JsonGenerator.Feature.ESCAPE_NON_ASCII) + .writeValueAsString(value); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new IllegalArgumentException("Failed to serialize JSON header", e); + } + } + } + + private feign.Contract jsonHeaderContract() { + feign.Contract delegate = new feign.Contract.Default(); + return type -> { + java.util.List methods = delegate.parseAndValidateMetadata(type); + for (feign.MethodMetadata method : methods) { + java.util.Map expanders = new java.util.LinkedHashMap<>(); + if (method.indexToExpander() != null) expanders.putAll(method.indexToExpander()); + java.util.Iterator>> entries = + method.indexToExpanderClass().entrySet().iterator(); + while (entries.hasNext()) { + java.util.Map.Entry> entry = entries.next(); + if (entry.getValue() == JsonHeaderParamExpander.class) { + expanders.put(entry.getKey(), new JsonHeaderParamExpander(this)); + entries.remove(); + } + } + method.indexToExpander(expanders); + } + return methods; + }; + } + {{/hasFeignJsonHeader}} protected static final Logger log = Logger.getLogger(ApiClient.class.getName()); public interface Api {} @@ -89,6 +136,9 @@ public class ApiClient { {{#feign-hc5}} .client(APACHE_HTTP5_CLIENT) {{/feign-hc5}} + {{#hasFeignJsonHeader}} + .contract(jsonHeaderContract()) + {{/hasFeignJsonHeader}} .encoder(new FormEncoder(new JacksonEncoder(objectMapper))) .decoder(new ApiResponseDecoder(objectMapper)) {{#hasOAuthMethods}} @@ -194,6 +244,7 @@ public class ApiClient { {{#jackson}} protected ObjectMapper createObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); {{#failOnUnknownProperties}} diff --git a/modules/openapi-generator/src/main/resources/Java/libraries/feign/api.mustache b/modules/openapi-generator/src/main/resources/Java/libraries/feign/api.mustache index fd660d4c5510..3559c47fac03 100644 --- a/modules/openapi-generator/src/main/resources/Java/libraries/feign/api.mustache +++ b/modules/openapi-generator/src/main/resources/Java/libraries/feign/api.mustache @@ -53,7 +53,7 @@ public interface {{classname}} extends ApiClient.Api { "{{baseName}}: {{=<% %>=}}{<%paramName%>}<%={{ }}=%>"{{^-last}}, {{/-last}}{{/headerParams}} }) - {{#returnType}}{{{.}}} {{/returnType}}{{^returnType}}void {{/returnType}}{{nickname}}({{#allParams}}{{^isBodyParam}}{{^isFormParam}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + {{#returnType}}{{{.}}} {{/returnType}}{{^returnType}}void {{/returnType}}{{nickname}}({{#allParams}}{{^isBodyParam}}{{^isFormParam}}{{#jackson}}{{#headerIsJsonMimeType}}@Param(value="{{paramName}}", expander=ApiClient.JsonHeaderParamExpander.class) {{/headerIsJsonMimeType}}{{/jackson}}{{^headerIsJsonMimeType}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/headerIsJsonMimeType}}{{^jackson}}{{#headerIsJsonMimeType}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/headerIsJsonMimeType}}{{/jackson}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); /** * {{summary}} @@ -83,7 +83,7 @@ public interface {{classname}} extends ApiClient.Api { "{{baseName}}: {{=<% %>=}}{<%paramName%>}<%={{ }}=%>"{{^-last}}, {{/-last}}{{/headerParams}} }) - ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{nickname}}WithHttpInfo({{#allParams}}{{^isBodyParam}}{{^isFormParam}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{nickname}}WithHttpInfo({{#allParams}}{{^isBodyParam}}{{^isFormParam}}{{#jackson}}{{#headerIsJsonMimeType}}@Param(value="{{paramName}}", expander=ApiClient.JsonHeaderParamExpander.class) {{/headerIsJsonMimeType}}{{/jackson}}{{^headerIsJsonMimeType}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/headerIsJsonMimeType}}{{^jackson}}{{#headerIsJsonMimeType}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/headerIsJsonMimeType}}{{/jackson}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); {{#hasQueryParams}} @@ -128,7 +128,7 @@ public interface {{classname}} extends ApiClient.Api { "{{baseName}}: {{=<% %>=}}{<%paramName%>}<%={{ }}=%>"{{^-last}}, {{/-last}}{{/headerParams}} }) - {{#returnType}}{{{.}}} {{/returnType}}{{^returnType}}void {{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{^isBodyParam}}{{^isFormParam}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}@QueryMap {{operationIdCamelCase}}QueryParams queryParams); + {{#returnType}}{{{.}}} {{/returnType}}{{^returnType}}void {{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{^isBodyParam}}{{^isFormParam}}{{#jackson}}{{#headerIsJsonMimeType}}@Param(value="{{paramName}}", expander=ApiClient.JsonHeaderParamExpander.class) {{/headerIsJsonMimeType}}{{/jackson}}{{^headerIsJsonMimeType}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/headerIsJsonMimeType}}{{^jackson}}{{#headerIsJsonMimeType}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/headerIsJsonMimeType}}{{/jackson}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}@QueryMap {{operationIdCamelCase}}QueryParams queryParams); /** * {{summary}} @@ -168,7 +168,7 @@ public interface {{classname}} extends ApiClient.Api { "{{baseName}}: {{=<% %>=}}{<%paramName%>}<%={{ }}=%>"{{^-last}}, {{/-last}}{{/headerParams}} }) - ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{nickname}}WithHttpInfo({{#allParams}}{{^isQueryParam}}{{^isBodyParam}}{{^isFormParam}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}@QueryMap {{operationIdCamelCase}}QueryParams queryParams); + ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{nickname}}WithHttpInfo({{#allParams}}{{^isQueryParam}}{{^isBodyParam}}{{^isFormParam}}{{#jackson}}{{#headerIsJsonMimeType}}@Param(value="{{paramName}}", expander=ApiClient.JsonHeaderParamExpander.class) {{/headerIsJsonMimeType}}{{/jackson}}{{^headerIsJsonMimeType}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/headerIsJsonMimeType}}{{^jackson}}{{#headerIsJsonMimeType}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/headerIsJsonMimeType}}{{/jackson}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}@QueryMap {{operationIdCamelCase}}QueryParams queryParams); /** diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java index 435767b783ec..c8d1ace73f47 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java @@ -81,6 +81,25 @@ public class JavaClientCodegenTest { private static final String JAVA_GENERATOR = "java"; + @Test + public void testFeignJacksonJsonHeader() { + final Path output = newTempFolder(); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName(JAVA_GENERATOR) + .setLibrary(FEIGN) + .addAdditionalProperty("serializationLibrary", "jackson") + .setInputSpec("src/test/resources/3_1/java/feign-jackson-json-header.yaml") + .setOutputDir(output.toString()); + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + validateJavaSourceFiles(files); + Path java = output.resolve("src/main/java/org/openapitools/client"); + assertThat(java.resolve("api/DefaultApi.java")).content() + .contains("@Param(value=\"xJsonArg\", expander=ApiClient.JsonHeaderParamExpander.class)", + "@Param(\"xPlain\")"); + assertThat(java.resolve("ApiClient.java")).content() + .contains("class JsonHeaderParamExpander", "ESCAPE_NON_ASCII", "writeValueAsString(value)"); + } + // This is the kind of information that ideally would be defined and available system-wide @Getter enum Library { diff --git a/modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java b/modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java new file mode 100644 index 000000000000..0b40e7fed9fe --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java @@ -0,0 +1,50 @@ +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Response; +import org.junit.jupiter.api.Test; +import org.openapitools.client.ApiClient; +import org.openapitools.client.api.DefaultApi; +import org.openapitools.client.model.HeaderArg; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; + +class FeignJacksonHeaderRuntimeTest { + @Test + void serializesJsonHeadersAndPreservesPlainHeaders() { + ApiClient client = new ApiClient(); + client.getFeignBuilder().client((request, options) -> { + assertEquals(Collections.singletonList("{\"path\":\"/\\u0442\\u0435\\u0441\\u0442\\uD83D\\uDE00\"}"), + request.headers().get("X-Json-Arg")); + assertEquals(Collections.singletonList("plain"), request.headers().get("X-Plain")); + return Response.builder().request(request).status(204).reason("No Content") + .headers(Collections.emptyMap()) + .build(); + }); + client.buildClient(DefaultApi.class).getMetadata(new HeaderArg().path("/ั‚ะตัั‚๐Ÿ˜€"), "plain"); + } + + @Test + void usesConfiguredMapperForJsonHeaders() { + ApiClient client = new ApiClient(); + ObjectMapper customMapper = client.getObjectMapper().copy(); + com.fasterxml.jackson.databind.module.SimpleModule module = new com.fasterxml.jackson.databind.module.SimpleModule(); + module.addSerializer(HeaderArg.class, new com.fasterxml.jackson.databind.JsonSerializer() { + @Override + public void serialize(HeaderArg value, com.fasterxml.jackson.core.JsonGenerator generator, + com.fasterxml.jackson.databind.SerializerProvider provider) throws java.io.IOException { + generator.writeStartObject(); + generator.writeStringField("custom", value.getPath()); + generator.writeEndObject(); + } + }); + customMapper.registerModule(module); + client.setObjectMapper(customMapper); + client.getFeignBuilder().client((request, options) -> { + assertEquals(Collections.singletonList("{\"custom\":\"/x\"}"), request.headers().get("X-Json-Arg")); + return Response.builder().request(request).status(204).reason("No Content").headers(Collections.emptyMap()) + .build(); + }); + client.buildClient(DefaultApi.class).getMetadata(new HeaderArg().path("/x"), "plain"); + } + +} diff --git a/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml b/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml new file mode 100644 index 000000000000..849fede6d6a5 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Feign JSON header + version: 1.0.0 +paths: + /metadata: + get: + operationId: getMetadata + parameters: + - name: X-Json-Arg + in: header + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/HeaderArg' + - name: X-Plain + in: header + schema: + type: string + responses: + '204': + description: Success +components: + schemas: + HeaderArg: + type: object + properties: + path: + type: string + optionalValue: + type: string diff --git a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiClient.java b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiClient.java index fbfd2932658c..0e04d90cae28 100644 --- a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiClient.java +++ b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiClient.java @@ -140,6 +140,7 @@ public ApiClient setFeignBuilder(Feign.Builder feignBuilder) { protected ObjectMapper createObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); diff --git a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiClient.java b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiClient.java index 48c266c167e7..a45a5d012ccf 100644 --- a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiClient.java +++ b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiClient.java @@ -136,6 +136,7 @@ public ApiClient setFeignBuilder(Feign.Builder feignBuilder) { protected ObjectMapper createObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); diff --git a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiClient.java b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiClient.java index 72c37b24cf1e..cbbae2dcea53 100644 --- a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiClient.java +++ b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiClient.java @@ -140,6 +140,7 @@ public ApiClient setFeignBuilder(Feign.Builder feignBuilder) { protected ObjectMapper createObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); From e66f5fab4256e1c995fcfc72399c356b067f557a Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Mon, 2 Nov 2026 17:25:11 -0800 Subject: [PATCH 2/2] fix(java): address Feign JSON header review --- .../test-java-feign-jackson-headers.yaml | 32 +++++++++ .../codegen/languages/JavaClientCodegen.java | 22 ++++--- .../Java/libraries/feign/ApiClient.mustache | 65 ++++++++++++++++++- .../codegen/java/JavaClientCodegenTest.java | 14 ++++ .../java/FeignJacksonHeaderRuntimeTest.java | 53 +++++++++++++++ .../3_1/java/feign-jackson-json-header.yaml | 16 +++++ .../java/feign-jackson-response-header.yaml | 13 ++++ .../org/openapitools/client/ApiClient.java | 1 - .../org/openapitools/client/ApiClient.java | 1 - .../org/openapitools/client/ApiClient.java | 1 - 10 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/test-java-feign-jackson-headers.yaml create mode 100644 modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-response-header.yaml diff --git a/.github/workflows/test-java-feign-jackson-headers.yaml b/.github/workflows/test-java-feign-jackson-headers.yaml new file mode 100644 index 000000000000..9b1c7d1ef05e --- /dev/null +++ b/.github/workflows/test-java-feign-jackson-headers.yaml @@ -0,0 +1,32 @@ +name: Java Feign Jackson headers +on: + push: + paths: + - 'modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java' + - 'modules/openapi-generator/src/main/resources/Java/**' + - 'modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml' + - 'modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java' + - '.github/workflows/test-java-feign-jackson-headers.yaml' + pull_request: + paths: + - 'modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java' + - 'modules/openapi-generator/src/main/resources/Java/**' + - 'modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml' + - 'modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java' + - '.github/workflows/test-java-feign-jackson-headers.yaml' +jobs: + runtime: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '17' + cache: maven + - run: ./mvnw -B -pl modules/openapi-generator-cli -am -DskipTests package + - run: | + java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -g java --library feign -i modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml -o /tmp/feign-test --additional-properties=serializationLibrary=jackson --global-property=apis,models,supportingFiles,apiTests=false,modelTests=false + mkdir -p /tmp/feign-test/src/test/java + cp modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java /tmp/feign-test/src/test/java/ + mvn -B -f /tmp/feign-test/pom.xml -Dtest=FeignJacksonHeaderRuntimeTest test diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java index 2156718fd494..dc4ed3d3b8aa 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java @@ -19,7 +19,6 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; -import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.servers.Server; import lombok.AccessLevel; @@ -949,6 +948,17 @@ public void processOpts() { public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { super.postProcessOperationsWithModels(objs, allModels); + if (isLibrary(FEIGN) && jackson && objs.getOperations() != null) { + for (CodegenOperation operation : objs.getOperations().getOperation()) { + if (operation.allParams.stream().anyMatch(p -> p.isHeaderParam && p.headerIsJsonMimeType)) { + additionalProperties.put("hasFeignJsonHeader", true); + if (operation.allParams.stream().anyMatch(p -> p.isHeaderParam && p.headerIsJsonMimeType && p.isArray)) { + additionalProperties.put("hasFeignJsonHeaderArrays", true); + } + } + } + } + if (this.getSingleRequestParameter() && (isLibrary(JERSEY2) || isLibrary(JERSEY3) || isLibrary(OKHTTP_GSON) || isLibrary(NATIVE))) { // loop through operations to set x-group-parameters extension to true if useSingleRequestParameter option is enabled OperationMap operations = objs.getOperations(); @@ -1147,15 +1157,7 @@ private static boolean isMultipartType(List> consumes) { public void processOpenAPI(OpenAPI openAPI) { super.processOpenAPI(openAPI); additionalProperties.remove("hasFeignJsonHeader"); - } - - @Override - public CodegenParameter fromParameter(Parameter parameter, Set imports) { - CodegenParameter result = super.fromParameter(parameter, imports); - if (isLibrary(FEIGN) && jackson && result.isHeaderParam && result.headerIsJsonMimeType) { - additionalProperties.put("hasFeignJsonHeader", true); - } - return result; + additionalProperties.remove("hasFeignJsonHeaderArrays"); } @Override diff --git a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiClient.mustache b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiClient.mustache index dd3a856d2842..57523f7b1ffd 100644 --- a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiClient.mustache +++ b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiClient.mustache @@ -67,7 +67,7 @@ public class ApiClient { private final ApiClient client; public JsonHeaderParamExpander() { - this(new ApiClient()); + throw new IllegalStateException("JSON header expander requires a configured ApiClient contract"); } private JsonHeaderParamExpander(ApiClient client) { @@ -77,7 +77,10 @@ public class ApiClient { @Override public String expand(Object value) { try { - return client.objectMapper.writer() + if (value instanceof JsonHeaderIterable) value = ((JsonHeaderIterable) value).value; + return client.objectMapper.copy() + .setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + .writer() .with(com.fasterxml.jackson.core.JsonGenerator.Feature.ESCAPE_NON_ASCII) .writeValueAsString(value); } catch (com.fasterxml.jackson.core.JsonProcessingException e) { @@ -86,6 +89,53 @@ public class ApiClient { } } + private static final class JsonHeaderIterable { + private final Object value; + private JsonHeaderIterable(Object value) { this.value = value; } + } + + {{#hasFeignJsonHeaderArrays}} + private static final class JsonHeaderInvocationHandler implements java.lang.reflect.InvocationHandler { + private final Object target; + private final java.lang.reflect.InvocationHandler delegate; + + private JsonHeaderInvocationHandler(Object target) { + this.target = target; + this.delegate = java.lang.reflect.Proxy.getInvocationHandler(target); + } + + @Override + public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) throws Throwable { + if (method.getDeclaringClass() == Object.class && method.getName().equals("equals")) { + Object other = args[0]; + if (other == proxy) return true; + if (other != null && java.lang.reflect.Proxy.isProxyClass(other.getClass())) { + java.lang.reflect.InvocationHandler otherHandler = java.lang.reflect.Proxy.getInvocationHandler(other); + if (otherHandler instanceof JsonHeaderInvocationHandler) { + return delegate.equals(((JsonHeaderInvocationHandler) otherHandler).delegate); + } + } + return false; + } + if (args != null) { + args = args.clone(); + java.lang.annotation.Annotation[][] annotations = method.getParameterAnnotations(); + for (int i = 0; i < args.length; i++) { + if (!(args[i] instanceof Iterable)) continue; + for (java.lang.annotation.Annotation annotation : annotations[i]) { + if (annotation instanceof feign.Param + && ((feign.Param) annotation).expander() == JsonHeaderParamExpander.class) { + args[i] = new JsonHeaderIterable(args[i]); + break; + } + } + } + } + return delegate.invoke(target, method, args); + } + } + {{/hasFeignJsonHeaderArrays}} + private feign.Contract jsonHeaderContract() { feign.Contract delegate = new feign.Contract.Default(); return type -> { @@ -238,13 +288,15 @@ public class ApiClient { public ApiClient setFeignBuilder(Feign.Builder feignBuilder) { this.feignBuilder = feignBuilder; + {{#hasFeignJsonHeader}} + this.feignBuilder.contract(jsonHeaderContract()); + {{/hasFeignJsonHeader}} return this; } {{#jackson}} protected ObjectMapper createObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); {{#failOnUnknownProperties}} @@ -306,7 +358,14 @@ public class ApiClient { * @return The Client */ public T buildClient(Class clientClass) { + {{#hasFeignJsonHeaderArrays}} + T target = feignBuilder.target(clientClass, basePath); + return clientClass.cast(java.lang.reflect.Proxy.newProxyInstance(clientClass.getClassLoader(), + new Class[] { clientClass }, new JsonHeaderInvocationHandler(target))); + {{/hasFeignJsonHeaderArrays}} + {{^hasFeignJsonHeaderArrays}} return feignBuilder.target(clientClass, basePath); + {{/hasFeignJsonHeaderArrays}} } /** diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java index c8d1ace73f47..522f49f42381 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java @@ -100,6 +100,20 @@ public void testFeignJacksonJsonHeader() { .contains("class JsonHeaderParamExpander", "ESCAPE_NON_ASCII", "writeValueAsString(value)"); } + @Test + public void testFeignJacksonResponseHeaderDoesNotEnableRequestExpander() { + Path output = newTempFolder(); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName(JAVA_GENERATOR) + .setLibrary(FEIGN) + .addAdditionalProperty("serializationLibrary", "jackson") + .setInputSpec("src/test/resources/3_1/java/feign-jackson-response-header.yaml") + .setOutputDir(output.toString()); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + assertThat(output.resolve("src/main/java/org/openapitools/client/ApiClient.java")) + .content().doesNotContain("JsonHeaderParamExpander"); + } + // This is the kind of information that ideally would be defined and available system-wide @Getter enum Library { diff --git a/modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java b/modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java index 0b40e7fed9fe..7f821ebc5026 100644 --- a/modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java +++ b/modules/openapi-generator/src/test/resources/3_1/java/FeignJacksonHeaderRuntimeTest.java @@ -47,4 +47,57 @@ public void serialize(HeaderArg value, com.fasterxml.jackson.core.JsonGenerator client.buildClient(DefaultApi.class).getMetadata(new HeaderArg().path("/x"), "plain"); } + @Test + void serializesJsonArrayAsOneHeader() { + ApiClient client = new ApiClient(); + client.getFeignBuilder().client((request, options) -> { + assertEquals(Collections.singletonList("[{\"path\":\"/a\"},{\"path\":\"/b\"}]"), + request.headers().get("X-Json-Array")); + return Response.builder().request(request).status(204).reason("No Content") + .headers(Collections.emptyMap()).build(); + }); + client.buildClient(DefaultApi.class).getArray(java.util.Arrays.asList(new HeaderArg().path("/a"), new HeaderArg().path("/b"))); + } + + @Test + void replacingBuilderKeepsClientMapper() { + ApiClient client = new ApiClient(); + com.fasterxml.jackson.databind.module.SimpleModule module = new com.fasterxml.jackson.databind.module.SimpleModule(); + module.addSerializer(HeaderArg.class, new com.fasterxml.jackson.databind.JsonSerializer() { + @Override + public void serialize(HeaderArg value, com.fasterxml.jackson.core.JsonGenerator generator, + com.fasterxml.jackson.databind.SerializerProvider provider) throws java.io.IOException { + generator.writeString("custom"); + } + }); + client.getObjectMapper().registerModule(module); + client.setFeignBuilder(feign.Feign.builder().client((request, options) -> { + assertEquals(Collections.singletonList("\"custom\""), request.headers().get("X-Json-Arg")); + return Response.builder().request(request).status(204).reason("No Content").headers(Collections.emptyMap()).build(); + })); + client.buildClient(DefaultApi.class).getMetadata(new HeaderArg().path("/x").optionalValue("v"), "plain"); + } + + @Test + void requestBodyMapperRetainsNullBehavior() throws Exception { + String json = new ApiClient().getObjectMapper().writeValueAsString(new HeaderArg().path("/x")); + assertTrue(json.contains("\"optionalValue\":null")); + } + + @Test + void serializesEmptyJsonArray() { + ApiClient client = new ApiClient(); + client.getFeignBuilder().client((request, options) -> { + assertEquals(Collections.singletonList("[]"), request.headers().get("X-Json-Array")); + return Response.builder().request(request).status(204).reason("No Content") + .headers(Collections.emptyMap()).build(); + }); + DefaultApi api = client.buildClient(DefaultApi.class); + assertEquals(api, api); + DefaultApi other = client.buildClient(DefaultApi.class); + assertEquals(api, other); + assertEquals(api.hashCode(), other.hashCode()); + api.getArray(Collections.emptyList()); + } + } diff --git a/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml b/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml index 849fede6d6a5..e28aa9692965 100644 --- a/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml +++ b/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-json-header.yaml @@ -3,6 +3,22 @@ info: title: Feign JSON header version: 1.0.0 paths: + /array: + get: + operationId: getArray + parameters: + - name: X-Json-Array + in: header + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/HeaderArg' + responses: + '204': + description: Success /metadata: get: operationId: getMetadata diff --git a/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-response-header.yaml b/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-response-header.yaml new file mode 100644 index 000000000000..81c80aefe396 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/java/feign-jackson-response-header.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: {title: Response header, version: '1'} +paths: + /test: + get: + responses: + '204': + description: ok + headers: + X-Json: + content: + application/json: + schema: {type: object} diff --git a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiClient.java b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiClient.java index 0e04d90cae28..fbfd2932658c 100644 --- a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiClient.java +++ b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiClient.java @@ -140,7 +140,6 @@ public ApiClient setFeignBuilder(Feign.Builder feignBuilder) { protected ObjectMapper createObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); diff --git a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiClient.java b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiClient.java index a45a5d012ccf..48c266c167e7 100644 --- a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiClient.java +++ b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiClient.java @@ -136,7 +136,6 @@ public ApiClient setFeignBuilder(Feign.Builder feignBuilder) { protected ObjectMapper createObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); diff --git a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiClient.java b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiClient.java index cbbae2dcea53..72c37b24cf1e 100644 --- a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiClient.java +++ b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiClient.java @@ -140,7 +140,6 @@ public ApiClient setFeignBuilder(Feign.Builder feignBuilder) { protected ObjectMapper createObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);