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<T> wrappers.

Consistent with the native library fix in #21346.

Closes #2486

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
cziberpv
2026-02-11 09:55:00 +01:00
committed by GitHub
parent 33cce11a1d
commit b39aad0d56
4 changed files with 288 additions and 24 deletions

View File

@@ -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<String, Collection<String>> 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<String> 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;
}
}