feign icon indicating copy to clipboard operation
feign copied to clipboard

Wrong content-type for multipart requests on JSON parts (text/plain instead of application/json)

Open anessi opened this issue 9 months ago • 2 comments

I have the following request mapping defined which includes 2 parts:

  • metadata: POJO (application/json)
  • files: multipart
    @RequestMapping(
        method = RequestMethod.POST,
        value = "/v1/files/bulk",
        produces = { "application/json" },
        consumes = "multipart/form-data"
    )
    ResponseEntity<Files> saveFiles(
        @Parameter(name = "metadata", description = "Array of file attributes", required = true) @Valid @RequestPart(value = "metadata", required = true) List<@Valid Metadata> metadata,
        @Parameter(name = "files", description = "Array of Files") @RequestPart(value = "files", required = false) List<MultipartFile> files
    );

which produces the following body:

--195903cbf58
Content-Disposition: form-data; name="metadata"
Content-Type: text/plain; charset=UTF-8

[{"fileName":"somefileName","hash":"somehash"}]
--195903cbf58
Content-Disposition: form-data; name="files"; filename=""
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary

--195903cbf58--

It shows that the content type of the JSON part is set to text/plain instead of application/json. This is due to the fact that feign.form.multipart.DelegateWriter#parameterWriter created in feign.form.MultipartFormContentProcessor#MultipartFormContentProcessor is set to feign.form.multipart.SingleParameterWriter which is using a hard-coded content-type of text/plain.

I have noticed that the RequestTemplate that is used to write the JSON data actually has the correct headers set ("Content-Type: application/json"). However, this is ignored as the hard-coded value is used.

I would expect that the content type is taken from the RequestTemplate.

The only workaround that I found is to do a String replace on the body like this:

public class SpringFormEncoderWithContentTypeCorrection extends SpringFormEncoder {

    private static final String CONTENT_TYPE_STRING_TO_REPLACE = "Content-Type: text/plain; charset=";
    private static final String NEW_CONTENT_TYPE_STRING = "Content-Type: application/json; charset=";

    public SpringFormEncoderWithContentTypeCorrection(Encoder delegate) {
        super(delegate);
    }

    @Override
    public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException {
        super.encode(object, bodyType, template);
        if (template.body() != null) {
            // modify content type from 'text/plain' to 'application/json' for json parts
            String modifiedContent = (new String(template.body())).replace(CONTENT_TYPE_STRING_TO_REPLACE, NEW_CONTENT_TYPE_STRING);
            template.body(modifiedContent);
        }
    }
}

Configuration:

    @Bean
    @Primary
    @Scope("prototype")
    public Encoder feignFormEncoder(final ObjectFactory<HttpMessageConverters> messageConverters) {
        return new SpringFormEncoderWithContentTypeCorrection(new SpringEncoder(messageConverters));
    }

Versions used:

  • openfeign: 13.5
  • spring-cloud-openfeign-core: 4.2.0
  • Spring Boot: 3.4.2

anessi avatar Mar 13 '25 16:03 anessi

I found a less invasive workaround by create a custom JsonFormWriter and SpringFormEncoder.

/**
 * Custom JSON form writer which writes everything as Json, except multipart files.
 */
public class CustomJsonFormWriter extends JsonFormWriter {

    private final ObjectMapper objectMapper;
    // These objects are just needed to check if the value is a multipart file
    private final SpringManyMultipartFilesWriter springManyMultipartFilesWriter = new SpringManyMultipartFilesWriter();
    private final SpringSingleMultipartFileWriter springSingleMultipartFileWriter = new SpringSingleMultipartFileWriter();

    public CustomJsonFormWriter(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public boolean isApplicable(Object value) {
        // Required because we can only add writers to the beginning of the list and our writer will be the first. See CustomSpringFormEncoder.
        // The logic is needed because we want to use the objectMapper always, except for multipart files.
        return !springSingleMultipartFileWriter.isApplicable(value) && !springManyMultipartFilesWriter.isApplicable(value);
    }

    @Override
    protected String writeAsString(Object object) throws IOException {
        return objectMapper.writeValueAsString(object);
    }
}
/**
 * Custom Feign form encoder which uses a custom JsonFormWriter to encode the request body as JSON. This is necessary to support JSON request bodies in multipart requests.
 */
public class CustomSpringFormEncoder extends SpringFormEncoder {

    public CustomSpringFormEncoder(Encoder delegate, JsonFormWriter jsonFormWriter) {
        super(delegate);
        MultipartFormContentProcessor processors = (MultipartFormContentProcessor) getContentProcessor(MULTIPART);
        processors.addFirstWriter(jsonFormWriter);
    }
}

Spring Configuration:

    @Bean
    public CustomJsonFormWriter jsonFormWriter(ObjectMapper objectMapper) {
        return new CustomJsonFormWriter(objectMapper);
    }

    @Bean
    @Primary
    @Scope("prototype")
    public Encoder feignFormEncoder(final ObjectFactory<HttpMessageConverters> messageConverters, CustomJsonFormWriter customJsonFormWriter) {
        return new CustomSpringFormEncoder(new SpringEncoder(messageConverters), customJsonFormWriter);
    }

anessi avatar Jun 03 '25 14:06 anessi

nice

coolerpluto avatar Nov 06 '25 09:11 coolerpluto