From b39aad0d5620659aa8364d591f61b0a5cd0c2532 Mon Sep 17 00:00:00 2001 From: cziberpv Date: Wed, 11 Feb 2026 09:55:00 +0100 Subject: [PATCH] fix(java/feign): handle binary response types in ApiResponseDecoder (#22939) * fix(java/feign): handle binary response types in ApiResponseDecoder The Feign library's ApiResponseDecoder routes all responses through JacksonDecoder, including binary ones (File, byte[], InputStream). This causes JsonParseException when an endpoint returns non-JSON content (e.g. PDF, ZIP, images). Add binary type detection and handling before delegating to JacksonDecoder. This applies to both direct return types and ApiResponse wrappers. Consistent with the native library fix in #21346. Closes #2486 Co-Authored-By: Claude Opus 4.6 * fix: address code review feedback - Sanitize Content-Disposition filename to prevent path traversal (Paths.get(filename).getFileName() strips directory components) - Add null check for response.body() to handle 204/205 empty responses - Fix regex to support quoted filenames with spaces (e.g. filename="my invoice.pdf") * fix: regenerate feign-hc5 sample with updated ApiResponseDecoder The feign-hc5 sample was missed during the second commit's regeneration because setTemplateDir("feign") overrides the filesystem templateDir from the config, causing the generator to use embedded JAR resources. After rebuilding the JAR with the updated mustache template, the feign-hc5 sample now matches feign and feign-no-nullable. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../feign/ApiResponseDecoder.mustache | 78 +++++++++++++++++-- .../client/ApiResponseDecoder.java | 78 +++++++++++++++++-- .../client/ApiResponseDecoder.java | 78 +++++++++++++++++-- .../client/ApiResponseDecoder.java | 78 +++++++++++++++++-- 4 files changed, 288 insertions(+), 24 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache index 9062b648ca18..7eaf68cea854 100644 --- a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache +++ b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache @@ -7,33 +7,99 @@ import feign.Response; import feign.Types; import feign.jackson.JacksonDecoder; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import {{modelPackage}}.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { + private static final Pattern FILENAME_PATTERN = + Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)"); + public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); } @Override public Object decode(Response response, Type type) throws IOException { - //Detects if the type is an instance of the parameterized class ApiResponse if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) { - //The ApiResponse class has a single type parameter, the Dto class itself Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0]; - Object body = super.decode(response, responseBodyType); + Object body = isBinaryType(responseBodyType) + ? decodeBinary(response, responseBodyType) + : super.decode(response, responseBodyType); Map> responseHeaders = Collections.unmodifiableMap(response.headers()); return new ApiResponse<>(response.status(), responseHeaders, body); - } else { - //The response is not encapsulated in the ApiResponse, decode the Dto as normal - return super.decode(response, type); } + + if (isBinaryType(type)) { + return decodeBinary(response, type); + } + + return super.decode(response, type); + } + + private boolean isBinaryType(Type type) { + Class raw = Types.getRawType(type); + return File.class.isAssignableFrom(raw) + || byte[].class.isAssignableFrom(raw) + || InputStream.class.isAssignableFrom(raw); + } + + private Object decodeBinary(Response response, Type type) throws IOException { + Class raw = Types.getRawType(type); + if (response.body() == null) { + return null; + } + if (byte[].class.isAssignableFrom(raw)) { + return response.body().asInputStream().readAllBytes(); + } + if (InputStream.class.isAssignableFrom(raw)) { + return response.body().asInputStream(); + } + return downloadToTempFile(response); + } + + private File downloadToTempFile(Response response) throws IOException { + String filename = extractFilename(response); + File file; + if (filename != null) { + // Sanitize: strip path components to prevent path traversal + String safeName = Paths.get(filename).getFileName().toString(); + java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); + file = Files.createFile(tempDir.resolve(safeName)).toFile(); + tempDir.toFile().deleteOnExit(); + } else { + file = Files.createTempFile("download-", "").toFile(); + } + file.deleteOnExit(); + try (InputStream is = response.body().asInputStream()) { + Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return file; + } + + private String extractFilename(Response response) { + Collection dispositions = response.headers().get("Content-Disposition"); + if (dispositions == null) return null; + for (String disposition : dispositions) { + Matcher m = FILENAME_PATTERN.matcher(disposition); + if (m.find()) { + // Group 1: quoted filename (may contain spaces), Group 2: unquoted token + return m.group(1) != null ? m.group(1) : m.group(2); + } + } + return null; } } diff --git a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 52ca0850b144..ed75d1731f3d 100644 --- a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -18,33 +18,99 @@ import feign.Response; import feign.Types; import feign.jackson.JacksonDecoder; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.openapitools.client.model.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { + private static final Pattern FILENAME_PATTERN = + Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)"); + public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); } @Override public Object decode(Response response, Type type) throws IOException { - //Detects if the type is an instance of the parameterized class ApiResponse if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) { - //The ApiResponse class has a single type parameter, the Dto class itself Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0]; - Object body = super.decode(response, responseBodyType); + Object body = isBinaryType(responseBodyType) + ? decodeBinary(response, responseBodyType) + : super.decode(response, responseBodyType); Map> responseHeaders = Collections.unmodifiableMap(response.headers()); return new ApiResponse<>(response.status(), responseHeaders, body); - } else { - //The response is not encapsulated in the ApiResponse, decode the Dto as normal - return super.decode(response, type); } + + if (isBinaryType(type)) { + return decodeBinary(response, type); + } + + return super.decode(response, type); + } + + private boolean isBinaryType(Type type) { + Class raw = Types.getRawType(type); + return File.class.isAssignableFrom(raw) + || byte[].class.isAssignableFrom(raw) + || InputStream.class.isAssignableFrom(raw); + } + + private Object decodeBinary(Response response, Type type) throws IOException { + Class raw = Types.getRawType(type); + if (response.body() == null) { + return null; + } + if (byte[].class.isAssignableFrom(raw)) { + return response.body().asInputStream().readAllBytes(); + } + if (InputStream.class.isAssignableFrom(raw)) { + return response.body().asInputStream(); + } + return downloadToTempFile(response); + } + + private File downloadToTempFile(Response response) throws IOException { + String filename = extractFilename(response); + File file; + if (filename != null) { + // Sanitize: strip path components to prevent path traversal + String safeName = Paths.get(filename).getFileName().toString(); + java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); + file = Files.createFile(tempDir.resolve(safeName)).toFile(); + tempDir.toFile().deleteOnExit(); + } else { + file = Files.createTempFile("download-", "").toFile(); + } + file.deleteOnExit(); + try (InputStream is = response.body().asInputStream()) { + Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return file; + } + + private String extractFilename(Response response) { + Collection dispositions = response.headers().get("Content-Disposition"); + if (dispositions == null) return null; + for (String disposition : dispositions) { + Matcher m = FILENAME_PATTERN.matcher(disposition); + if (m.find()) { + // Group 1: quoted filename (may contain spaces), Group 2: unquoted token + return m.group(1) != null ? m.group(1) : m.group(2); + } + } + return null; } } diff --git a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 52ca0850b144..ed75d1731f3d 100644 --- a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -18,33 +18,99 @@ import feign.Response; import feign.Types; import feign.jackson.JacksonDecoder; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.openapitools.client.model.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { + private static final Pattern FILENAME_PATTERN = + Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)"); + public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); } @Override public Object decode(Response response, Type type) throws IOException { - //Detects if the type is an instance of the parameterized class ApiResponse if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) { - //The ApiResponse class has a single type parameter, the Dto class itself Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0]; - Object body = super.decode(response, responseBodyType); + Object body = isBinaryType(responseBodyType) + ? decodeBinary(response, responseBodyType) + : super.decode(response, responseBodyType); Map> responseHeaders = Collections.unmodifiableMap(response.headers()); return new ApiResponse<>(response.status(), responseHeaders, body); - } else { - //The response is not encapsulated in the ApiResponse, decode the Dto as normal - return super.decode(response, type); } + + if (isBinaryType(type)) { + return decodeBinary(response, type); + } + + return super.decode(response, type); + } + + private boolean isBinaryType(Type type) { + Class raw = Types.getRawType(type); + return File.class.isAssignableFrom(raw) + || byte[].class.isAssignableFrom(raw) + || InputStream.class.isAssignableFrom(raw); + } + + private Object decodeBinary(Response response, Type type) throws IOException { + Class raw = Types.getRawType(type); + if (response.body() == null) { + return null; + } + if (byte[].class.isAssignableFrom(raw)) { + return response.body().asInputStream().readAllBytes(); + } + if (InputStream.class.isAssignableFrom(raw)) { + return response.body().asInputStream(); + } + return downloadToTempFile(response); + } + + private File downloadToTempFile(Response response) throws IOException { + String filename = extractFilename(response); + File file; + if (filename != null) { + // Sanitize: strip path components to prevent path traversal + String safeName = Paths.get(filename).getFileName().toString(); + java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); + file = Files.createFile(tempDir.resolve(safeName)).toFile(); + tempDir.toFile().deleteOnExit(); + } else { + file = Files.createTempFile("download-", "").toFile(); + } + file.deleteOnExit(); + try (InputStream is = response.body().asInputStream()) { + Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return file; + } + + private String extractFilename(Response response) { + Collection dispositions = response.headers().get("Content-Disposition"); + if (dispositions == null) return null; + for (String disposition : dispositions) { + Matcher m = FILENAME_PATTERN.matcher(disposition); + if (m.find()) { + // Group 1: quoted filename (may contain spaces), Group 2: unquoted token + return m.group(1) != null ? m.group(1) : m.group(2); + } + } + return null; } } diff --git a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 52ca0850b144..ed75d1731f3d 100644 --- a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -18,33 +18,99 @@ import feign.Response; import feign.Types; import feign.jackson.JacksonDecoder; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.openapitools.client.model.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { + private static final Pattern FILENAME_PATTERN = + Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)"); + public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); } @Override public Object decode(Response response, Type type) throws IOException { - //Detects if the type is an instance of the parameterized class ApiResponse if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) { - //The ApiResponse class has a single type parameter, the Dto class itself Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0]; - Object body = super.decode(response, responseBodyType); + Object body = isBinaryType(responseBodyType) + ? decodeBinary(response, responseBodyType) + : super.decode(response, responseBodyType); Map> responseHeaders = Collections.unmodifiableMap(response.headers()); return new ApiResponse<>(response.status(), responseHeaders, body); - } else { - //The response is not encapsulated in the ApiResponse, decode the Dto as normal - return super.decode(response, type); } + + if (isBinaryType(type)) { + return decodeBinary(response, type); + } + + return super.decode(response, type); + } + + private boolean isBinaryType(Type type) { + Class raw = Types.getRawType(type); + return File.class.isAssignableFrom(raw) + || byte[].class.isAssignableFrom(raw) + || InputStream.class.isAssignableFrom(raw); + } + + private Object decodeBinary(Response response, Type type) throws IOException { + Class raw = Types.getRawType(type); + if (response.body() == null) { + return null; + } + if (byte[].class.isAssignableFrom(raw)) { + return response.body().asInputStream().readAllBytes(); + } + if (InputStream.class.isAssignableFrom(raw)) { + return response.body().asInputStream(); + } + return downloadToTempFile(response); + } + + private File downloadToTempFile(Response response) throws IOException { + String filename = extractFilename(response); + File file; + if (filename != null) { + // Sanitize: strip path components to prevent path traversal + String safeName = Paths.get(filename).getFileName().toString(); + java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); + file = Files.createFile(tempDir.resolve(safeName)).toFile(); + tempDir.toFile().deleteOnExit(); + } else { + file = Files.createTempFile("download-", "").toFile(); + } + file.deleteOnExit(); + try (InputStream is = response.body().asInputStream()) { + Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return file; + } + + private String extractFilename(Response response) { + Collection dispositions = response.headers().get("Content-Disposition"); + if (dispositions == null) return null; + for (String disposition : dispositions) { + Matcher m = FILENAME_PATTERN.matcher(disposition); + if (m.find()) { + // Group 1: quoted filename (may contain spaces), Group 2: unquoted token + return m.group(1) != null ? m.group(1) : m.group(2); + } + } + return null; } }