Skip to content

Commit 9ec55d5

Browse files
committed
fix(context): propagate JSON deserialization error instead of silent ClassCastException
When JSON doesn't match the function's input POJO, the framework now lets Jackson throw a descriptive MismatchedInputException instead of passing the raw string through to the function lambda. Signed-off-by: akenra <37288280+akenra@users.noreply.github.com>
1 parent 6a6798b commit 9ec55d5

3 files changed

Lines changed: 230 additions & 6 deletions

File tree

‎spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
* @author Roman Samarev
9191
* @author Soby Chacko
9292
* @author Chris Bono
93+
* @author Roman Akentev
9394
*/
9495
public class SimpleFunctionRegistry implements FunctionRegistry {
9596
protected Log logger = LogFactory.getLog(this.getClass());
@@ -987,6 +988,16 @@ private String contentTypeHeaderValue(Message<?> msg) {
987988
return Objects.toString(contentType);
988989
}
989990

991+
private static boolean isJsonContentType(String contentTypeValue) {
992+
try {
993+
return contentTypeValue != null
994+
&& MimeTypeUtils.parseMimeType(contentTypeValue).isCompatibleWith(MimeTypeUtils.APPLICATION_JSON);
995+
}
996+
catch (Exception e) {
997+
return false;
998+
}
999+
}
1000+
9901001
@SuppressWarnings("unchecked")
9911002
private Object invokeFunction(Object convertedInput) {
9921003
Object result;
@@ -1219,7 +1230,10 @@ else if (input instanceof Message) {
12191230

12201231
convertedInput = this.convertInputMessageIfNecessary((Message) input, type);
12211232
if (convertedInput == null) { // give ConversionService a chance
1222-
convertedInput = this.convertNonMessageInputIfNecessary(type, ((Message) input).getPayload(), false);
1233+
boolean failOnJsonError = isJsonContentType(contentTypeHeaderValue((Message<?>) input));
1234+
Object payload = ((Message) input).getPayload();
1235+
boolean maybeJson = failOnJsonError || JsonMapper.isJsonString(payload);
1236+
convertedInput = this.convertNonMessageInputIfNecessary(type, payload, maybeJson, failOnJsonError);
12231237
}
12241238
if (convertedInput != null && !FunctionTypeUtils.isMultipleArgumentType(this.inputType)) {
12251239
convertedInput = !convertedInput.equals(input)
@@ -1232,7 +1246,8 @@ else if (input instanceof Message) {
12321246
}
12331247
}
12341248
else {
1235-
convertedInput = this.convertNonMessageInputIfNecessary(type, input, JsonMapper.isJsonString(input));
1249+
// Non-Message input — no Content-Type headers to evaluate
1250+
convertedInput = this.convertNonMessageInputIfNecessary(type, input, JsonMapper.isJsonString(input), false);
12361251
if (convertedInput != null && logger.isDebugEnabled()) {
12371252
logger.debug("Converted input: " + input + " to: " + convertedInput);
12381253
}
@@ -1384,7 +1399,8 @@ private boolean containsRetainMessageSignalInHeaders(Message message) {
13841399
/*
13851400
*
13861401
*/
1387-
private Object convertNonMessageInputIfNecessary(Type inputType, Object input, boolean maybeJson) {
1402+
private Object convertNonMessageInputIfNecessary(Type inputType, Object input,
1403+
boolean maybeJson, boolean failOnJsonError) {
13881404
Object convertedInput = null;
13891405
Class<?> rawInputType = this.isTypePublisher(inputType) || this.isInputTypeMessage()
13901406
? FunctionTypeUtils.getRawType(FunctionTypeUtils.getGenericType(inputType))
@@ -1395,10 +1411,21 @@ private Object convertNonMessageInputIfNecessary(Type inputType, Object input, b
13951411
inputType = FunctionTypeUtils.getGenericType(inputType);
13961412
}
13971413
if (Object.class != inputType) {
1398-
convertedInput = SimpleFunctionRegistry.this.jsonMapper.fromJson(input, inputType);
1414+
try {
1415+
convertedInput = SimpleFunctionRegistry.this.jsonMapper.fromJson(input, inputType);
1416+
}
1417+
catch (Exception e) {
1418+
if (failOnJsonError) {
1419+
throw e;
1420+
}
1421+
if (logger.isDebugEnabled()) {
1422+
logger.debug("JSON conversion failed for '" + input + "' to " + inputType
1423+
+ ". Falling back to ConversionService.", e);
1424+
}
1425+
}
13991426
}
14001427
}
1401-
else if (SimpleFunctionRegistry.this.conversionService != null
1428+
if (convertedInput == null && SimpleFunctionRegistry.this.conversionService != null
14021429
&& !rawInputType.equals(input.getClass())
14031430
&& SimpleFunctionRegistry.this.conversionService.canConvert(input.getClass(), rawInputType)) {
14041431
convertedInput = SimpleFunctionRegistry.this.conversionService.convert(input, rawInputType);

‎spring-cloud-function-context/src/main/java/org/springframework/cloud/function/json/JacksonMapper.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
/**
3232
* @author Dave Syer
3333
* @author Oleg Zhurakousky
34+
* @author Roman Akentev
3435
*/
3536
public class JacksonMapper extends JsonMapper {
3637

@@ -70,7 +71,7 @@ else if (json instanceof Map) {
7071
}
7172
}
7273
catch (Exception e) {
73-
throw new IllegalStateException("Failed to convert. Possible bug as the conversion probably shouldn't have been attempted here", e);
74+
throw new IllegalStateException("Failed to convert JSON to " + type + ": " + e.getMessage(), e);
7475
}
7576
return convertedValue;
7677
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/*
2+
* Copyright 2012-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.function.web;
18+
19+
import java.util.Locale;
20+
import java.util.function.Function;
21+
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.extension.ExtendWith;
24+
25+
import org.springframework.beans.factory.annotation.Autowired;
26+
import org.springframework.boot.resttestclient.TestRestTemplate;
27+
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
28+
import org.springframework.boot.test.context.SpringBootTest;
29+
import org.springframework.boot.test.system.CapturedOutput;
30+
import org.springframework.boot.test.system.OutputCaptureExtension;
31+
import org.springframework.context.annotation.Bean;
32+
import org.springframework.context.annotation.Configuration;
33+
import org.springframework.http.HttpStatus;
34+
import org.springframework.http.MediaType;
35+
import org.springframework.http.RequestEntity;
36+
import org.springframework.http.ResponseEntity;
37+
38+
import static org.assertj.core.api.Assertions.assertThat;
39+
40+
/**
41+
* Integration test for POJO function invocation over HTTP, specifically verifying that
42+
* JSON deserialization failures produce descriptive Jackson errors rather than a
43+
* misleading {@link ClassCastException}.
44+
* <p>
45+
* <b>Architectural defect under test:</b><br>
46+
* When a POST request contains a JSON body whose structure does not match the function's
47+
* input POJO (e.g. an array where a String is expected), the framework previously
48+
* swallowed the Jackson deserialization failure and silently passed the raw JSON string
49+
* as the function argument. The function lambda then failed with a misleading
50+
* {@code ClassCastException: class java.lang.String cannot be cast to Person}. The true
51+
* cause (structural type mismatch in the JSON) was hidden behind an irrelevant cast error.
52+
* <p>
53+
* <b>Fix location:</b> {@code SimpleFunctionRegistry.convertInputIfNecessary()} in
54+
* {@code spring-cloud-function-context}. The fallback path now uses
55+
* {@code org.springframework.cloud.function.json.JsonMapper#isJsonString(Object)}
56+
* together with a Content-Type check to detect JSON payloads and dispatches through
57+
* {@code convertNonMessageInputIfNecessary} with
58+
* {@code maybeJson = true} when {@code convertInputMessageIfNecessary} returns
59+
* {@code null}. This causes Jackson to re-attempt deserialization and throw a descriptive
60+
* {@code tools.jackson.databind.exc.MismatchedInputException} that identifies the exact
61+
* field and type conflict.
62+
* <p>
63+
* These tests verify that correct input, structurally malformed JSON, completely
64+
* broken JSON, and scalar-typed JSON all produce the Jackson error instead of a
65+
* {@code ClassCastException}.
66+
* @author Roman Akentev
67+
*
68+
* @see org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry
69+
* @see org.springframework.cloud.function.json.JsonMapper#isJsonString(Object)
70+
*/
71+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
72+
classes = { RestApplication.class, PojoFunctionIntegrationTests.TestConfig.class })
73+
@AutoConfigureTestRestTemplate
74+
@ExtendWith(OutputCaptureExtension.class)
75+
class PojoFunctionIntegrationTests {
76+
77+
@Autowired
78+
private TestRestTemplate rest;
79+
80+
/**
81+
* Verifies that a correctly structured JSON body is deserialized into the
82+
* {@link Person} record and the function produces the expected output.
83+
*/
84+
@Test
85+
void testCorrectInput() {
86+
String json = """
87+
{"firstName":"john","lastName":"doe"}""";
88+
89+
RequestEntity<String> request = RequestEntity
90+
.post("/personUppercase")
91+
.contentType(MediaType.APPLICATION_JSON)
92+
.body(json);
93+
94+
ResponseEntity<String> result = this.rest.exchange(request, String.class);
95+
96+
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
97+
assertThat(result.getBody()).isEqualTo("HELLO, JOHN DOE!");
98+
}
99+
100+
/**
101+
* Regression test: structurally valid JSON with wrong field types is caught
102+
* by Jackson and produces a {@code MismatchedInputException} in the log
103+
* instead of a silent {@code ClassCastException}.
104+
*/
105+
@Test
106+
void testMalformedJsonRejectsWithJacksonError(CapturedOutput output) {
107+
String json = """
108+
{"firstName": ["john"], "lastName": {"value": "doe"}}""";
109+
110+
RequestEntity<String> request = RequestEntity
111+
.post("/personUppercase")
112+
.contentType(MediaType.APPLICATION_JSON)
113+
.body(json);
114+
115+
ResponseEntity<String> result = this.rest.exchange(request, String.class);
116+
117+
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
118+
119+
assertThat(output)
120+
.as("Server log must contain Jackson deserialization error, not ClassCastException")
121+
.contains("MismatchedInputException")
122+
.contains("Cannot deserialize value");
123+
124+
assertThat(output)
125+
.as("Server log must NOT contain the old misleading ClassCastException")
126+
.doesNotContain("ClassCastException");
127+
}
128+
129+
/**
130+
* Regression test: completely broken JSON (not syntactically valid) is caught
131+
* by Jackson and produces a parse error in the log instead of a
132+
* {@code ClassCastException}.
133+
*/
134+
@Test
135+
void testBrokenJsonRejectsWithJacksonError(CapturedOutput output) {
136+
String json = "{broken}";
137+
138+
RequestEntity<String> request = RequestEntity
139+
.post("/personUppercase")
140+
.contentType(MediaType.APPLICATION_JSON)
141+
.body(json);
142+
143+
ResponseEntity<String> result = this.rest.exchange(request, String.class);
144+
145+
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
146+
147+
assertThat(output)
148+
.as("Server log must contain Jackson parse error, not ClassCastException")
149+
.contains("Unexpected character");
150+
151+
assertThat(output)
152+
.as("Server log must NOT contain the old misleading ClassCastException")
153+
.doesNotContain("ClassCastException");
154+
}
155+
156+
/**
157+
* Regression test: a JSON scalar string sent to a POJO function is caught
158+
* by Jackson and produces a {@code MismatchedInputException} in the log.
159+
* Without the fix the scalar passes through unchecked and the function
160+
* lambda fails with a misleading {@code ClassCastException}.
161+
*/
162+
@Test
163+
void testScalarStringRejectsWithJacksonError(CapturedOutput output) {
164+
String json = "\"hello\"";
165+
166+
RequestEntity<String> request = RequestEntity
167+
.post("/personUppercase")
168+
.contentType(MediaType.APPLICATION_JSON)
169+
.body(json);
170+
171+
ResponseEntity<String> result = this.rest.exchange(request, String.class);
172+
173+
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
174+
175+
assertThat(output)
176+
.as("Server log must contain Jackson deserialization error, not ClassCastException")
177+
.contains("MismatchedInputException");
178+
179+
assertThat(output)
180+
.as("Server log must NOT contain the old misleading ClassCastException")
181+
.doesNotContain("ClassCastException");
182+
}
183+
184+
record Person(String firstName, String lastName) { }
185+
186+
@Configuration
187+
static class TestConfig {
188+
189+
@Bean
190+
public Function<Person, String> personUppercase() {
191+
return person -> String.format("HELLO, %s %s!",
192+
person.firstName().toUpperCase(Locale.ROOT),
193+
person.lastName().toUpperCase(Locale.ROOT));
194+
}
195+
}
196+
}

0 commit comments

Comments
 (0)