diff --git a/docs/src/main/asciidoc/adapters/gcp-intro.adoc b/docs/src/main/asciidoc/adapters/gcp-intro.adoc index 5c6a06c06..1f72b19ed 100644 --- a/docs/src/main/asciidoc/adapters/gcp-intro.adoc +++ b/docs/src/main/asciidoc/adapters/gcp-intro.adoc @@ -136,6 +136,28 @@ Invoke the HTTP function: curl https://REGION-PROJECT_ID.cloudfunctions.net/function-sample-gcp-http -d "hello" ---- + +Setting custom HTTP statusCode: + +---- +Functions can specify a custom HTTP response code by setting the `FunctionInvoker.HTTP_STATUS_CODE` header. +---- + +[source, java] +---- +@Bean +public Function> function() { + + String payload = "hello"; + + Message message = MessageBuilder.withPayload(payload).setHeader(FunctionInvoker.HTTP_STATUS_CODE, 404).build(); + + return input -> message; +}; +---- + + + ==== Background Functions Google Cloud Functions also supports deploying https://cloud.google.com/functions/docs/writing/background[Background Functions] which are invoked indirectly in response to an event, such as a message on a https://cloud.google.com/pubsub[Cloud Pub/Sub] topic, a change in a https://cloud.google.com/storage[Cloud Storage] bucket, or a https://firebase.google.com/[Firebase] event. diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/FunctionInvoker.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/FunctionInvoker.java index 4dff00d8d..1d4e9109e 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/FunctionInvoker.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/FunctionInvoker.java @@ -18,8 +18,10 @@ package org.springframework.cloud.function.adapter.gcp; import java.io.BufferedReader; import java.nio.charset.StandardCharsets; +import java.util.Collection; import java.util.Map.Entry; import java.util.function.Function; +import java.util.stream.Collectors; import com.google.cloud.functions.Context; import com.google.cloud.functions.HttpFunction; @@ -32,6 +34,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.cloud.function.context.AbstractSpringFunctionAdapterInitializer; import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.support.MessageBuilder; import org.springframework.util.Assert; import org.springframework.util.MimeTypeUtils; @@ -51,6 +54,11 @@ public class FunctionInvoker extends AbstractSpringFunctionAdapterInitializer, Message> function = lookupFunction(); Message message = getInputType() == Void.class || getInputType() == null ? null - : MessageBuilder.withPayload(httpRequest.getReader()).copyHeaders(httpRequest.getHeaders()).build(); + : MessageBuilder.withPayload(httpRequest.getReader()).copyHeaders(httpRequest.getHeaders()).build(); + Message result = function.apply(message); if (result != null) { + MessageHeaders headers = result.getHeaders(); httpResponse.getWriter().write(new String(result.getPayload(), StandardCharsets.UTF_8)); - for (Entry header : result.getHeaders().entrySet()) { - httpResponse.appendHeader(header.getKey(), header.getValue().toString()); + for (Entry 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()); + } } httpRequest.getContentType().ifPresent(contentType -> httpResponse.setContentType(contentType)); + + 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"); + } + } } } diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/FunctionInvokerHttpTests.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/FunctionInvokerHttpTests.java index 57de8998e..eff309bfb 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/FunctionInvokerHttpTests.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/FunctionInvokerHttpTests.java @@ -18,8 +18,11 @@ package org.springframework.cloud.function.adapter.gcp; import java.io.BufferedReader; import java.io.BufferedWriter; +import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -27,9 +30,13 @@ import java.util.function.Supplier; import com.google.cloud.functions.HttpRequest; import com.google.cloud.functions.HttpResponse; import com.google.gson.Gson; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mockito; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -37,7 +44,9 @@ import org.springframework.context.annotation.Import; import org.springframework.messaging.Message; import org.springframework.messaging.support.MessageBuilder; +import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -46,55 +55,114 @@ import static org.mockito.Mockito.when; * @author Dmitry Solomakha * @author Mike Eltsufin */ + +@ExtendWith(OutputCaptureExtension.class) public class FunctionInvokerHttpTests { private static final Gson gson = new Gson(); + private HttpRequest request; + private HttpResponse response; + private BufferedWriter bufferedWriter; + private StringWriter writer; + + @BeforeEach + void testSetup() throws IOException { + request = Mockito.mock(HttpRequest.class); + response = Mockito.mock(HttpResponse.class); + writer = new StringWriter(); + bufferedWriter = new BufferedWriter(writer); + when(response.getWriter()).thenReturn(bufferedWriter); + } @Test public void testHelloWorldSupplier() throws Exception { - testHttpFunction(HelloWorldSupplier.class, null, "Hello World!"); + + String expectedOutput = "Hello World!"; + FunctionInvoker handler = new FunctionInvoker(HelloWorldSupplier.class); + + handler.service(request, response); + bufferedWriter.close(); + + + assertThat(writer.toString()).isEqualTo(gson.toJson(expectedOutput)); + + } @Test public void testJsonInputFunction() throws Exception { - testHttpFunction(JsonInputFunction.class, new IncomingRequest("hello"), - "Thank you for sending the message: hello"); + + FunctionInvoker handler = new FunctionInvoker(JsonInputFunction.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 testJsonInputOutputFunction() throws Exception { - testHttpFunction(JsonInputOutputFunction.class, new IncomingRequest("hello"), - new OutgoingResponse("Thank you for sending the message: hello")); + + FunctionInvoker handler = new FunctionInvoker(JsonInputOutputFunction.class); + + OutgoingResponse expectedOutput = new OutgoingResponse("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 testJsonInputConsumer_Background() throws Exception { - testHttpFunction(JsonInputConsumer.class, new IncomingRequest("hello"), null); + public void testJsonInputConsumer_Background(CapturedOutput capturedOutput) throws Exception { + + FunctionInvoker handler = new FunctionInvoker(JsonInputConsumer.class); + + IncomingRequest input = new IncomingRequest("hello"); + + when(request.getReader()).thenReturn(new BufferedReader(new StringReader(gson.toJson(input)))); + handler.service(request, response); + bufferedWriter.close(); + + assertThat(capturedOutput.toString()).contains("Thank you for sending the message: hello"); + } - private void testHttpFunction(Class configurationClass, I input, O expectedOutput) throws Exception { - try (FunctionInvoker handler = new FunctionInvoker(configurationClass);) { + @Test + public void testStatusCodeSet() throws Exception { - HttpRequest request = Mockito.mock(HttpRequest.class); + FunctionInvoker handler = new FunctionInvoker(StatusCodeSupplier.class); + String input = "hello"; + when(request.getReader()).thenReturn(new BufferedReader(new StringReader(gson.toJson(input)))); + handler.service(request, response); + bufferedWriter.close(); - if (input != null) { - when(request.getReader()).thenReturn(new BufferedReader(new StringReader(gson.toJson(input)))); - } + verify(response).setStatusCode(404); - HttpResponse response = Mockito.mock(HttpResponse.class); - StringWriter writer = new StringWriter(); + } - BufferedWriter bufferedWriter = new BufferedWriter(writer); - when(response.getWriter()).thenReturn(bufferedWriter); - handler.service(request, response); + @Test + public void testMultiValueHeaderSupplied() throws Exception { - // Closing the writer is done by the Framework/caller. - bufferedWriter.close(); + FunctionInvoker handler = new FunctionInvoker(MultiValueHeaderSupplier.class); + String input = "hello"; + when(request.getReader()).thenReturn(new BufferedReader(new StringReader(gson.toJson(input)))); + handler.service(request, response); + bufferedWriter.close(); - if (expectedOutput != null) { - assertThat(writer.toString()).isEqualTo(gson.toJson(expectedOutput)); - } - } + verify(response).appendHeader("multiValueHeader", "123,headerThing"); } @Configuration @@ -108,6 +176,41 @@ public class FunctionInvokerHttpTests { } + @Configuration + @Import({ ContextFunctionCatalogAutoConfiguration.class }) + protected static class StatusCodeSupplier { + + @Bean + public Function> function() { + + String payload = "hello"; + + Message msg = MessageBuilder.withPayload(payload).setHeader("statusCode", 404) + .build(); + + return x -> msg; + }; + + } + + @Configuration + @Import({ ContextFunctionCatalogAutoConfiguration.class }) + protected static class MultiValueHeaderSupplier { + + @Bean + public Function> function() { + + String payload = "hello"; + List li = new ArrayList(asList(123, "headerThing")); + + Message msg = MessageBuilder.withPayload(payload).setHeader("multiValueHeader", li) + .build(); + + return x -> msg; + }; + + } + @Configuration @Import({ ContextFunctionCatalogAutoConfiguration.class }) protected static class JsonInputFunction { @@ -131,7 +234,6 @@ public class FunctionInvokerHttpTests { .setHeader("foo", "bar").build(); }; } - } @Configuration