Skip to content

Commit b39aad0

Browse files
cziberpvclaude
andauthored
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>
1 parent 33cce11 commit b39aad0

4 files changed

Lines changed: 284 additions & 20 deletions

File tree

modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,99 @@ import feign.Response;
77
import feign.Types;
88
import feign.jackson.JacksonDecoder;
99

10+
import java.io.File;
1011
import java.io.IOException;
12+
import java.io.InputStream;
1113
import java.lang.reflect.ParameterizedType;
1214
import java.lang.reflect.Type;
15+
import java.nio.file.Files;
16+
import java.nio.file.Paths;
17+
import java.nio.file.StandardCopyOption;
1318
import java.util.Collection;
1419
import java.util.Collections;
1520
import java.util.Map;
21+
import java.util.regex.Matcher;
22+
import java.util.regex.Pattern;
1623

1724
import {{modelPackage}}.ApiResponse;
1825

1926
public class ApiResponseDecoder extends JacksonDecoder {
2027
28+
private static final Pattern FILENAME_PATTERN =
29+
Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)");
30+
2131
public ApiResponseDecoder(ObjectMapper mapper) {
2232
super(mapper);
2333
}
2434

2535
@Override
2636
public Object decode(Response response, Type type) throws IOException {
27-
//Detects if the type is an instance of the parameterized class ApiResponse
2837
if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) {
29-
//The ApiResponse class has a single type parameter, the Dto class itself
3038
Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0];
31-
Object body = super.decode(response, responseBodyType);
39+
Object body = isBinaryType(responseBodyType)
40+
? decodeBinary(response, responseBodyType)
41+
: super.decode(response, responseBodyType);
3242
Map<String, Collection<String>> responseHeaders = Collections.unmodifiableMap(response.headers());
3343
return new ApiResponse<>(response.status(), responseHeaders, body);
44+
}
45+
46+
if (isBinaryType(type)) {
47+
return decodeBinary(response, type);
48+
}
49+
50+
return super.decode(response, type);
51+
}
52+
53+
private boolean isBinaryType(Type type) {
54+
Class<?> raw = Types.getRawType(type);
55+
return File.class.isAssignableFrom(raw)
56+
|| byte[].class.isAssignableFrom(raw)
57+
|| InputStream.class.isAssignableFrom(raw);
58+
}
59+
60+
private Object decodeBinary(Response response, Type type) throws IOException {
61+
Class<?> raw = Types.getRawType(type);
62+
if (response.body() == null) {
63+
return null;
64+
}
65+
if (byte[].class.isAssignableFrom(raw)) {
66+
return response.body().asInputStream().readAllBytes();
67+
}
68+
if (InputStream.class.isAssignableFrom(raw)) {
69+
return response.body().asInputStream();
70+
}
71+
return downloadToTempFile(response);
72+
}
73+
74+
private File downloadToTempFile(Response response) throws IOException {
75+
String filename = extractFilename(response);
76+
File file;
77+
if (filename != null) {
78+
// Sanitize: strip path components to prevent path traversal
79+
String safeName = Paths.get(filename).getFileName().toString();
80+
java.nio.file.Path tempDir = Files.createTempDirectory("feign-download");
81+
file = Files.createFile(tempDir.resolve(safeName)).toFile();
82+
tempDir.toFile().deleteOnExit();
3483
} else {
35-
//The response is not encapsulated in the ApiResponse, decode the Dto as normal
36-
return super.decode(response, type);
84+
file = Files.createTempFile("download-", "").toFile();
85+
}
86+
file.deleteOnExit();
87+
try (InputStream is = response.body().asInputStream()) {
88+
Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING);
89+
}
90+
return file;
91+
}
92+
93+
private String extractFilename(Response response) {
94+
Collection<String> dispositions = response.headers().get("Content-Disposition");
95+
if (dispositions == null) return null;
96+
for (String disposition : dispositions) {
97+
Matcher m = FILENAME_PATTERN.matcher(disposition);
98+
if (m.find()) {
99+
// Group 1: quoted filename (may contain spaces), Group 2: unquoted token
100+
return m.group(1) != null ? m.group(1) : m.group(2);
101+
}
37102
}
103+
return null;
38104
}
39105
}

samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,33 +18,99 @@
1818
import feign.Types;
1919
import feign.jackson.JacksonDecoder;
2020

21+
import java.io.File;
2122
import java.io.IOException;
23+
import java.io.InputStream;
2224
import java.lang.reflect.ParameterizedType;
2325
import java.lang.reflect.Type;
26+
import java.nio.file.Files;
27+
import java.nio.file.Paths;
28+
import java.nio.file.StandardCopyOption;
2429
import java.util.Collection;
2530
import java.util.Collections;
2631
import java.util.Map;
32+
import java.util.regex.Matcher;
33+
import java.util.regex.Pattern;
2734

2835
import org.openapitools.client.model.ApiResponse;
2936

3037
public class ApiResponseDecoder extends JacksonDecoder {
3138

39+
private static final Pattern FILENAME_PATTERN =
40+
Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)");
41+
3242
public ApiResponseDecoder(ObjectMapper mapper) {
3343
super(mapper);
3444
}
3545

3646
@Override
3747
public Object decode(Response response, Type type) throws IOException {
38-
//Detects if the type is an instance of the parameterized class ApiResponse
3948
if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) {
40-
//The ApiResponse class has a single type parameter, the Dto class itself
4149
Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0];
42-
Object body = super.decode(response, responseBodyType);
50+
Object body = isBinaryType(responseBodyType)
51+
? decodeBinary(response, responseBodyType)
52+
: super.decode(response, responseBodyType);
4353
Map<String, Collection<String>> responseHeaders = Collections.unmodifiableMap(response.headers());
4454
return new ApiResponse<>(response.status(), responseHeaders, body);
55+
}
56+
57+
if (isBinaryType(type)) {
58+
return decodeBinary(response, type);
59+
}
60+
61+
return super.decode(response, type);
62+
}
63+
64+
private boolean isBinaryType(Type type) {
65+
Class<?> raw = Types.getRawType(type);
66+
return File.class.isAssignableFrom(raw)
67+
|| byte[].class.isAssignableFrom(raw)
68+
|| InputStream.class.isAssignableFrom(raw);
69+
}
70+
71+
private Object decodeBinary(Response response, Type type) throws IOException {
72+
Class<?> raw = Types.getRawType(type);
73+
if (response.body() == null) {
74+
return null;
75+
}
76+
if (byte[].class.isAssignableFrom(raw)) {
77+
return response.body().asInputStream().readAllBytes();
78+
}
79+
if (InputStream.class.isAssignableFrom(raw)) {
80+
return response.body().asInputStream();
81+
}
82+
return downloadToTempFile(response);
83+
}
84+
85+
private File downloadToTempFile(Response response) throws IOException {
86+
String filename = extractFilename(response);
87+
File file;
88+
if (filename != null) {
89+
// Sanitize: strip path components to prevent path traversal
90+
String safeName = Paths.get(filename).getFileName().toString();
91+
java.nio.file.Path tempDir = Files.createTempDirectory("feign-download");
92+
file = Files.createFile(tempDir.resolve(safeName)).toFile();
93+
tempDir.toFile().deleteOnExit();
4594
} else {
46-
//The response is not encapsulated in the ApiResponse, decode the Dto as normal
47-
return super.decode(response, type);
95+
file = Files.createTempFile("download-", "").toFile();
96+
}
97+
file.deleteOnExit();
98+
try (InputStream is = response.body().asInputStream()) {
99+
Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING);
100+
}
101+
return file;
102+
}
103+
104+
private String extractFilename(Response response) {
105+
Collection<String> dispositions = response.headers().get("Content-Disposition");
106+
if (dispositions == null) return null;
107+
for (String disposition : dispositions) {
108+
Matcher m = FILENAME_PATTERN.matcher(disposition);
109+
if (m.find()) {
110+
// Group 1: quoted filename (may contain spaces), Group 2: unquoted token
111+
return m.group(1) != null ? m.group(1) : m.group(2);
112+
}
48113
}
114+
return null;
49115
}
50116
}

samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,33 +18,99 @@
1818
import feign.Types;
1919
import feign.jackson.JacksonDecoder;
2020

21+
import java.io.File;
2122
import java.io.IOException;
23+
import java.io.InputStream;
2224
import java.lang.reflect.ParameterizedType;
2325
import java.lang.reflect.Type;
26+
import java.nio.file.Files;
27+
import java.nio.file.Paths;
28+
import java.nio.file.StandardCopyOption;
2429
import java.util.Collection;
2530
import java.util.Collections;
2631
import java.util.Map;
32+
import java.util.regex.Matcher;
33+
import java.util.regex.Pattern;
2734

2835
import org.openapitools.client.model.ApiResponse;
2936

3037
public class ApiResponseDecoder extends JacksonDecoder {
3138

39+
private static final Pattern FILENAME_PATTERN =
40+
Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)");
41+
3242
public ApiResponseDecoder(ObjectMapper mapper) {
3343
super(mapper);
3444
}
3545

3646
@Override
3747
public Object decode(Response response, Type type) throws IOException {
38-
//Detects if the type is an instance of the parameterized class ApiResponse
3948
if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) {
40-
//The ApiResponse class has a single type parameter, the Dto class itself
4149
Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0];
42-
Object body = super.decode(response, responseBodyType);
50+
Object body = isBinaryType(responseBodyType)
51+
? decodeBinary(response, responseBodyType)
52+
: super.decode(response, responseBodyType);
4353
Map<String, Collection<String>> responseHeaders = Collections.unmodifiableMap(response.headers());
4454
return new ApiResponse<>(response.status(), responseHeaders, body);
55+
}
56+
57+
if (isBinaryType(type)) {
58+
return decodeBinary(response, type);
59+
}
60+
61+
return super.decode(response, type);
62+
}
63+
64+
private boolean isBinaryType(Type type) {
65+
Class<?> raw = Types.getRawType(type);
66+
return File.class.isAssignableFrom(raw)
67+
|| byte[].class.isAssignableFrom(raw)
68+
|| InputStream.class.isAssignableFrom(raw);
69+
}
70+
71+
private Object decodeBinary(Response response, Type type) throws IOException {
72+
Class<?> raw = Types.getRawType(type);
73+
if (response.body() == null) {
74+
return null;
75+
}
76+
if (byte[].class.isAssignableFrom(raw)) {
77+
return response.body().asInputStream().readAllBytes();
78+
}
79+
if (InputStream.class.isAssignableFrom(raw)) {
80+
return response.body().asInputStream();
81+
}
82+
return downloadToTempFile(response);
83+
}
84+
85+
private File downloadToTempFile(Response response) throws IOException {
86+
String filename = extractFilename(response);
87+
File file;
88+
if (filename != null) {
89+
// Sanitize: strip path components to prevent path traversal
90+
String safeName = Paths.get(filename).getFileName().toString();
91+
java.nio.file.Path tempDir = Files.createTempDirectory("feign-download");
92+
file = Files.createFile(tempDir.resolve(safeName)).toFile();
93+
tempDir.toFile().deleteOnExit();
4594
} else {
46-
//The response is not encapsulated in the ApiResponse, decode the Dto as normal
47-
return super.decode(response, type);
95+
file = Files.createTempFile("download-", "").toFile();
96+
}
97+
file.deleteOnExit();
98+
try (InputStream is = response.body().asInputStream()) {
99+
Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING);
100+
}
101+
return file;
102+
}
103+
104+
private String extractFilename(Response response) {
105+
Collection<String> dispositions = response.headers().get("Content-Disposition");
106+
if (dispositions == null) return null;
107+
for (String disposition : dispositions) {
108+
Matcher m = FILENAME_PATTERN.matcher(disposition);
109+
if (m.find()) {
110+
// Group 1: quoted filename (may contain spaces), Group 2: unquoted token
111+
return m.group(1) != null ? m.group(1) : m.group(2);
112+
}
48113
}
114+
return null;
49115
}
50116
}

0 commit comments

Comments
 (0)