GH-1259 feat: add reactor publisher support

Resolves #1259
Resolves #1264

Signed-off-by: geezylucas <sorec@live.com>
This commit is contained in:
geezylucas
2025-04-14 23:58:16 -06:00
committed by Oleg Zhurakousky
parent 4f8d647bd2
commit 4b0636f48f
3 changed files with 211 additions and 38 deletions

View File

@@ -17,8 +17,11 @@
package org.springframework.cloud.function.adapter.gcp;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map.Entry;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -30,9 +33,12 @@ import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.cloud.functions.RawBackgroundFunction;
import reactor.core.publisher.Flux;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.reactivestreams.Publisher;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.cloud.function.context.FunctionCatalog;
@@ -40,6 +46,7 @@ import org.springframework.cloud.function.context.FunctionalSpringApplication;
import org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry.FunctionInvocationWrapper;
import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration;
import org.springframework.cloud.function.context.config.RoutingFunction;
import org.springframework.cloud.function.json.JsonMapper;
import org.springframework.cloud.function.utils.FunctionClassUtils;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.messaging.Message;
@@ -76,6 +83,8 @@ public class FunctionInvoker implements HttpFunction, RawBackgroundFunction {
private ConfigurableApplicationContext context;
private JsonMapper jsonMapper;
public FunctionInvoker() {
this(FunctionClassUtils.getStartClass());
}
@@ -90,18 +99,19 @@ public class FunctionInvoker implements HttpFunction, RawBackgroundFunction {
System.setProperty(ContextFunctionCatalogAutoConfiguration.JSON_MAPPER_PROPERTY, "gson");
}
Thread.currentThread() // TODO: remove after upgrading to 1.0.0-alpha-2-rc5
.setContextClassLoader(FunctionInvoker.class.getClassLoader());
.setContextClassLoader(FunctionInvoker.class.getClassLoader());
log.info("Initializing: " + configurationClass);
SpringApplication springApplication = springApplication(configurationClass);
this.context = springApplication.run();
this.catalog = this.context.getBean(FunctionCatalog.class);
this.jsonMapper = this.context.getBean(JsonMapper.class);
initFunctionConsumerOrSupplierFromCatalog();
}
private <I> Function<Message<I>, Message<byte[]>> lookupFunction() {
Function<Message<I>, Message<byte[]>> function = this.catalog.lookup(functionName,
MimeTypeUtils.APPLICATION_JSON.toString());
MimeTypeUtils.APPLICATION_JSON.toString());
Assert.notNull(function, "'function' with name '" + functionName + "' must not be null");
return function;
}
@@ -114,43 +124,22 @@ public class FunctionInvoker implements HttpFunction, RawBackgroundFunction {
public void service(HttpRequest httpRequest, HttpResponse httpResponse) throws Exception {
Function<Message<BufferedReader>, Message<byte[]>> function = lookupFunction();
Message<BufferedReader> message = this.functionWrapped.getInputType() == Void.class || this.functionWrapped.getInputType() == null ? null
: MessageBuilder.withPayload(httpRequest.getReader()).copyHeaders(httpRequest.getHeaders()).build();
Message<BufferedReader> message = this.functionWrapped.getInputType() == Void.class
|| this.functionWrapped.getInputType() == null ? null
: MessageBuilder.withPayload(httpRequest.getReader()).copyHeaders(httpRequest.getHeaders())
.build();
Message<?> result = function.apply(message);
Object resultObject = function.apply(message);
if (result != null) {
MessageHeaders headers = result.getHeaders();
if (result.getHeaders().containsKey(MessageHeaders.CONTENT_TYPE)) {
httpResponse.setContentType(result.getHeaders().get(MessageHeaders.CONTENT_TYPE).toString());
}
else if (result.getHeaders().containsKey("Content-Type")) {
httpResponse.setContentType(result.getHeaders().get("Content-Type").toString());
}
else {
httpRequest.getContentType().ifPresent(contentType -> httpResponse.setContentType(contentType));
}
String content = result.getPayload() instanceof String strPayload ? strPayload : new String((byte[]) result.getPayload(), StandardCharsets.UTF_8);
httpResponse.getWriter().write(content);
for (Entry<String, Object> header : headers.entrySet()) {
Object values = header.getValue();
if (values instanceof Collection<?>) {
String headerValue = ((Collection<?>) values).stream().map(item -> item.toString()).collect(Collectors.joining(","));
httpResponse.appendHeader(header.getKey(), headerValue);
}
else {
httpResponse.appendHeader(header.getKey(), header.getValue().toString());
}
if (resultObject != null) {
Message<?> result = null;
if (resultObject instanceof Publisher<?>) {
result = getResultFromPublisher(resultObject);
} else {
result = (Message<?>) resultObject;
}
if (headers.containsKey(HTTP_STATUS_CODE)) {
if (headers.get(HTTP_STATUS_CODE) instanceof Integer) {
httpResponse.setStatusCode((int) headers.get(HTTP_STATUS_CODE));
}
else {
log.warn("The statusCode should be an Integer value");
}
}
buildHttpResponse(httpRequest, httpResponse, result);
}
}
@@ -162,19 +151,109 @@ public class FunctionInvoker implements HttpFunction, RawBackgroundFunction {
* @param context event context.
* @since 3.0.5
*/
@SuppressWarnings("unchecked")
@Override
public void accept(String json, Context context) {
Function<Message<String>, Message<byte[]>> function = lookupFunction();
Message<String> message = this.functionWrapped.getInputType() == Void.class ? null
: MessageBuilder.withPayload(json).setHeader("gcf_context", context).build();
: MessageBuilder.withPayload(json).setHeader("gcf_context", context).build();
Message<byte[]> result = function.apply(message);
Object resultObject = function.apply(message);
Message<byte[]> result = null;
if (resultObject instanceof Publisher<?>) {
result = getResultFromPublisher(resultObject);
} else {
result = (Message<byte[]>) resultObject;
}
if (result != null) {
log.info("Dropping background function result: " + new String(result.getPayload()));
}
}
/**
* This method build the http response from service
*
* @throws IOException
*/
private void buildHttpResponse(HttpRequest httpRequest, HttpResponse httpResponse, Message<?> result)
throws IOException {
MessageHeaders headers = result.getHeaders();
if (result.getHeaders().containsKey(MessageHeaders.CONTENT_TYPE)) {
httpResponse.setContentType(result.getHeaders().get(MessageHeaders.CONTENT_TYPE).toString());
} else if (result.getHeaders().containsKey("Content-Type")) {
httpResponse.setContentType(result.getHeaders().get("Content-Type").toString());
} else {
httpRequest.getContentType().ifPresent(contentType -> httpResponse.setContentType(contentType));
}
String content = result.getPayload() instanceof String strPayload ? strPayload
: new String((byte[]) result.getPayload(), StandardCharsets.UTF_8);
httpResponse.getWriter().write(content);
for (Entry<String, Object> header : headers.entrySet()) {
Object values = header.getValue();
if (values instanceof Collection<?>) {
String headerValue = ((Collection<?>) values).stream().map(item -> item.toString())
.collect(Collectors.joining(","));
httpResponse.appendHeader(header.getKey(), headerValue);
} else {
httpResponse.appendHeader(header.getKey(), header.getValue().toString());
}
}
if (headers.containsKey(HTTP_STATUS_CODE)) {
if (headers.get(HTTP_STATUS_CODE) instanceof Integer) {
httpResponse.setStatusCode((int) headers.get(HTTP_STATUS_CODE));
} else {
log.warn("The statusCode should be an Integer value");
}
}
}
/**
* This methd get the result from reactor's publisher
*
* For reference: https://github.com/spring-cloud/spring-cloud-function/blob/main/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/main/java/org/springframework/cloud/function/adapter/aws/AWSLambdaUtils.java
*/
private Message<byte[]> getResultFromPublisher(Object resultObject) {
List<Object> results = new ArrayList<>();
Message<?> lastMessage = null;
for (Object item : Flux.from((Publisher<?>) resultObject).toIterable()) {
log.info("Response value: " + item);
if (item instanceof Message<?> messageItem) {
results.add(convertFromJsonIfNecessary(messageItem.getPayload()));
lastMessage = messageItem;
} else {
results.add(convertFromJsonIfNecessary(item));
}
}
byte[] resultsPayload;
if (results.size() == 1) {
resultsPayload = jsonMapper.toJson(results.get(0));
} else if (results.size() > 1) {
resultsPayload = jsonMapper.toJson(results);
} else {
resultsPayload = null;
}
Assert.notNull(resultsPayload, "Couldn't resolve payload result");
MessageBuilder<byte[]> messageBuilder = MessageBuilder.withPayload(resultsPayload);
if (lastMessage != null) {
messageBuilder.copyHeaders(lastMessage.getHeaders());
}
return messageBuilder.build();
}
private Object convertFromJsonIfNecessary(Object value) {
if (JsonMapper.isJsonString(value)) {
return jsonMapper.fromJson(value, Object.class);
}
return value;
}
private void initFunctionConsumerOrSupplierFromCatalog() {
String name = resolveName(Function.class);
this.functionWrapped = this.catalog.lookup(Function.class, name);

View File

@@ -22,6 +22,10 @@ import java.util.function.Supplier;
import com.github.blindpirate.extensions.CaptureSystemOutput;
import com.google.gson.Gson;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
@@ -58,6 +62,18 @@ public class FunctionInvokerBackgroundTests {
"Thank you for sending the message: hello", null, null);
}
@Test
public void testJsonInputFunction_BackgroundMono(CaptureSystemOutput.OutputCapture outputCapture) {
testBackgroundFunction(outputCapture, JsonInputFunctionMono.class, new IncomingRequest("hello"),
"Thank you for sending the message: hello", null, null);
}
@Test
public void testJsonInputFunction_BackgroundFlux(CaptureSystemOutput.OutputCapture outputCapture) {
testBackgroundFunction(outputCapture, JsonInputFunctionFlux.class, new IncomingRequest("hello"),
"Thank you for sending the message: hello", null, null);
}
@Test
public void testJsonInputOutputFunction_Background(CaptureSystemOutput.OutputCapture outputCapture) {
testBackgroundFunction(outputCapture, JsonInputOutputFunction.class, new IncomingRequest("hello"),
@@ -149,6 +165,28 @@ public class FunctionInvokerBackgroundTests {
}
@Configuration
@Import({ ContextFunctionCatalogAutoConfiguration.class })
protected static class JsonInputFunctionMono {
@Bean
public Function<Mono<IncomingRequest>, Mono<String>> function() {
return (in) -> in.map(o -> "Thank you for sending the message: " + o.message);
}
}
@Configuration
@Import({ ContextFunctionCatalogAutoConfiguration.class })
protected static class JsonInputFunctionFlux {
@Bean
public Function<Flux<IncomingRequest>, Flux<String>> function() {
return (in) -> in.map(o -> "Thank you for sending the message: " + o.message);
}
}
@Configuration
@Import({ ContextFunctionCatalogAutoConfiguration.class })
protected static class JsonInputOutputFunction {

View File

@@ -30,6 +30,10 @@ import java.util.function.Supplier;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.gson.Gson;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -90,6 +94,36 @@ public class FunctionInvokerHttpTests {
}
@Test
public void testJsonInputFunctionMono() throws Exception {
FunctionInvoker handler = new FunctionInvoker(JsonInputFunctionMono.class);
String expectedOutput = "Thank you for sending the message: hello";
IncomingRequest input = new IncomingRequest("hello");
when(request.getReader()).thenReturn(new BufferedReader(new StringReader(gson.toJson(input))));
handler.service(request, response);
bufferedWriter.close();
assertThat(writer.toString()).isEqualTo(gson.toJson(expectedOutput));
}
@Test
public void testJsonInputFunctionFlux() throws Exception {
FunctionInvoker handler = new FunctionInvoker(JsonInputFunctionFlux.class);
String expectedOutput = "hello!!!";
when(request.getReader()).thenReturn(new BufferedReader(new StringReader("hello")));
handler.service(request, response);
bufferedWriter.close();
assertThat(writer.toString()).isEqualTo(gson.toJson(expectedOutput));
}
@Test
public void testJsonInputFunction() throws Exception {
@@ -238,6 +272,28 @@ public class FunctionInvokerHttpTests {
}
@Configuration
@Import({ ContextFunctionCatalogAutoConfiguration.class })
protected static class JsonInputFunctionMono {
@Bean
public Function<Mono<IncomingRequest>, Mono<String>> function() {
return (in) -> in.map(o -> "Thank you for sending the message: " + o.message);
}
}
@Configuration
@Import({ ContextFunctionCatalogAutoConfiguration.class })
protected static class JsonInputFunctionFlux {
@Bean
public Function<Flux<String>, Flux<String>> function() {
return (in) -> in.map(word -> word + "!!!");
}
}
@Configuration
@Import({ ContextFunctionCatalogAutoConfiguration.class })
protected static class JsonInputOutputFunction {