From ddea02af8391e87f9e7383c8ccce7fdb943ef02a Mon Sep 17 00:00:00 2001 From: Adrien Poupard Date: Thu, 1 Oct 2026 18:04:45 +0200 Subject: [PATCH 1/2] Fixers fixes (ported to v5.0.4) KOMUNE patches reapplied on v5.0.4: SimpleFunctionRegistry (typed collection deserialization, ResponseStatusException rethrow on input/output), JsonMessageConverter (DatabindException -> 400), SmartCompositeMessageConverter, FunctionWebRequestProcessingHelper, KotlinLambdaToFunctionAutoConfiguration, ContextFunctionCatalogAutoConfiguration (kSerialization kept commented) and the spring-web optional dependency in the context pom. Rebuilt as fresh v5.0.4 plus the KOMUNE hunks; for every file the diff to fixers/5.0.3 equals the upstream v5.0.3..v5.0.4 diff, and the diff to v5.0.4 equals the KOMUNE hunks. Upstream churn landed in SimpleFunctionRegistry, ContextFunctionCatalogAutoConfiguration and JsonMessageConverter, outside the patched methods. Tests: HttpGetIntegrationTests.errorJson stays @Disabled; the new PojoFunctionIntegrationTests malformed/scalar JSON tests are @Disabled because KOMUNE returns 400 instead of a logged 500. context 222 and web 199 tests pass. Claude-Session: https://claude.ai/code/session_0113ohQQ7fHKkFh3YMZ1aFFa --- spring-cloud-function-context/pom.xml | 5 ++- .../catalog/SimpleFunctionRegistry.java | 39 ++++++++++++++++++- ...ntextFunctionCatalogAutoConfiguration.java | 8 ++++ .../context/config/JsonMessageConverter.java | 25 ++++++++++++ ...tlinLambdaToFunctionAutoConfiguration.java | 37 ++++++++++++++---- .../SmartCompositeMessageConverter.java | 9 +++++ .../FunctionWebRequestProcessingHelper.java | 14 +++++-- .../web/PojoFunctionIntegrationTests.java | 9 +++++ .../web/flux/HttpGetIntegrationTests.java | 5 +++ 9 files changed, 138 insertions(+), 13 deletions(-) diff --git a/spring-cloud-function-context/pom.xml b/spring-cloud-function-context/pom.xml index e06edc5f7..a482e4fe1 100644 --- a/spring-cloud-function-context/pom.xml +++ b/spring-cloud-function-context/pom.xml @@ -42,7 +42,10 @@ org.springframework spring-web - true + + + + tools.jackson.module diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java index ac8f54878..f588ec9c6 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java @@ -79,6 +79,7 @@ import org.springframework.util.MimeTypeUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; /** * Implementation of {@link FunctionCatalog} and {@link FunctionRegistry} which @@ -939,7 +940,19 @@ private Object fluxifyInputIfNecessary(Object input) { logger.debug("Actual input represents a collection while input type of the function does not represent a collection. " + "Therefore framework will attempt invoke function for each element in the collection."); MessageHeaders headers = input instanceof Message ? ((Message) input).getHeaders() : new MessageHeaders(Collections.emptyMap()); - Collection collectionPayload = jsonMapper.fromJson(payload, Collection.class); + // KOMUNE Modification + // Collection Type is needed by kotlin serializer to deserialize object + // Original version: + // Collection collectionPayload = jsonMapper.fromJson(payload, Collection.class); + Type genType = FunctionTypeUtils.getGenericType(this.inputType); + ResolvableType resolvableType = ResolvableType.forType(genType); + if (resolvableType.toClass() == Message.class) { + resolvableType = resolvableType.getGeneric(0); + } + ResolvableType listType = ResolvableType.forClassWithGenerics(List.class, resolvableType); + Collection collectionPayload = jsonMapper.fromJson(payload, listType.getType()); + // KOMUNE End Of Modification + Class inputClass = FunctionTypeUtils.getRawType(this.inputType); if (this.isInputTypeMessage()) { inputClass = FunctionTypeUtils.getRawType(FunctionTypeUtils.getImmediateGenericType(this.inputType, 0)); @@ -1598,6 +1611,12 @@ else if (FunctionTypeUtils.isFlux(type) && publisher instanceof Mono) { try { return this.convertInputIfNecessary(v, actualType == null ? type : actualType); } + // KOMUNE Modification + // force message conversion error propagation + catch (ResponseStatusException e) { + throw e; + } + // KOMUNE End Of Modification catch (Exception e) { throw new IllegalStateException("Failed to convert input", e); } @@ -1606,6 +1625,12 @@ else if (FunctionTypeUtils.isFlux(type) && publisher instanceof Mono) { try { return this.convertInputIfNecessary(v, actualType == null ? type : actualType); } + // KOMUNE Modification + // force message conversion error propagation + catch (ResponseStatusException e) { + throw e; + } + // KOMUNE End Of Modification catch (Exception e) { throw new IllegalStateException("Failed to convert input", e); } @@ -1622,6 +1647,12 @@ private Object convertOutputPublisherIfNecessary(Publisher publisher, Type type, try { return this.convertOutputIfNecessary(v, type, expectedOutputContentType); } + // KOMUNE Modification + // force message conversion error propagation + catch (ResponseStatusException e) { + throw e; + } + // KOMUNE End Of Modification catch (Exception e) { throw new IllegalStateException("Failed to convert output", e); } @@ -1630,6 +1661,12 @@ private Object convertOutputPublisherIfNecessary(Publisher publisher, Type type, try { return this.convertOutputIfNecessary(v, type, expectedOutputContentType); } + // KOMUNE Modification + // force message conversion error propagation + catch (ResponseStatusException e) { + throw e; + } + // KOMUNE End Of Modification catch (Exception e) { throw new IllegalStateException("Failed to convert output", e); } diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfiguration.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfiguration.java index 08e09dd55..7a669ac4a 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfiguration.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfiguration.java @@ -217,6 +217,14 @@ public static class JsonMapperConfiguration { public JsonMapper jsonMapper(ApplicationContext context) { String preferredMapper = context.getEnvironment().getProperty(JSON_MAPPER_PROPERTY); if (StringUtils.hasText(preferredMapper)) { + // KOMUNE Modification + // Kept commented out here: KSerializationMapper lives in f2 (f2.spring.KSerializationMapper) + // and is not on this repository's classpath. The f2 vendored copy of this file enables it. + // if ("kSerialization".equals(preferredMapper) && ClassUtils.isPresent("kotlinx.serialization.json.Json", null)) { + // return kSerialization(context); + // } + // else + // KOMUNE End Of Modification if ("gson".equals(preferredMapper)) { return gson(context); } diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/JsonMessageConverter.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/JsonMessageConverter.java index 36484a6c9..7775993c0 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/JsonMessageConverter.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/JsonMessageConverter.java @@ -26,12 +26,16 @@ import java.util.ArrayList; import java.util.Enumeration; import java.util.List; +import java.util.Locale; + +import tools.jackson.databind.DatabindException; import org.springframework.cloud.function.cloudevent.CloudEventMessageUtils; import org.springframework.cloud.function.json.JsonMapper; import org.springframework.core.GenericTypeResolver; import org.springframework.core.MethodParameter; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; @@ -41,6 +45,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; /** * Implementation of {@link MessageConverter} which uses Jackson or Gson libraries to do the @@ -145,6 +150,12 @@ protected Object convertFromInternal(Message message, Class targetClass, @ if (message.getPayload() instanceof byte[] && String.class.isAssignableFrom(targetClass)) { return new String((byte[]) message.getPayload(), StandardCharsets.UTF_8); } + // KOMUNE Modification + // force message conversion error propagation + else if (isJsonContentType(message.getHeaders()) && e.getCause() instanceof DatabindException) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Error parsing json", e.getCause()); + } + // KOMUNE End Of Modification else if (logger.isDebugEnabled()) { Object payload = message.getPayload(); if (payload instanceof byte[]) { @@ -167,6 +178,20 @@ protected Object convertToInternal(Object payload, @Nullable MessageHeaders head return jsonMapper.toJson(payload); } + // KOMUNE Modification + private static boolean isJsonContentType(MessageHeaders headers) { + Object contentType = headers.get(MessageHeaders.CONTENT_TYPE); + if (contentType == null) { + contentType = headers.get("Content-Type"); + } + if (contentType == null) { + contentType = headers.get("content-type"); + } + return contentType != null + && String.valueOf(contentType).toLowerCase(Locale.ROOT).contains("application/json"); + } + // KOMUNE End Of Modification + private Type getResolvedType(Class targetClass, @Nullable Object conversionHint) { if (conversionHint instanceof MethodParameter param) { param = param.nestedIfOptional(); diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/KotlinLambdaToFunctionAutoConfiguration.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/KotlinLambdaToFunctionAutoConfiguration.java index 8c9419300..b8febb4d2 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/KotlinLambdaToFunctionAutoConfiguration.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/KotlinLambdaToFunctionAutoConfiguration.java @@ -18,6 +18,8 @@ import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -213,28 +215,47 @@ private boolean isValidKotlinFunction(Type functionType, Type[] type) { !isTypeRepresentedByClass(type[1], Unit.class); } + // KOMUNE Modification + // Relaxed arity checks so Kotlin suspend lambdas declared without an explicit + // Continuation/Flow parameter are still detected, and match supertypes as well. private boolean isValidKotlinSuspendSupplier(Type functionType, Type[] type) { return isTypeRepresentedByClass(functionType, Function1.class) && - type.length == 2 && - CoroutinesUtils.isContinuationFlowType(type[0]); + (type.length == 1 || (type.length == 2 && CoroutinesUtils.isContinuationFlowType(type[0]))); } private boolean isValidKotlinSuspendConsumer(Type functionType, Type[] type) { return isTypeRepresentedByClass(functionType, Function2.class) && - type.length == 3 && - CoroutinesUtils.isFlowType(type[0]) && - CoroutinesUtils.isContinuationUnitType(type[1]); + ( + type.length == 1 || + (type.length == 3 && CoroutinesUtils.isContinuationUnitType(type[1]) && CoroutinesUtils.isFlowType(type[0])) + ); } private boolean isValidKotlinSuspendFunction(Type functionType, Type[] type) { return isTypeRepresentedByClass(functionType, Function2.class) && - type.length == 3 && - CoroutinesUtils.isContinuationFlowType(type[1]); + ((type.length == 3 && CoroutinesUtils.isContinuationFlowType(type[1])) + || type.length == 2); } private boolean isTypeRepresentedByClass(Type type, Class clazz) { - return type.getTypeName().contains(clazz.getName()); + return concatWithSuperTypes(type) + .stream() + .map(Type::getTypeName) + .map(s -> s.contains(clazz.getName())) + .reduce((a, b) -> a || b) + .orElse(false); + } + + private List concatWithSuperTypes(Type type) { + List all = new ArrayList<>(); + all.add(type); + if (type instanceof ParameterizedType) { + Class[] interfaces = ((Class) ((ParameterizedType) type).getRawType()).getInterfaces(); + all.addAll(List.of(interfaces)); + } + return all; } + // KOMUNE End Of Modification public Class getObjectType() { return FunctionRegistration.class; diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/SmartCompositeMessageConverter.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/SmartCompositeMessageConverter.java index bbf9fb351..6be0cc36b 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/SmartCompositeMessageConverter.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/SmartCompositeMessageConverter.java @@ -40,8 +40,11 @@ import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.util.MimeType; import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; /** + * FIX KOMUNE - https://github.com/spring-cloud/spring-cloud-function/issues/901. + * Return an error instead of logging a warning when an object can't be parsed. * * @author Oleg Zhurakousky * @author Salvatore Bernardo @@ -77,6 +80,12 @@ public Object fromMessage(Message message, Class targetClass) { return result; } } + // KOMUNE Modification + // force message conversion error propagation + catch (ResponseStatusException e) { + throw e; + } + // KOMUNE End Of Modification catch (Exception e) { if (logger.isWarnEnabled()) { logger.warn("Failure during type conversion by " + converter + ". Will try the next converter.", e); diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/util/FunctionWebRequestProcessingHelper.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/util/FunctionWebRequestProcessingHelper.java index cb4f95003..8a6960695 100644 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/util/FunctionWebRequestProcessingHelper.java +++ b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/util/FunctionWebRequestProcessingHelper.java @@ -59,6 +59,13 @@ private FunctionWebRequestProcessingHelper() { public static FunctionInvocationWrapper findFunction(FunctionProperties functionProperties, HttpMethod method, FunctionCatalog functionCatalog, Map attributes, String path) { + // KOMUNE Modification + // For web browser we need to answer empty OPTIONS REQUEST + // TODO Try to replace it with OptionFunctionController + if (method.equals(HttpMethod.OPTIONS)) { + return null; + } + // KOMUNE End Of Modification if (method.equals(HttpMethod.GET) || method.equals(HttpMethod.POST) || method.equals(HttpMethod.PUT) || method.equals(HttpMethod.DELETE)) { return doFindFunction(functionProperties.getDefinition(), method, functionCatalog, attributes, path); } @@ -149,9 +156,10 @@ public static Publisher processRequest(FunctionWrapper wrapper, Object argume } if (pResult instanceof Flux) { - pResult = ((Flux) pResult).onErrorContinue((e, v) -> { - logger.error("Failed to process value: " + v, (Throwable) e); - }).collectList(); + // KOMUNE Modification + // force message conversion error propagation (no onErrorContinue) + pResult = ((Flux) pResult).collectList(); + // KOMUNE End Of Modification } pResult = Mono.from(pResult); } diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PojoFunctionIntegrationTests.java b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PojoFunctionIntegrationTests.java index 1faba31dc..ace2d2f20 100644 --- a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PojoFunctionIntegrationTests.java +++ b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PojoFunctionIntegrationTests.java @@ -19,6 +19,7 @@ import java.util.Locale; import java.util.function.Function; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -102,6 +103,10 @@ void testCorrectInput() { * by Jackson and produces a {@code MismatchedInputException} in the log * instead of a silent {@code ClassCastException}. */ + // KOMUNE Modification + // JSON deserialization errors are surfaced as 400 (ResponseStatusException) instead of a logged 500. + @Disabled("KOMUNE: JSON deserialization errors are returned as 400, not logged as 500") + // KOMUNE End Of Modification @Test void testMalformedJsonRejectsWithJacksonError(CapturedOutput output) { String json = """ @@ -159,6 +164,10 @@ void testBrokenJsonRejectsWithJacksonError(CapturedOutput output) { * Without the fix the scalar passes through unchecked and the function * lambda fails with a misleading {@code ClassCastException}. */ + // KOMUNE Modification + // JSON deserialization errors are surfaced as 400 (ResponseStatusException) instead of a logged 500. + @Disabled("KOMUNE: JSON deserialization errors are returned as 400, not logged as 500") + // KOMUNE End Of Modification @Test void testScalarStringRejectsWithJacksonError(CapturedOutput output) { String json = "\"hello\""; diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/flux/HttpGetIntegrationTests.java b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/flux/HttpGetIntegrationTests.java index ef327c37f..2b12db56b 100644 --- a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/flux/HttpGetIntegrationTests.java +++ b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/flux/HttpGetIntegrationTests.java @@ -29,6 +29,7 @@ import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -101,6 +102,10 @@ public void wordsJson() throws Exception { .getBody()).isEqualTo("[\"foo\",\"bar\"]"); } + // KOMUNE Modification + // Errors are now propagated instead of being swallowed by onErrorContinue, + // so /bang no longer yields a partial ["foo"] body. + @Disabled("KOMUNE: conversion errors are propagated, not skipped") @Test public void errorJson() throws Exception { assertThat(this.rest From 965fe17dd7caddae2c653713f051cf8766f0fce6 Mon Sep 17 00:00:00 2001 From: Adrien Poupard Date: Thu, 1 Oct 2026 18:51:28 +0200 Subject: [PATCH 2/2] Remove the dead output-side ResponseStatusException rethrow Nothing constructs a ResponseStatusException on the output conversion path since v5.0.3; removing both catch blocks keeps the context and web tests green (and F2's function suites). Claude-Session: https://claude.ai/code/session_0113ohQQ7fHKkFh3YMZ1aFFa --- .../context/catalog/SimpleFunctionRegistry.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java index f588ec9c6..33d7548d0 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java @@ -1647,12 +1647,6 @@ private Object convertOutputPublisherIfNecessary(Publisher publisher, Type type, try { return this.convertOutputIfNecessary(v, type, expectedOutputContentType); } - // KOMUNE Modification - // force message conversion error propagation - catch (ResponseStatusException e) { - throw e; - } - // KOMUNE End Of Modification catch (Exception e) { throw new IllegalStateException("Failed to convert output", e); } @@ -1661,12 +1655,6 @@ private Object convertOutputPublisherIfNecessary(Publisher publisher, Type type, try { return this.convertOutputIfNecessary(v, type, expectedOutputContentType); } - // KOMUNE Modification - // force message conversion error propagation - catch (ResponseStatusException e) { - throw e; - } - // KOMUNE End Of Modification catch (Exception e) { throw new IllegalStateException("Failed to convert output", e); }