From 1af0d451cf2e17549ad7dc3f254f63612cfd00e9 Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Tue, 8 Aug 2017 08:27:04 +0100 Subject: [PATCH] Migrate to servlet binder for web features --- README.adoc | 10 +- docs/src/main/asciidoc/getting-started.adoc | 11 +- pom.xml | 1 - .../README.adoc | 2 +- .../aws/SpringFunctionInitializer.java | 2 +- .../aws/SpringBootRequestHandlerTests.java | 2 +- .../aws/SpringBootStreamHandlerTests.java | 2 +- .../aws/SpringFunctionInitializerTests.java | 2 +- .../openwhisk/FunctionInitializer.java | 2 +- .../context/catalog/FunctionCatalogEvent.java | 26 +- .../{ => catalog}/FunctionInspector.java | 2 +- .../catalog/FunctionRegistrationEvent.java | 45 ++ .../catalog/FunctionUnregistrationEvent.java | 45 ++ .../InMemoryFunctionCatalog.java | 66 ++- ...ntextFunctionCatalogAutoConfiguration.java | 49 +- .../main/resources/META-INF/spring.factories | 2 +- .../BeanFactoryFunctionCatalogTests.java | 7 +- ...FunctionCatalogAutoConfigurationTests.java | 5 +- .../ContextFunctionPostProcessorTests.java | 45 +- spring-cloud-function-deployer/pom.xml | 9 +- .../deployer/DeployedApplicationFilter.java | 84 ++++ .../FunctionExtractingAutoConfiguration.java | 2 +- .../FunctionExtractingFunctionCatalog.java | 165 +++++-- .../resources/META-INF/thin-slim.properties | 2 +- .../function/deployer/AdhocTestSuite.java | 5 +- .../deployer/FunctionAppDeployerTests.java | 2 +- ...actingFunctionCatalogIntegrationTests.java | 32 +- .../function-sample-aws/pom.xml | 13 +- .../main/resources/META-INF/thin.properties | 3 +- .../function-sample-compiler/pom.xml | 11 +- .../example/SampleCompiledConsumerTests.java | 17 +- .../example/SampleCompiledFunctionTests.java | 2 +- .../function-sample-pof/pom.xml | 6 +- .../function-sample-pojo/pom.xml | 6 +- .../com/example/SampleApplicationTests.java | 6 +- .../function-sample-task/pom.xml | 6 +- .../function-sample/pom.xml | 4 +- .../resources/META-INF/thin-rabbit.properties | 9 + .../resources/META-INF/thin-stream.properties | 3 - spring-cloud-function-stream/pom.xml | 10 +- .../SupplierInvokingMessageProducer.java | 61 --- .../RouteRegistryAutoConfiguration.java | 42 ++ .../StreamAutoConfiguration.java} | 6 +- .../StreamConfigurationProperties.java | 2 +- .../StreamListeningFunctionInvoker.java | 10 +- .../SupplierInvokingMessageProducer.java | 110 +++++ .../main/resources/META-INF/spring.factories | 3 +- .../PojoStreamingExplicitEndpointTests.java | 2 +- .../stream/mixed/PojoStreamingMixedTests.java | 2 +- .../function/stream}/scan/ComponentTests.java | 7 +- .../supplier/RestartStreamSupplierTests.java | 94 ++++ .../stream/supplier/StreamSupplierTests.java | 2 +- .../function/stream}/web/PrefixTests.java | 10 +- .../stream}/web/RestApplicationTests.java | 167 ++++--- .../function/stream}/web/SingletonTests.java | 52 +- spring-cloud-function-web/pom.xml | 50 -- .../function/web/flux/FunctionController.java | 123 ----- .../web/flux/FunctionHandlerMapping.java | 161 ------ .../web/flux/ReactorAutoConfiguration.java | 114 ----- .../flux/constants/WebRequestConstants.java | 39 -- .../web/flux/request/DelegateHandler.java | 53 -- .../FluxHandlerMethodArgumentResolver.java | 134 ----- .../response/FluxResponseBodyEmitter.java | 60 --- .../flux/response/FluxResponseSseEmitter.java | 55 --- .../flux/response/FluxReturnValueHandler.java | 267 ---------- .../ResponseBodyEmitterSubscriber.java | 212 -------- ...itional-spring-configuration-metadata.json | 9 - .../main/resources/META-INF/spring.factories | 2 - .../function/mvc/MvcRestApplicationTests.java | 392 --------------- .../src/test/resources/static/test.html | 1 - .../.jdk8 | 0 spring-cloud-stream-binder-servlet/README.md | 39 ++ spring-cloud-stream-binder-servlet/pom.xml | 126 +++++ .../binder/servlet/EnabledBindings.java | 24 +- .../stream/binder/servlet}/HeaderUtils.java | 7 +- .../stream/binder/servlet/JsonUtils.java | 86 ++++ .../binder/servlet/MessageController.java | 457 ++++++++++++++++++ .../stream/binder/servlet/RouteRegistrar.java | 30 ++ .../stream/binder/servlet/RouteRegistry.java | 27 ++ .../servlet/ServletMessageChannelBinder.java | 55 +++ .../config/BeanFactoryEnabledBindings.java | 120 +++++ .../MessageHandlingAutoConfiguration.java | 105 ++++ .../ServletServiceAutoConfiguration.java | 53 ++ .../stream/binder/servlet/package-info.java | 5 + .../main/resources/META-INF/spring.binders | 2 + .../main/resources/META-INF/spring.factories | 2 + .../stream/binder/servlet/JsonUtilsTests.java | 79 +++ .../DoubleSinkMessageChannelBinderTests.java | 101 ++++ ...derProcessorMessageChannelBinderTests.java | 75 +++ ...tedProcessorMessageChannelBinderTests.java | 72 +++ ...medProcessorMessageChannelBinderTests.java | 116 +++++ .../NamedSinkMessageChannelBinderTests.java | 79 +++ .../NamedSourceMessageChannelBinderTests.java | 65 +++ ...ojoProcessorMessageChannelBinderTests.java | 106 ++++ .../PojoSinkMessageChannelBinderTests.java | 113 +++++ .../test/PrefixMessageChannelBinderTests.java | 79 +++ .../ProcessorMessageChannelBinderTests.java | 138 ++++++ ...tedProcessorMessageChannelBinderTests.java | 99 ++++ .../RoutedSinkMessageChannelBinderTests.java | 83 ++++ ...RoutedSourceMessageChannelBinderTests.java | 75 +++ ...AndProcessorMessageChannelBinderTests.java | 96 ++++ .../test/SinkMessageChannelBinderTests.java | 111 +++++ ...WithResponseMessageChannelBinderTests.java | 77 +++ ...AndProcessorMessageChannelBinderTests.java | 107 ++++ .../test/SourceMessageChannelBinderTests.java | 94 ++++ .../SseSourceMessageChannelBinderTests.java | 195 ++++++++ .../src/test/resources/application.properties | 2 + 107 files changed, 4055 insertions(+), 2010 deletions(-) rename spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/FluxRequest.java => spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionCatalogEvent.java (65%) rename spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/{ => catalog}/FunctionInspector.java (95%) create mode 100644 spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionRegistrationEvent.java create mode 100644 spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionUnregistrationEvent.java rename spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/{ => catalog}/InMemoryFunctionCatalog.java (60%) rename spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/{ => config}/ContextFunctionCatalogAutoConfiguration.java (93%) rename spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/{ => config}/BeanFactoryFunctionCatalogTests.java (91%) rename spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/{ => config}/ContextFunctionCatalogAutoConfigurationTests.java (98%) rename spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/{ => config}/ContextFunctionPostProcessorTests.java (89%) create mode 100644 spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/DeployedApplicationFilter.java create mode 100644 spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-rabbit.properties delete mode 100644 spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-stream.properties delete mode 100644 spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/SupplierInvokingMessageProducer.java create mode 100644 spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/RouteRegistryAutoConfiguration.java rename spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/{StreamConfiguration.java => config/StreamAutoConfiguration.java} (94%) rename spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/{ => config}/StreamConfigurationProperties.java (96%) rename spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/{ => config}/StreamListeningFunctionInvoker.java (95%) create mode 100644 spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/SupplierInvokingMessageProducer.java rename {spring-cloud-function-web/src/test/java/org/springframework/cloud/function => spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream}/scan/ComponentTests.java (90%) create mode 100644 spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/supplier/RestartStreamSupplierTests.java rename {spring-cloud-function-web/src/test/java/org/springframework/cloud/function => spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream}/web/PrefixTests.java (87%) rename {spring-cloud-function-web/src/test/java/org/springframework/cloud/function => spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream}/web/RestApplicationTests.java (76%) rename {spring-cloud-function-web/src/test/java/org/springframework/cloud/function => spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream}/web/SingletonTests.java (63%) delete mode 100644 spring-cloud-function-web/pom.xml delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/FunctionController.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/FunctionHandlerMapping.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/ReactorAutoConfiguration.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/constants/WebRequestConstants.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/DelegateHandler.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/FluxHandlerMethodArgumentResolver.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxResponseBodyEmitter.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxResponseSseEmitter.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxReturnValueHandler.java delete mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/ResponseBodyEmitterSubscriber.java delete mode 100644 spring-cloud-function-web/src/main/resources/META-INF/additional-spring-configuration-metadata.json delete mode 100644 spring-cloud-function-web/src/main/resources/META-INF/spring.factories delete mode 100644 spring-cloud-function-web/src/test/java/org/springframework/cloud/function/mvc/MvcRestApplicationTests.java delete mode 100644 spring-cloud-function-web/src/test/resources/static/test.html rename {spring-cloud-function-web => spring-cloud-stream-binder-servlet}/.jdk8 (100%) create mode 100644 spring-cloud-stream-binder-servlet/README.md create mode 100644 spring-cloud-stream-binder-servlet/pom.xml rename spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/RestApplication.java => spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/EnabledBindings.java (60%) rename {spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/util => spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet}/HeaderUtils.java (93%) create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/JsonUtils.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/MessageController.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/RouteRegistrar.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/RouteRegistry.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/ServletMessageChannelBinder.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/BeanFactoryEnabledBindings.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/MessageHandlingAutoConfiguration.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/ServletServiceAutoConfiguration.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/package-info.java create mode 100644 spring-cloud-stream-binder-servlet/src/main/resources/META-INF/spring.binders create mode 100644 spring-cloud-stream-binder-servlet/src/main/resources/META-INF/spring.factories create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/JsonUtilsTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/DoubleSinkMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/HeaderProcessorMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/HeadersDroppedRoutedProcessorMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedProcessorMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedSinkMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedSourceMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PojoProcessorMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PojoSinkMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PrefixMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/ProcessorMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedProcessorMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedSinkMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedSourceMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkAndProcessorMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkWithResponseMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SourceAndProcessorMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SourceMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SseSourceMessageChannelBinderTests.java create mode 100644 spring-cloud-stream-binder-servlet/src/test/resources/application.properties diff --git a/README.adoc b/README.adoc index 3d00d92b6..6c5905563 100644 --- a/README.adoc +++ b/README.adoc @@ -101,12 +101,12 @@ JMS. The `@Beans` can be `Function`, `Consumer` or `Supplier` (all from `java.util`), and their parametric types can be String or POJO. A -`Function` is exposed as an HTTP POST if `spring-cloud-function-web` -is on the classpath, and as a Spring Cloud Stream `Processor` if +`Function` is exposed as a `Processor` if `spring-cloud-function-stream` is on the classpath and a -`spring.cloud.function.stream.endpoint` property is configured in the Spring -environment. A `Consumer` is also exposed as an HTTP POST, or as a Stream -`Sink`. A `Supplier` translates to an HTTP GET, or a Stream `Source`. +`spring.cloud.function.stream.endpoint` property is configured in the +Spring environment. A `Consumer` is also exposed as an HTTP POST, or +as a Stream `Sink`. A `Supplier` translates to an HTTP GET, or a +Stream `Source`. Functions can be of `Flux` or `Flux` and Spring Cloud Function takes care of converting the data to and from the desired diff --git a/docs/src/main/asciidoc/getting-started.adoc b/docs/src/main/asciidoc/getting-started.adoc index f09bd5f67..3e08f3352 100644 --- a/docs/src/main/asciidoc/getting-started.adoc +++ b/docs/src/main/asciidoc/getting-started.adoc @@ -41,12 +41,11 @@ JMS. The `@Beans` can be `Function`, `Consumer` or `Supplier` (all from `java.util`), and their parametric types can be String or POJO. A -`Function` is exposed as an HTTP POST if `spring-cloud-function-web` -is on the classpath, and as a Spring Cloud Stream `Processor` if -`spring-cloud-function-stream` is on the classpath and a -`spring.cloud.function.stream.endpoint` property is configured in the Spring -environment. A `Consumer` is also exposed as an HTTP POST, or as a Stream -`Sink`. A `Supplier` translates to an HTTP GET, or a Stream `Source`. +`Function` is exposed as a Spring Cloud Stream `Processor` if +`spring-cloud-function-stream` is on the classpath. +A `Consumer` is also exposed as a Stream +`Sink` and a `Supplier` translates to a Stream `Source`. +HTTP endpoints are exposed if the Stream binder is `spring-cloud-stream-binder-servlet`. Functions can be of `Flux` or `Flux` and Spring Cloud Function takes care of converting the data to and from the desired diff --git a/pom.xml b/pom.xml index d936aa831..eb71b1df5 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,6 @@ spring-cloud-function-context spring-cloud-function-stream spring-cloud-function-task - spring-cloud-function-web spring-cloud-function-samples spring-cloud-function-deployer spring-cloud-function-adapters diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/README.adoc b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/README.adoc index b3483cb03..391072ae4 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/README.adoc +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/README.adoc @@ -1,4 +1,4 @@ -This project provides an adapter layer for a Spring Cloud Function application onto AWS Lambda. You can write an app with a single `@Bean` of type `Function`, `Consumer` or `Supplier` and it will be deployable in AWS if you get the JAR file laid out right. The best way to make it work is to include `spring-cloud-function-context` as a dependency, but not the higher level adapters (e.g. `spring-cloud-function-web` or `spring-cloud-function-stream`). +This project provides an adapter layer for a Spring Cloud Function application onto AWS Lambda. You can write an app with a single `@Bean` of type `Function`, `Consumer` or `Supplier` and it will be deployable in AWS if you get the JAR file laid out right. The best way to make it work is to include `spring-cloud-function-context` as a dependency, but not the higher level adapters (e.g. `spring-cloud-function-stream`). The adapter has a couple of generic request handlers that you can use. The most generic is `SpringBootStreamHandler`, which uses a Jackson `ObjectMapper` provided by Spring Boot to serialize and deserialize the objects in the function. There is also a `SpringBootRequestHandler` which you can extend, and provide the input and output types as type parameters (enabling AWS to inspect the class and do the JSON conversions itself). diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/main/java/org/springframework/cloud/function/adapter/aws/SpringFunctionInitializer.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/main/java/org/springframework/cloud/function/adapter/aws/SpringFunctionInitializer.java index 4bc602039..9227535eb 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/main/java/org/springframework/cloud/function/adapter/aws/SpringFunctionInitializer.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/main/java/org/springframework/cloud/function/adapter/aws/SpringFunctionInitializer.java @@ -32,7 +32,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.builder.SpringApplicationBuilder; -import org.springframework.cloud.function.context.FunctionInspector; +import org.springframework.cloud.function.context.catalog.FunctionInspector; import org.springframework.cloud.function.core.FunctionCatalog; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.util.ClassUtils; diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringBootRequestHandlerTests.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringBootRequestHandlerTests.java index aa7e7fceb..f192475f0 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringBootRequestHandlerTests.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringBootRequestHandlerTests.java @@ -21,7 +21,7 @@ import java.util.function.Function; import org.junit.Test; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; -import org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfiguration; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringBootStreamHandlerTests.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringBootStreamHandlerTests.java index d9401f4b6..58cc82553 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringBootStreamHandlerTests.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringBootStreamHandlerTests.java @@ -23,7 +23,7 @@ import java.util.function.Function; import org.junit.Test; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; -import org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfiguration; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringFunctionInitializerTests.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringFunctionInitializerTests.java index 77065d3a8..84be63958 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringFunctionInitializerTests.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/test/java/org/springframework/cloud/function/adapter/aws/SpringFunctionInitializerTests.java @@ -24,7 +24,7 @@ import java.util.stream.Collectors; import org.junit.After; import org.junit.Test; -import org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfiguration; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-openwhisk/src/main/java/org/springframework/cloud/function/adapter/openwhisk/FunctionInitializer.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-openwhisk/src/main/java/org/springframework/cloud/function/adapter/openwhisk/FunctionInitializer.java index 510b39e03..f12f9caa2 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-openwhisk/src/main/java/org/springframework/cloud/function/adapter/openwhisk/FunctionInitializer.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-openwhisk/src/main/java/org/springframework/cloud/function/adapter/openwhisk/FunctionInitializer.java @@ -22,7 +22,7 @@ import java.util.function.Function; import java.util.function.Supplier; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.cloud.function.context.FunctionInspector; +import org.springframework.cloud.function.context.catalog.FunctionInspector; import org.springframework.cloud.function.core.FluxFunction; import org.springframework.cloud.function.core.FunctionCatalog; import org.springframework.cloud.function.core.FunctionFactoryUtils; diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/FluxRequest.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionCatalogEvent.java similarity index 65% rename from spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/FluxRequest.java rename to spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionCatalogEvent.java index 00b2225fc..2aeff2d71 100644 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/FluxRequest.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionCatalogEvent.java @@ -13,33 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package org.springframework.cloud.function.context.catalog; -package org.springframework.cloud.function.web.flux.request; - -import java.util.List; - -import reactor.core.publisher.Flux; +import org.springframework.context.ApplicationEvent; /** * @author Dave Syer * */ -public class FluxRequest { - - private List body; +@SuppressWarnings("serial") +public class FunctionCatalogEvent extends ApplicationEvent { - public FluxRequest(List body) { - this.body = body; + public FunctionCatalogEvent(Object source) { + super(source); } - public Flux flux() { - return Flux.fromIterable(body); - } - - public List body() { - return body; - } - } - - diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/FunctionInspector.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionInspector.java similarity index 95% rename from spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/FunctionInspector.java rename to spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionInspector.java index bbb2dd2b7..dbd975025 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/FunctionInspector.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionInspector.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.context; +package org.springframework.cloud.function.context.catalog; import java.lang.reflect.Type; import java.util.Optional; diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionRegistrationEvent.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionRegistrationEvent.java new file mode 100644 index 000000000..0d3cb1360 --- /dev/null +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionRegistrationEvent.java @@ -0,0 +1,45 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.function.context.catalog; + +import java.util.HashSet; +import java.util.Set; + +/** + * @author Dave Syer + * + */ +@SuppressWarnings("serial") +public class FunctionRegistrationEvent extends FunctionCatalogEvent { + + private final Class type; + private final Set names; + + public FunctionRegistrationEvent(Object source, Class type, Set names) { + super(source); + this.type = type; + this.names = new HashSet<>(names); + } + + public Class getType() { + return type; + } + + public Set getNames() { + return names; + } + +} diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionUnregistrationEvent.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionUnregistrationEvent.java new file mode 100644 index 000000000..dec1b3fa3 --- /dev/null +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/FunctionUnregistrationEvent.java @@ -0,0 +1,45 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.function.context.catalog; + +import java.util.HashSet; +import java.util.Set; + +/** + * @author Dave Syer + * + */ +@SuppressWarnings("serial") +public class FunctionUnregistrationEvent extends FunctionCatalogEvent { + + private final Class type; + private final Set names; + + public FunctionUnregistrationEvent(Object source, Class type, Set names) { + super(source); + this.type = type; + this.names = new HashSet<>(names); + } + + public Class getType() { + return type; + } + + public Set getNames() { + return names; + } + +} diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/InMemoryFunctionCatalog.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/InMemoryFunctionCatalog.java similarity index 60% rename from spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/InMemoryFunctionCatalog.java rename to spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/InMemoryFunctionCatalog.java index 0963c02ba..528c344fb 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/InMemoryFunctionCatalog.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/InMemoryFunctionCatalog.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.context; +package org.springframework.cloud.function.context.catalog; import java.util.Collections; import java.util.HashMap; @@ -24,7 +24,14 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.function.context.FunctionRegistration; +import org.springframework.cloud.function.context.FunctionRegistry; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.util.Assert; /** @@ -32,7 +39,8 @@ import org.springframework.util.Assert; * @author Mark Fisher * @author Oleg Zhurakousky */ -public class InMemoryFunctionCatalog implements FunctionRegistry { +public class InMemoryFunctionCatalog + implements FunctionRegistry, ApplicationEventPublisherAware { private final Map> functions; @@ -40,11 +48,13 @@ public class InMemoryFunctionCatalog implements FunctionRegistry { private final Map> suppliers; + @Autowired(required = false) + private ApplicationEventPublisher publisher; + public InMemoryFunctionCatalog() { this(Collections.emptySet()); } - - @Autowired(required=false) + public InMemoryFunctionCatalog(Set> registrations) { Assert.notNull(registrations, "'registrations' must not be null"); this.suppliers = new HashMap<>(); @@ -66,20 +76,68 @@ public class InMemoryFunctionCatalog implements FunctionRegistry { @Override public void register(FunctionRegistration registration) { Map values = null; + FunctionRegistrationEvent event; if (registration.getTarget() instanceof Function) { values = this.functions; + event = new FunctionRegistrationEvent(this, Function.class, + registration.getNames()); } else if (registration.getTarget() instanceof Supplier) { values = this.suppliers; + event = new FunctionRegistrationEvent(this, Supplier.class, + registration.getNames()); } else { values = this.consumers; + event = new FunctionRegistrationEvent(this, Consumer.class, + registration.getNames()); } @SuppressWarnings("unchecked") Map map = (Map) values; for (String name : registration.getNames()) { map.put(name, registration.getTarget()); } + publisher.publishEvent(event); + } + + public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + @PostConstruct + public void init() { + if (publisher != null) { + if (!functions.isEmpty()) { + publisher.publishEvent(new FunctionRegistrationEvent(this, Function.class, + functions.keySet())); + } + if (!consumers.isEmpty()) { + publisher.publishEvent(new FunctionRegistrationEvent(this, Consumer.class, + consumers.keySet())); + } + if (!suppliers.isEmpty()) { + publisher.publishEvent(new FunctionRegistrationEvent(this, Supplier.class, + suppliers.keySet())); + } + } + } + + @PreDestroy + public void close() { + if (publisher != null) { + if (!functions.isEmpty()) { + publisher.publishEvent(new FunctionUnregistrationEvent(this, + Function.class, functions.keySet())); + } + if (!consumers.isEmpty()) { + publisher.publishEvent(new FunctionUnregistrationEvent(this, + Consumer.class, consumers.keySet())); + } + if (!suppliers.isEmpty()) { + publisher.publishEvent(new FunctionUnregistrationEvent(this, + Supplier.class, suppliers.keySet())); + } + } } @Override diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/ContextFunctionCatalogAutoConfiguration.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfiguration.java similarity index 93% rename from spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/ContextFunctionCatalogAutoConfiguration.java rename to spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfiguration.java index a4ffae496..3b64b7131 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/ContextFunctionCatalogAutoConfiguration.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfiguration.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.context; +package org.springframework.cloud.function.context.config; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -34,6 +34,8 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; +import javax.annotation.PreDestroy; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.config.BeanDefinition; @@ -42,6 +44,13 @@ import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueH import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.cloud.function.context.FunctionRegistration; +import org.springframework.cloud.function.context.FunctionRegistry; +import org.springframework.cloud.function.context.FunctionScan; +import org.springframework.cloud.function.context.catalog.FunctionInspector; +import org.springframework.cloud.function.context.catalog.FunctionRegistrationEvent; +import org.springframework.cloud.function.context.catalog.FunctionUnregistrationEvent; +import org.springframework.cloud.function.context.catalog.InMemoryFunctionCatalog; import org.springframework.cloud.function.core.FluxConsumer; import org.springframework.cloud.function.core.FluxFunction; import org.springframework.cloud.function.core.FluxSupplier; @@ -51,6 +60,7 @@ import org.springframework.cloud.function.core.FunctionFactoryUtils; import org.springframework.cloud.function.core.IsolatedConsumer; import org.springframework.cloud.function.core.IsolatedFunction; import org.springframework.cloud.function.core.IsolatedSupplier; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.ResolvableType; @@ -112,32 +122,38 @@ public class ContextFunctionCatalogAutoConfiguration { processor.register(registration); } + @Override @SuppressWarnings("unchecked") public Supplier lookupSupplier(String name) { Supplier result = (Supplier) processor.lookupSupplier(name); return result; } + @Override @SuppressWarnings("unchecked") public Function lookupFunction(String name) { Function result = (Function) processor.lookupFunction(name); return result; } + @Override @SuppressWarnings("unchecked") public Consumer lookupConsumer(String name) { Consumer result = (Consumer) processor.lookupConsumer(name); return result; } + @Override public Set getSupplierNames() { return this.processor.getSuppliers(); } + @Override public Set getFunctionNames() { return this.processor.getFunctions(); } + @Override public Set getConsumerNames() { return this.processor.getConsumers(); } @@ -202,6 +218,8 @@ public class ContextFunctionCatalogAutoConfiguration { private Map consumers = new HashMap<>(); + @Autowired(required = false) + private ApplicationEventPublisher publisher; @Autowired private ConfigurableListableBeanFactory registry; @@ -339,6 +357,24 @@ public class ContextFunctionCatalogAutoConfiguration { function.getNames().iterator().next()); } + @PreDestroy + public void close() { + if (publisher != null) { + if (!functions.isEmpty()) { + publisher.publishEvent(new FunctionUnregistrationEvent(this, + Function.class, functions.keySet())); + } + if (!consumers.isEmpty()) { + publisher.publishEvent(new FunctionUnregistrationEvent(this, + Consumer.class, consumers.keySet())); + } + if (!suppliers.isEmpty()) { + publisher.publishEvent(new FunctionUnregistrationEvent(this, + Supplier.class, suppliers.keySet())); + } + } + } + public Set> merge( Map> initial, Map> consumers, Map> suppliers, @@ -417,7 +453,9 @@ public class ContextFunctionCatalogAutoConfiguration { private void wrap(FunctionRegistration registration, String key) { Object target = registration.getTarget(); this.registrations.put(target, key); + Class type; if (target instanceof Supplier) { + type = Supplier.class; findType(target, ParamType.OUTPUT); registration.target(target((Supplier) target, key)); for (String name : registration.getNames()) { @@ -425,6 +463,7 @@ public class ContextFunctionCatalogAutoConfiguration { } } else if (target instanceof Consumer) { + type = Consumer.class; findType(target, ParamType.INPUT); registration.target(target((Consumer) target, key)); for (String name : registration.getNames()) { @@ -432,6 +471,7 @@ public class ContextFunctionCatalogAutoConfiguration { } } else if (target instanceof Function) { + type = Function.class; findType(target, ParamType.INPUT); findType(target, ParamType.OUTPUT); registration.target(target((Function) target, key)); @@ -439,8 +479,15 @@ public class ContextFunctionCatalogAutoConfiguration { this.functions.put(name, (Function) registration.getTarget()); } } + else { + return; + } registrations.remove(target); this.registrations.put(registration.getTarget(), key); + if (publisher != null) { + publisher.publishEvent(new FunctionRegistrationEvent( + registration.getTarget(), type, registration.getNames())); + } } private String getQualifier(String key) { diff --git a/spring-cloud-function-context/src/main/resources/META-INF/spring.factories b/spring-cloud-function-context/src/main/resources/META-INF/spring.factories index d00ef6707..f3a0792fc 100644 --- a/spring-cloud-function-context/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-function-context/src/main/resources/META-INF/spring.factories @@ -1,2 +1,2 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ -org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfiguration \ No newline at end of file +org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration \ No newline at end of file diff --git a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/BeanFactoryFunctionCatalogTests.java b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/BeanFactoryFunctionCatalogTests.java similarity index 91% rename from spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/BeanFactoryFunctionCatalogTests.java rename to spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/BeanFactoryFunctionCatalogTests.java index db13e1498..290b6c569 100644 --- a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/BeanFactoryFunctionCatalogTests.java +++ b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/BeanFactoryFunctionCatalogTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.context; +package org.springframework.cloud.function.context.config; import java.util.ArrayList; import java.util.List; @@ -24,8 +24,9 @@ import java.util.function.Supplier; import org.junit.Test; -import org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfiguration.BeanFactoryFunctionCatalog; -import org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfiguration.ContextFunctionRegistry; +import org.springframework.cloud.function.context.FunctionRegistration; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration.BeanFactoryFunctionCatalog; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration.ContextFunctionRegistry; import static org.assertj.core.api.Assertions.assertThat; diff --git a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/ContextFunctionCatalogAutoConfigurationTests.java b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfigurationTests.java similarity index 98% rename from spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/ContextFunctionCatalogAutoConfigurationTests.java rename to spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfigurationTests.java index 067d82fe7..c35c13472 100644 --- a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/ContextFunctionCatalogAutoConfigurationTests.java +++ b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/ContextFunctionCatalogAutoConfigurationTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.context; +package org.springframework.cloud.function.context.config; import static org.assertj.core.api.Assertions.assertThat; @@ -43,6 +43,9 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.cloud.function.compiler.CompiledFunctionFactory; import org.springframework.cloud.function.compiler.FunctionCompiler; +import org.springframework.cloud.function.context.FunctionRegistration; +import org.springframework.cloud.function.context.FunctionScan; +import org.springframework.cloud.function.context.catalog.FunctionInspector; import org.springframework.cloud.function.core.FunctionCatalog; import org.springframework.cloud.function.scan.ScannedFunction; import org.springframework.cloud.function.test.GenericFunction; diff --git a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/ContextFunctionPostProcessorTests.java b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/ContextFunctionPostProcessorTests.java similarity index 89% rename from spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/ContextFunctionPostProcessorTests.java rename to spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/ContextFunctionPostProcessorTests.java index 75e28e4fd..2550e768c 100644 --- a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/ContextFunctionPostProcessorTests.java +++ b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/config/ContextFunctionPostProcessorTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.context; +package org.springframework.cloud.function.context.config; import java.net.URLClassLoader; import java.util.ArrayList; @@ -28,10 +28,10 @@ import org.junit.After; import org.junit.Test; import org.springframework.beans.BeanUtils; -import org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfiguration.ContextFunctionRegistry; -import org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfigurationTests.Bar; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.ClassUtils; +import org.springframework.cloud.function.context.FunctionRegistration; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration.ContextFunctionRegistry; import static org.assertj.core.api.Assertions.assertThat; @@ -189,4 +189,43 @@ public class ContextFunctionPostProcessorTests { } + public static class Foo { + private String value; + + public Foo(String value) { + this.value = value; + } + + Foo() { + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + public static class Bar { + private String message; + + public Bar(String value) { + this.message = value; + } + + Bar() { + } + + public String getMessage() { + return this.message; + } + + public void setMessage(String message) { + this.message = message; + } + + } + } diff --git a/spring-cloud-function-deployer/pom.xml b/spring-cloud-function-deployer/pom.xml index 21ab542d3..a5492101e 100644 --- a/spring-cloud-function-deployer/pom.xml +++ b/spring-cloud-function-deployer/pom.xml @@ -16,6 +16,7 @@ 1.0.8.RELEASE + 1.0.0.BUILD-SNAPSHOT @@ -25,8 +26,12 @@ org.springframework.cloud - spring-cloud-function-web - runtime + spring-cloud-function-stream + + + org.springframework.cloud + spring-cloud-stream-binder-servlet + ${spring-cloud-stream-servlet.version} org.springframework.boot diff --git a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/DeployedApplicationFilter.java b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/DeployedApplicationFilter.java new file mode 100644 index 000000000..aa6ec5e50 --- /dev/null +++ b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/DeployedApplicationFilter.java @@ -0,0 +1,84 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.function.deployer; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; + +/** + * @author Dave Syer + * + */ +@Component +public class DeployedApplicationFilter extends WebMvcConfigurerAdapter + implements HandlerInterceptor { + + private final FunctionExtractingFunctionCatalog deployer; + + @Autowired + public DeployedApplicationFilter(FunctionExtractingFunctionCatalog deployer) { + this.deployer = deployer; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(this); + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + String path = (String) request + .getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); + if (path != null) { + // TODO: extract /stream to config property + if (path.startsWith("/stream")) { + String name = path.substring("/stream/".length()); + if (name.contains("/")) { + name = name.substring(0, name.indexOf("/")); + } + if (deployer.deployed().containsKey(name)) { + return true; + } + else { + response.setStatus(HttpStatus.NOT_FOUND.value()); + return false; + } + } + } + return true; + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, + Object handler, ModelAndView modelAndView) throws Exception { + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) throws Exception { + } + +} diff --git a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingAutoConfiguration.java b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingAutoConfiguration.java index 42d9d6e24..5d178793f 100644 --- a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingAutoConfiguration.java +++ b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingAutoConfiguration.java @@ -17,7 +17,7 @@ package org.springframework.cloud.function.deployer; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.cloud.function.context.ContextFunctionCatalogAutoConfiguration; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalog.java b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalog.java index d226900c9..1acc102b0 100644 --- a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalog.java +++ b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalog.java @@ -31,13 +31,16 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.loader.thin.ArchiveUtils; import org.springframework.cloud.deployer.spi.app.AppDeployer; import org.springframework.cloud.deployer.spi.core.AppDefinition; import org.springframework.cloud.deployer.spi.core.AppDeploymentRequest; import org.springframework.cloud.deployer.thin.ThinJarAppDeployer; -import org.springframework.cloud.function.context.FunctionInspector; +import org.springframework.cloud.function.context.catalog.FunctionInspector; import org.springframework.cloud.function.core.FunctionCatalog; +import org.springframework.cloud.function.stream.config.SupplierInvokingMessageProducer; +import org.springframework.cloud.stream.binder.servlet.RouteRegistrar; import org.springframework.context.support.LiveBeansView; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -49,6 +52,10 @@ public class FunctionExtractingFunctionCatalog private static Log logger = LogFactory .getLog(FunctionExtractingFunctionCatalog.class); + private RouteRegistrar routes; + + private SupplierInvokingMessageProducer producer; + private ThinJarAppDeployer deployer; private Map deployed = new LinkedHashMap<>(); @@ -65,6 +72,16 @@ public class FunctionExtractingFunctionCatalog deployer = new ThinJarAppDeployer(name, profiles); } + @Autowired + public void setRouteRegistrar(RouteRegistrar routes) { + this.routes = routes; + } + + @Autowired + public void setProducer(SupplierInvokingMessageProducer producer) { + this.producer = producer; + } + @Override public void destroy() throws Exception { for (String name : new HashSet<>(names.keySet())) { @@ -156,6 +173,7 @@ public class FunctionExtractingFunctionCatalog this.deployed.put(id, path); this.names.put(name, id); this.ids.put(id, name); + register(name); return id; } @@ -165,6 +183,7 @@ public class FunctionExtractingFunctionCatalog // TODO: Convert to 404 throw new IllegalStateException("No such app"); } + unregister(name); this.deployer.undeploy(id); String path = this.deployed.remove(id); this.names.remove(name); @@ -172,6 +191,39 @@ public class FunctionExtractingFunctionCatalog return new DeployedArtifact(name, id, path); } + private void register(String name) { + Set names = getSupplierNames(name); + if (routes != null) { + logger.info("Registering routes: " + names); + routes.registerRoutes(getSupplierNames(name)); + } + if (producer != null) { + // Need an ApplicationEvent that we can react to in the producer? + for (String supplier : names) { + producer.start(supplier); + } + } + } + + @SuppressWarnings("unchecked") + private Set getSupplierNames(String name) { + String id = this.names.get(name); + return (Set) invoke(id, FunctionCatalog.class, "getSupplierNames"); + } + + private void unregister(String name) { + Set names = getSupplierNames(name); + if (routes != null) { + logger.info("Unregistering routes: " + names); + routes.unregisterRoutes(names); + } + if (producer != null) { + for (String supplier : names) { + producer.stop(supplier); + } + } + } + private Object inspect(Object arg, String method) { if (logger.isDebugEnabled()) { logger.debug("Inspecting " + method); @@ -195,53 +247,90 @@ public class FunctionExtractingFunctionCatalog private Object invoke(Class type, String method, Object... arg) { Set results = new LinkedHashSet<>(); + Object fallback = null; for (String id : this.deployed.keySet()) { - Object catalog = this.deployer.getBean(id, type); - if (catalog == null) { + Object result = invoke(id, type, method, arg); + if (result instanceof Collection) { + results.addAll((Collection) result); continue; } - String name = this.ids.get(id); - String prefix = name + "/"; - if (arg.length == 1) { - if (arg[0] instanceof String) { - String specific = arg[0].toString(); - if (specific.startsWith(prefix)) { - arg[0] = specific.substring(prefix.length()); - } - else { - continue; - } + if (result != null) { + if (result == Object.class) { + // Type fallback is Object + fallback = Object.class; + continue; } - } - try { - MethodInvoker invoker = new MethodInvoker(); - invoker.setTargetObject(catalog); - invoker.setTargetMethod(method); - invoker.setArguments(arg); - invoker.prepare(); - Object result = invoker.invoke(); - if (result != null) { - if (result instanceof Collection) { - for (Object value : (Collection) result) { - results.add(prefix + value); - } - } - else if (result instanceof String) { - return prefix + result; - } - - else { - return result; - } + if (result instanceof Boolean && !((Boolean) result)) { + // Boolean fallback is false + fallback = false; + continue; } - } - catch (Exception e) { - throw new IllegalStateException("Cannot extract catalog", e); + return result; } } + if (fallback != null) { + return fallback; + } + if (logger.isDebugEnabled()) { + logger.debug("Results: " + results); + } return arg.length > 0 ? null : results; } + private Object invoke(String id, Class type, String method, Object... arg) { + Object catalog = this.deployer.getBean(id, type); + if (catalog == null) { + return null; + } + String name = this.ids.get(id); + String prefix = name + "/"; + if (arg.length == 1) { + if (arg[0] instanceof String) { + String specific = arg[0].toString(); + if (specific.startsWith(prefix)) { + arg[0] = specific.substring(prefix.length()); + } + else { + return null; + } + } + } + try { + MethodInvoker invoker = new MethodInvoker(); + invoker.setTargetObject(catalog); + invoker.setTargetMethod(method); + invoker.setArguments(arg); + invoker.prepare(); + Object result = invoker.invoke(); + if (result != null) { + if (result instanceof Collection) { + Set results = new LinkedHashSet<>(); + for (Object value : (Collection) result) { + results.add(prefix + value); + } + return results; + } + else if (result instanceof String) { + if (logger.isDebugEnabled()) { + logger.debug("Prefixed (from \" + name + \"): " + result); + } + return prefix + result; + } + + else { + if (logger.isDebugEnabled()) { + logger.debug("Result (from " + name + "): " + result); + } + return result; + } + } + } + catch (Exception e) { + throw new IllegalStateException("Cannot extract catalog", e); + } + return null; + } + public Map deployed() { Map result = new LinkedHashMap<>(); for (String name : this.names.keySet()) { diff --git a/spring-cloud-function-deployer/src/main/resources/META-INF/thin-slim.properties b/spring-cloud-function-deployer/src/main/resources/META-INF/thin-slim.properties index 7336d7ff9..af7781b7e 100644 --- a/spring-cloud-function-deployer/src/main/resources/META-INF/thin-slim.properties +++ b/spring-cloud-function-deployer/src/main/resources/META-INF/thin-slim.properties @@ -1,8 +1,8 @@ exclusions.spring-web-reactive: org.springframework:spring-web-reactive -exclusions.spring-cloud-function-web: org.springframework.cloud:spring-cloud-function-web exclusions.reator-netty: io.projectreactor.ipc:reactor-netty exclusions.spring-cloud-stream: org.springframework.cloud:spring-cloud-stream exclusions.spring-cloud-stream-reactive: org.springframework.cloud:spring-cloud-stream-reactive +exclusions.spring-cloud-stream-binder-servlet: org.springframework.cloud:spring-cloud-stream-binder-servlet exclusions.spring-cloud-stream-binder-rabbit: org.springframework.cloud:spring-cloud-stream-binder-rabbit exclusions.spring-cloud-stream-binder-kafka: org.springframework.cloud:spring-cloud-stream-binder-kafka exclusions.spring-boot-starter-web: org.springframework.boot:spring-boot-starter-web diff --git a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/AdhocTestSuite.java b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/AdhocTestSuite.java index c4a7a338d..50831ca68 100644 --- a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/AdhocTestSuite.java +++ b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/AdhocTestSuite.java @@ -27,8 +27,9 @@ import org.junit.runners.Suite.SuiteClasses; * @author Dave Syer */ @RunWith(Suite.class) -@SuiteClasses({ FunctionExtractingFunctionCatalogIntegrationTests.class, - FunctionExtractingFunctionCatalogTests.class }) +@SuiteClasses({ FunctionAppDeployerTests.class, + FunctionExtractingFunctionCatalogTests.class, + FunctionExtractingFunctionCatalogIntegrationTests.class }) @Ignore public class AdhocTestSuite { diff --git a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionAppDeployerTests.java b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionAppDeployerTests.java index fd5b53e26..94a290220 100644 --- a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionAppDeployerTests.java +++ b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionAppDeployerTests.java @@ -94,7 +94,7 @@ public class FunctionAppDeployerTests { @Test public void stream() throws Exception { String first = deploy("maven://io.spring.sample:function-sample:1.0.0.BUILD-SNAPSHOT", - "spring.cloud.deployer.thin.profile=stream", + "spring.cloud.deployer.thin.profile=rabbit", "--spring.cloud.function.stream.supplier.enabled=false", "--debug=true"); // Deployment is blocking so it either failed or succeeded. assertThat(deployer.status(first).getState()).isEqualTo(DeploymentState.deployed); diff --git a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogIntegrationTests.java b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogIntegrationTests.java index 91cbf03e6..eaa524033 100644 --- a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogIntegrationTests.java +++ b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogIntegrationTests.java @@ -43,9 +43,9 @@ public class FunctionExtractingFunctionCatalogIntegrationTests { public static void open() throws Exception { port = SocketUtils.findAvailableTcpPort(); // System.setProperty("debug", "true"); - context = new ApplicationRunner().start("--server.port=" + port, - "--spring.cloud.stream.enabled=false"); - deploy("sample", "maven://io.spring.sample:function-sample:1.0.0.BUILD-SNAPSHOT"); + context = new ApplicationRunner().start("--server.port=" + port, "--debug", + "--logging.level.org.springframework.cloud.function=DEBUG"); + deploy("sample", "maven://com.example:function-sample-pojo:1.0.0.BUILD-SNAPSHOT"); } private static void deploy(String name, String path) throws Exception { @@ -79,23 +79,31 @@ public class FunctionExtractingFunctionCatalogIntegrationTests { @Test public void words() { - assertThat(new TestRestTemplate() - .getForObject("http://localhost:" + port + "/sample/words", String.class)) + assertThat(new TestRestTemplate().getForObject( + "http://localhost:" + port + "/stream/sample/words", String.class)) .isEqualTo("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"); } + @Test + public void missing() throws Exception { + ResponseEntity result = new TestRestTemplate().exchange(RequestEntity + .get(new URI("http://localhost:" + port + "/stream/missing/words")) + .build(), String.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + @Test public void uppercase() { assertThat(new TestRestTemplate().postForObject( - "http://localhost:" + port + "/sample/uppercase", "{\"value\":\"foo\"}", - String.class)).isEqualTo("[{\"value\":\"FOO\"}]"); + "http://localhost:" + port + "/stream/sample/uppercase", + "{\"value\":\"foo\"}", String.class)).isEqualTo("{\"value\":\"FOO\"}"); } @Test public void another() throws Exception { deploy("strings", "maven://io.spring.sample:function-sample:1.0.0.BUILD-SNAPSHOT"); assertThat(new TestRestTemplate().getForObject( - "http://localhost:" + port + "/strings/words", String.class)) + "http://localhost:" + port + "/stream/strings/words", String.class)) .isEqualTo("[\"foo\",\"bar\"]"); } @@ -106,13 +114,13 @@ public class FunctionExtractingFunctionCatalogIntegrationTests { assertThat(undeploy.contains( "\"path\":\"maven://io.spring.sample:function-sample-pojo:1.0.0.BUILD-SNAPSHOT\"")); ResponseEntity result = new TestRestTemplate().exchange(RequestEntity - .get(new URI("http://localhost:" + port + "/sample/words")).build(), - String.class); + .get(new URI("http://localhost:" + port + "/stream/sample/words")) + .build(), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); deploy("sample", "maven://io.spring.sample:function-sample-pojo:1.0.0.BUILD-SNAPSHOT"); assertThat(new TestRestTemplate().postForObject( - "http://localhost:" + port + "/sample/uppercase", "{\"value\":\"foo\"}", - String.class)).isEqualTo("[{\"value\":\"FOO\"}]"); + "http://localhost:" + port + "/stream/sample/uppercase", + "{\"value\":\"foo\"}", String.class)).isEqualTo("{\"value\":\"FOO\"}"); } } diff --git a/spring-cloud-function-samples/function-sample-aws/pom.xml b/spring-cloud-function-samples/function-sample-aws/pom.xml index 27e5846a4..3ecde301b 100644 --- a/spring-cloud-function-samples/function-sample-aws/pom.xml +++ b/spring-cloud-function-samples/function-sample-aws/pom.xml @@ -26,6 +26,7 @@ 1.2.1 3.0.7.RELEASE 1.0.0.BUILD-SNAPSHOT + 1.0.0.BUILD-SNAPSHOT example.Config @@ -36,7 +37,12 @@ org.springframework.cloud - spring-cloud-function-web + spring-cloud-function-stream + provided + + + org.springframework.cloud + spring-cloud-stream-binder-servlet provided @@ -72,6 +78,11 @@ pom import + + org.springframework.cloud + spring-cloud-stream-binder-servlet + ${spring-cloud-stream-servlet.version} + diff --git a/spring-cloud-function-samples/function-sample-aws/src/main/resources/META-INF/thin.properties b/spring-cloud-function-samples/function-sample-aws/src/main/resources/META-INF/thin.properties index cd3c738e9..78f563bdc 100644 --- a/spring-cloud-function-samples/function-sample-aws/src/main/resources/META-INF/thin.properties +++ b/spring-cloud-function-samples/function-sample-aws/src/main/resources/META-INF/thin.properties @@ -1 +1,2 @@ -dependencies.spring-cloud-function-web: org.springframework.cloud:spring-cloud-function-web \ No newline at end of file +dependencies.spring-cloud-function-stream: org.springframework.cloud:spring-cloud-function-stream +dependencies.spring-cloud-stream-binder-servlet: org.springframework.cloud:spring-cloud-stream-binder-servlet \ No newline at end of file diff --git a/spring-cloud-function-samples/function-sample-compiler/pom.xml b/spring-cloud-function-samples/function-sample-compiler/pom.xml index 5c653652b..6380d0c27 100644 --- a/spring-cloud-function-samples/function-sample-compiler/pom.xml +++ b/spring-cloud-function-samples/function-sample-compiler/pom.xml @@ -26,17 +26,13 @@ - - org.springframework.cloud - spring-cloud-function-web - org.springframework.cloud spring-cloud-function-stream org.springframework.cloud - spring-cloud-starter-stream-rabbit + spring-cloud-stream-binder-servlet org.springframework.cloud @@ -65,6 +61,11 @@ pom import + + org.springframework.cloud + spring-cloud-stream-binder-servlet + ${spring-cloud-stream-servlet.version} + diff --git a/spring-cloud-function-samples/function-sample-compiler/src/test/java/com/example/SampleCompiledConsumerTests.java b/spring-cloud-function-samples/function-sample-compiler/src/test/java/com/example/SampleCompiledConsumerTests.java index 8fa4d765a..8964a9fec 100644 --- a/spring-cloud-function-samples/function-sample-compiler/src/test/java/com/example/SampleCompiledConsumerTests.java +++ b/spring-cloud-function-samples/function-sample-compiler/src/test/java/com/example/SampleCompiledConsumerTests.java @@ -16,8 +16,6 @@ package com.example; -import static org.junit.Assert.assertEquals; - import org.junit.Test; import org.junit.runner.RunWith; @@ -28,17 +26,16 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.test.context.junit4.SpringRunner; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; /** * @author Mark Fisher */ @RunWith(SpringRunner.class) -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, - properties = { - "spring.cloud.function.compile.test.lambda=com.example.SampleCompiledConsumerTests.Reference::set", - "spring.cloud.function.compile.test.inputType=String", - "spring.cloud.function.compile.test.type=consumer" - }) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = { + "spring.cloud.function.compile.test.lambda=com.example.SampleCompiledConsumerTests.Reference::set", + "spring.cloud.function.compile.test.inputType=String", + "spring.cloud.function.compile.test.type=consumer" }) public class SampleCompiledConsumerTests { @LocalServerPort @@ -47,8 +44,8 @@ public class SampleCompiledConsumerTests { @Test public void print() { assertThat(new TestRestTemplate().postForObject( - "http://localhost:" + port + "/test", "it works", - String.class)).isEqualTo("it works"); + "http://localhost:" + port + "/stream/test", "it works", String.class)) + .isEqualTo("it works"); assertEquals("it works", Reference.instance); } diff --git a/spring-cloud-function-samples/function-sample-compiler/src/test/java/com/example/SampleCompiledFunctionTests.java b/spring-cloud-function-samples/function-sample-compiler/src/test/java/com/example/SampleCompiledFunctionTests.java index 29435826c..ddd098fae 100644 --- a/spring-cloud-function-samples/function-sample-compiler/src/test/java/com/example/SampleCompiledFunctionTests.java +++ b/spring-cloud-function-samples/function-sample-compiler/src/test/java/com/example/SampleCompiledFunctionTests.java @@ -43,7 +43,7 @@ public class SampleCompiledFunctionTests { @Test public void lowercase() { assertThat(new TestRestTemplate().postForObject( - "http://localhost:" + port + "/test", "it works", String.class)) + "http://localhost:" + port + "/stream/test", "it works", String.class)) .isEqualTo("it works!!!"); } diff --git a/spring-cloud-function-samples/function-sample-pof/pom.xml b/spring-cloud-function-samples/function-sample-pof/pom.xml index 377bbf092..9dec74626 100644 --- a/spring-cloud-function-samples/function-sample-pof/pom.xml +++ b/spring-cloud-function-samples/function-sample-pof/pom.xml @@ -22,16 +22,18 @@ 1.8 3.1.1.RELEASE 1.0.0.BUILD-SNAPSHOT + 1.0.0.BUILD-SNAPSHOT org.springframework.cloud - spring-cloud-function-web + spring-cloud-function-stream org.springframework.cloud - spring-cloud-function-context + spring-cloud-stream-binder-servlet + ${spring-cloud-stream-servlet.version} diff --git a/spring-cloud-function-samples/function-sample-pojo/pom.xml b/spring-cloud-function-samples/function-sample-pojo/pom.xml index c73d5f985..0be547834 100644 --- a/spring-cloud-function-samples/function-sample-pojo/pom.xml +++ b/spring-cloud-function-samples/function-sample-pojo/pom.xml @@ -27,7 +27,11 @@ org.springframework.cloud - spring-cloud-function-web + spring-cloud-function-stream + + + org.springframework.cloud + spring-cloud-stream-binder-servlet org.springframework.boot diff --git a/spring-cloud-function-samples/function-sample-pojo/src/test/java/com/example/SampleApplicationTests.java b/spring-cloud-function-samples/function-sample-pojo/src/test/java/com/example/SampleApplicationTests.java index bd31a00de..8cc4f78f9 100644 --- a/spring-cloud-function-samples/function-sample-pojo/src/test/java/com/example/SampleApplicationTests.java +++ b/spring-cloud-function-samples/function-sample-pojo/src/test/java/com/example/SampleApplicationTests.java @@ -40,7 +40,7 @@ public class SampleApplicationTests { @Test public void words() { assertThat(new TestRestTemplate() - .getForObject("http://localhost:" + port + "/words", String.class)) + .getForObject("http://localhost:" + port + "/stream/words", String.class)) .isEqualTo("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"); } @@ -48,14 +48,14 @@ public class SampleApplicationTests { public void uppercase() { // TODO: make this work with a JSON stream as well (like in WebFlux) assertThat(new TestRestTemplate().postForObject( - "http://localhost:" + port + "/uppercase", "[{\"value\":\"foo\"}]", + "http://localhost:" + port + "/stream/uppercase", "[{\"value\":\"foo\"}]", String.class)).isEqualTo("[{\"value\":\"FOO\"}]"); } @Test public void lowercase() { assertThat(new TestRestTemplate().postForObject( - "http://localhost:" + port + "/lowercase", "[{\"value\":\"Foo\"}]", + "http://localhost:" + port + "/stream/lowercase", "[{\"value\":\"Foo\"}]", String.class)).isEqualTo("[{\"value\":\"foo\"}]"); } diff --git a/spring-cloud-function-samples/function-sample-task/pom.xml b/spring-cloud-function-samples/function-sample-task/pom.xml index 3833f42a2..aec9a5390 100644 --- a/spring-cloud-function-samples/function-sample-task/pom.xml +++ b/spring-cloud-function-samples/function-sample-task/pom.xml @@ -31,7 +31,11 @@ org.springframework.cloud - spring-cloud-function-context + spring-cloud-stream-binder-servlet + + + org.springframework.cloud + spring-cloud-function-stream org.springframework.cloud diff --git a/spring-cloud-function-samples/function-sample/pom.xml b/spring-cloud-function-samples/function-sample/pom.xml index 8ff3c43d5..03e82d347 100644 --- a/spring-cloud-function-samples/function-sample/pom.xml +++ b/spring-cloud-function-samples/function-sample/pom.xml @@ -27,11 +27,11 @@ org.springframework.cloud - spring-cloud-function-web + spring-cloud-function-stream org.springframework.cloud - spring-cloud-function-context + spring-cloud-stream-binder-servlet org.springframework.cloud diff --git a/spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-rabbit.properties b/spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-rabbit.properties new file mode 100644 index 000000000..b2575c4dc --- /dev/null +++ b/spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-rabbit.properties @@ -0,0 +1,9 @@ +<<<<<<< HEAD:spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-stream.properties +boms.spring-cloud-dependencies: org.springframework.cloud:spring-cloud-dependencies:Edgware.RELEASE +dependencies.spring-cloud-function-stream: org.springframework.cloud:spring-cloud-function-stream:1.0.0.BUILD-SNAPSHOT +dependencies.spring-cloud-stream-rabbit: org.springframework.cloud:spring-cloud-starter-stream-rabbit +======= +boms.spring-cloud-dependencies: org.springframework.cloud:spring-cloud-dependencies:Dalston.RELEASE +exclusions.spring-cloud-function-stream-binder-servlet: org.springframework.cloud:spring-cloud-stream-binder-servlet +dependencies.spring-cloud-stream-rabbit: org.springframework.cloud:spring-cloud-stream-binder-rabbit +>>>>>>> Migrate to servlet binder for web features:spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-rabbit.properties diff --git a/spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-stream.properties b/spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-stream.properties deleted file mode 100644 index 5ce12b0a0..000000000 --- a/spring-cloud-function-samples/function-sample/src/main/resources/META-INF/thin-stream.properties +++ /dev/null @@ -1,3 +0,0 @@ -boms.spring-cloud-dependencies: org.springframework.cloud:spring-cloud-dependencies:Edgware.RELEASE -dependencies.spring-cloud-function-stream: org.springframework.cloud:spring-cloud-function-stream:1.0.0.BUILD-SNAPSHOT -dependencies.spring-cloud-stream-rabbit: org.springframework.cloud:spring-cloud-starter-stream-rabbit \ No newline at end of file diff --git a/spring-cloud-function-stream/pom.xml b/spring-cloud-function-stream/pom.xml index a3b367a18..442d3e60a 100644 --- a/spring-cloud-function-stream/pom.xml +++ b/spring-cloud-function-stream/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 spring-cloud-function-stream @@ -13,6 +14,10 @@ 1.0.0.BUILD-SNAPSHOT + + 1.0.0.BUILD-SNAPSHOT + + io.projectreactor @@ -41,7 +46,8 @@ org.springframework.cloud - spring-cloud-stream-binder-rabbit + spring-cloud-stream-binder-servlet + ${servlet-binder.version} true diff --git a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/SupplierInvokingMessageProducer.java b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/SupplierInvokingMessageProducer.java deleted file mode 100644 index 36b321af9..000000000 --- a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/SupplierInvokingMessageProducer.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.stream; - -import java.util.function.Supplier; - -import org.springframework.cloud.function.core.FunctionCatalog; -import org.springframework.cloud.stream.messaging.Source; -import org.springframework.integration.endpoint.MessageProducerSupport; -import org.springframework.messaging.Message; -import org.springframework.messaging.support.MessageBuilder; -import org.springframework.util.Assert; - -import reactor.core.publisher.Flux; -import reactor.core.scheduler.Schedulers; - -/** - * @author Mark Fisher - */ -public class SupplierInvokingMessageProducer extends MessageProducerSupport { - - private final FunctionCatalog functionCatalog; - - public SupplierInvokingMessageProducer(FunctionCatalog registry) { - this.functionCatalog = registry; - this.setOutputChannelName(Source.OUTPUT); - } - - @Override - protected void doStart() { - supplier().subscribeOn(Schedulers.elastic()).subscribe(m -> this.sendMessage(m)); - } - - private Flux> supplier() { - Supplier> supplier = null; - Flux> result = Flux.empty(); - for (String name : functionCatalog.getSupplierNames()) { - supplier = functionCatalog.lookupSupplier(name); - Assert.notNull(supplier, "Supplier must not be null"); - result = Flux.merge(result, - supplier.get().map(payload -> MessageBuilder.withPayload(payload) - .setHeader(StreamConfigurationProperties.ROUTE_KEY, name) - .build())); - } - return result; - } -} diff --git a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/RouteRegistryAutoConfiguration.java b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/RouteRegistryAutoConfiguration.java new file mode 100644 index 000000000..a22b2e6e3 --- /dev/null +++ b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/RouteRegistryAutoConfiguration.java @@ -0,0 +1,42 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.function.stream.config; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; +import org.springframework.cloud.function.core.FunctionCatalog; +import org.springframework.cloud.stream.binder.servlet.RouteRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Dave Syer + * + */ +@Configuration +@ConditionalOnBean(FunctionCatalog.class) +@ConditionalOnClass(RouteRegistry.class) +@AutoConfigureAfter(ContextFunctionCatalogAutoConfiguration.class) +public class RouteRegistryAutoConfiguration { + + @Bean + public RouteRegistry supplierRoutes(FunctionCatalog registry) { + return () -> registry.getSupplierNames(); + } + +} diff --git a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamConfiguration.java b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamAutoConfiguration.java similarity index 94% rename from spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamConfiguration.java rename to spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamAutoConfiguration.java index 2f2b1cf2f..7f7cd5ee6 100644 --- a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamConfiguration.java +++ b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamAutoConfiguration.java @@ -14,14 +14,14 @@ * limitations under the License. */ -package org.springframework.cloud.function.stream; +package org.springframework.cloud.function.stream.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.cloud.function.context.FunctionInspector; +import org.springframework.cloud.function.context.catalog.FunctionInspector; import org.springframework.cloud.function.core.FunctionCatalog; import org.springframework.cloud.stream.annotation.EnableBinding; import org.springframework.cloud.stream.binder.Binder; @@ -39,7 +39,7 @@ import org.springframework.context.annotation.Lazy; @ConditionalOnBean(FunctionCatalog.class) @ConditionalOnProperty(name = "spring.cloud.stream.enabled", havingValue = "true", matchIfMissing = true) @EnableBinding(Processor.class) -public class StreamConfiguration { +public class StreamAutoConfiguration { @Autowired private StreamConfigurationProperties properties; diff --git a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamConfigurationProperties.java b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamConfigurationProperties.java similarity index 96% rename from spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamConfigurationProperties.java rename to spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamConfigurationProperties.java index ad4c2f07b..a913bbd37 100644 --- a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamConfigurationProperties.java +++ b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamConfigurationProperties.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.stream; +package org.springframework.cloud.function.stream.config; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamListeningFunctionInvoker.java b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamListeningFunctionInvoker.java similarity index 95% rename from spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamListeningFunctionInvoker.java rename to spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamListeningFunctionInvoker.java index 2c960ce2f..65767b954 100644 --- a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/StreamListeningFunctionInvoker.java +++ b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/StreamListeningFunctionInvoker.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.stream; +package org.springframework.cloud.function.stream.config; import java.util.ArrayList; import java.util.HashMap; @@ -26,7 +26,7 @@ import java.util.function.Consumer; import java.util.function.Function; import org.springframework.beans.factory.SmartInitializingSingleton; -import org.springframework.cloud.function.context.FunctionInspector; +import org.springframework.cloud.function.context.catalog.FunctionInspector; import org.springframework.cloud.function.core.FunctionCatalog; import org.springframework.cloud.stream.annotation.Input; import org.springframework.cloud.stream.annotation.Output; @@ -101,7 +101,11 @@ public class StreamListeningFunctionInvoker implements SmartInitializingSingleto } private Message message(Object result, Map headers) { - return result instanceof Message ? (Message) result + return result instanceof Message + // TODO: why do we have to do this? The headers should have come with the + // result. + ? MessageBuilder.fromMessage((Message) result) + .copyHeadersIfAbsent(headers).build() : MessageBuilder.withPayload(result).copyHeadersIfAbsent(headers).build(); } diff --git a/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/SupplierInvokingMessageProducer.java b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/SupplierInvokingMessageProducer.java new file mode 100644 index 000000000..8410d095a --- /dev/null +++ b/spring-cloud-function-stream/src/main/java/org/springframework/cloud/function/stream/config/SupplierInvokingMessageProducer.java @@ -0,0 +1,110 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.function.stream.config; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import org.springframework.cloud.function.core.FunctionCatalog; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.integration.endpoint.MessageProducerSupport; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; + +/** + * @author Mark Fisher + */ +public class SupplierInvokingMessageProducer extends MessageProducerSupport { + + private final FunctionCatalog functionCatalog; + + private final Set suppliers = new HashSet<>(); + + private final Map disposables = new HashMap<>(); + + public SupplierInvokingMessageProducer(FunctionCatalog registry) { + this.functionCatalog = registry; + this.setOutputChannelName(Source.OUTPUT); + } + + @Override + protected void doStart() { + for (String name : functionCatalog.getSupplierNames()) { + start(name); + } + } + + @Override + protected void doStop() { + for (String name : new HashSet<>(suppliers)) { + stop(name); + } + } + + public void stop(String name) { + if (disposables.containsKey(name)) { + synchronized (disposables) { + if (disposables.containsKey(name)) { + try { + disposables.get(name).dispose(); + } + finally { + disposables.remove(name); + suppliers.remove(name); + } + } + } + } + } + + public void start(String name) { + if (!disposables.containsKey(name)) { + synchronized (disposables) { + if (!disposables.containsKey(name)) { + Supplier> supplier = functionCatalog.lookupSupplier(name); + if (supplier != null) { + suppliers.add(name); + disposables.put(name, + supplier.get().subscribeOn(Schedulers.elastic()).subscribe(m -> send(name, m))); + } + } + } + } + } + + private void send(String name, Object payload) { + Message message; + if (payload instanceof Message) { + message = MessageBuilder.fromMessage((Message) payload) + .setHeaderIfAbsent(StreamConfigurationProperties.ROUTE_KEY, name) + .build(); + } + else { + message = MessageBuilder.withPayload(payload) + .setHeader(StreamConfigurationProperties.ROUTE_KEY, name).build(); + } + getOutputChannel().send(message); + } + +} diff --git a/spring-cloud-function-stream/src/main/resources/META-INF/spring.factories b/spring-cloud-function-stream/src/main/resources/META-INF/spring.factories index bdf658b18..14c1e3151 100644 --- a/spring-cloud-function-stream/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-function-stream/src/main/resources/META-INF/spring.factories @@ -1,2 +1,3 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ -org.springframework.cloud.function.stream.StreamConfiguration \ No newline at end of file +org.springframework.cloud.function.stream.config.StreamAutoConfiguration,\ +org.springframework.cloud.function.stream.config.RouteRegistryAutoConfiguration diff --git a/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/mixed/PojoStreamingExplicitEndpointTests.java b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/mixed/PojoStreamingExplicitEndpointTests.java index 3057d7899..4d4741e40 100644 --- a/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/mixed/PojoStreamingExplicitEndpointTests.java +++ b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/mixed/PojoStreamingExplicitEndpointTests.java @@ -29,7 +29,7 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.cloud.function.stream.StreamConfigurationProperties; +import org.springframework.cloud.function.stream.config.StreamConfigurationProperties; import org.springframework.cloud.stream.messaging.Processor; import org.springframework.cloud.stream.test.binder.MessageCollector; import org.springframework.context.annotation.Bean; diff --git a/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/mixed/PojoStreamingMixedTests.java b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/mixed/PojoStreamingMixedTests.java index 99644bdff..be7f083f6 100644 --- a/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/mixed/PojoStreamingMixedTests.java +++ b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/mixed/PojoStreamingMixedTests.java @@ -29,7 +29,7 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.cloud.function.stream.StreamConfigurationProperties; +import org.springframework.cloud.function.stream.config.StreamConfigurationProperties; import org.springframework.cloud.stream.messaging.Processor; import org.springframework.cloud.stream.test.binder.MessageCollector; import org.springframework.context.annotation.Bean; diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/scan/ComponentTests.java b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/scan/ComponentTests.java similarity index 90% rename from spring-cloud-function-web/src/test/java/org/springframework/cloud/function/scan/ComponentTests.java rename to spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/scan/ComponentTests.java index e05833d47..7c7d1b804 100644 --- a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/scan/ComponentTests.java +++ b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/scan/ComponentTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.scan; +package org.springframework.cloud.function.stream.scan; import java.net.URI; import java.util.function.Function; @@ -28,6 +28,7 @@ import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration; import org.springframework.context.annotation.ComponentScan; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -64,14 +65,14 @@ public class ComponentTests { public void greeter() throws Exception { ResponseEntity result = rest .exchange( - RequestEntity.post(new URI("/greeter")) + RequestEntity.post(new URI("/stream/greeter")) .contentType(MediaType.TEXT_PLAIN).body("World"), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isEqualTo("Hello World"); } - @SpringBootApplication + @SpringBootApplication(exclude=TestSupportBinderAutoConfiguration.class) @ComponentScan protected static class TestConfiguration { } diff --git a/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/supplier/RestartStreamSupplierTests.java b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/supplier/RestartStreamSupplierTests.java new file mode 100644 index 000000000..dd91d1a11 --- /dev/null +++ b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/supplier/RestartStreamSupplierTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.function.stream.supplier; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.function.stream.config.StreamConfigurationProperties; +import org.springframework.cloud.function.stream.config.SupplierInvokingMessageProducer; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.annotation.Bean; +import org.springframework.messaging.Message; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = RestartStreamSupplierTests.StreamingFunctionApplication.class) +@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) +public class RestartStreamSupplierTests { + + @Autowired + Source source; + + @Autowired + MessageCollector messageCollector; + + @Autowired + SupplierInvokingMessageProducer producer; + + @Rule + public ExpectedException expected = ExpectedException.none(); + + @Test + public void exhausted() throws Exception { + test(); + expected.expect(NullPointerException.class); + test(); + } + + @Test + public void restart() throws Exception { + test(); + assertThat(messageCollector.forChannel(source.output())).isEmpty(); + producer.stop(); + producer.start(); + test(); + } + + private void test() throws Exception { + Message result = messageCollector.forChannel(source.output()).poll(1000, + TimeUnit.MILLISECONDS); + assertThat(result.getPayload()).isEqualTo("foo"); + assertThat(result.getHeaders().get(StreamConfigurationProperties.ROUTE_KEY)) + .isEqualTo("simpleSupplier"); + } + + @SpringBootApplication + public static class StreamingFunctionApplication { + + @Bean + public Supplier simpleSupplier() { + return () -> "foo"; + } + } +} diff --git a/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/supplier/StreamSupplierTests.java b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/supplier/StreamSupplierTests.java index 67dacf8eb..441dd1460 100644 --- a/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/supplier/StreamSupplierTests.java +++ b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/supplier/StreamSupplierTests.java @@ -25,7 +25,7 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.cloud.function.stream.StreamConfigurationProperties; +import org.springframework.cloud.function.stream.config.StreamConfigurationProperties; import org.springframework.cloud.stream.messaging.Source; import org.springframework.cloud.stream.test.binder.MessageCollector; import org.springframework.context.annotation.Bean; diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PrefixTests.java b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/PrefixTests.java similarity index 87% rename from spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PrefixTests.java rename to spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/PrefixTests.java index 606ca54fe..d116ac3a0 100644 --- a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PrefixTests.java +++ b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/PrefixTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.web; +package org.springframework.cloud.function.stream.web; import java.net.URI; import java.util.function.Supplier; @@ -28,7 +28,9 @@ import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; @@ -43,7 +45,7 @@ import reactor.core.publisher.Flux; * */ @RunWith(SpringRunner.class) -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = "spring.cloud.function.web.path=/functions") +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = "spring.cloud.stream.binder.servlet.prefix=/functions") public class PrefixTests { @LocalServerPort @@ -66,8 +68,8 @@ public class PrefixTests { assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } - @EnableAutoConfiguration - @org.springframework.boot.test.context.TestConfiguration + @EnableAutoConfiguration(exclude=TestSupportBinderAutoConfiguration.class) + @Configuration protected static class TestConfiguration { @Bean({ "words", "get/more" }) public Supplier> words() { diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/RestApplicationTests.java b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/RestApplicationTests.java similarity index 76% rename from spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/RestApplicationTests.java rename to spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/RestApplicationTests.java index ff701b3d2..facd33c88 100644 --- a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/RestApplicationTests.java +++ b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/RestApplicationTests.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.cloud.function.web; +package org.springframework.cloud.function.stream.web; import java.net.URI; import java.time.Duration; @@ -34,7 +34,6 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; @@ -47,6 +46,8 @@ import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.messaging.Message; import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.util.StringUtils; @@ -59,7 +60,10 @@ import reactor.core.publisher.Flux; * */ @RunWith(SpringRunner.class) -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = { + "logging.level.org.springframework.integration=DEBUG", + "spring.autoconfigure.exclude=org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration" }) +@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) public class RestApplicationTests { private static final MediaType EVENT_STREAM = MediaType.TEXT_EVENT_STREAM; @@ -76,21 +80,17 @@ public class RestApplicationTests { } @Test - public void staticResource() throws Exception { - assertThat(rest.getForObject("/test.html", String.class)).contains("Test"); - } - - @Test + @Ignore("Needs Spring 5?") public void wordsSSE() throws Exception { assertThat(rest.exchange( - RequestEntity.get(new URI("/words")).accept(EVENT_STREAM).build(), + RequestEntity.get(new URI("/stream/words")).accept(EVENT_STREAM).build(), String.class).getBody()).isEqualTo(sse("foo", "bar")); } @Test public void wordsJson() throws Exception { assertThat(rest - .exchange(RequestEntity.get(new URI("/words")) + .exchange(RequestEntity.get(new URI("/stream/words")) .accept(MediaType.APPLICATION_JSON).build(), String.class) .getBody()).isEqualTo("[\"foo\",\"bar\"]"); } @@ -99,31 +99,31 @@ public class RestApplicationTests { @Ignore("Fix error handling") public void errorJson() throws Exception { assertThat(rest - .exchange(RequestEntity.get(new URI("/bang")) + .exchange(RequestEntity.get(new URI("/stream/bang")) .accept(MediaType.APPLICATION_JSON).build(), String.class) .getBody()).isEqualTo("[\"foo\"]"); } @Test public void words() throws Exception { - ResponseEntity result = rest - .exchange(RequestEntity.get(new URI("/words")).build(), String.class); + ResponseEntity result = rest.exchange( + RequestEntity.get(new URI("/stream/words")).build(), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isEqualTo("[\"foo\",\"bar\"]"); } @Test public void word() throws Exception { - ResponseEntity result = rest - .exchange(RequestEntity.get(new URI("/word")).build(), String.class); + ResponseEntity result = rest.exchange( + RequestEntity.get(new URI("/stream/word")).build(), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody()).isEqualTo("foo"); + assertThat(result.getBody()).isEqualTo("[\"foo\"]"); } @Test public void foos() throws Exception { - ResponseEntity result = rest - .exchange(RequestEntity.get(new URI("/foos")).build(), String.class); + ResponseEntity result = rest.exchange( + RequestEntity.get(new URI("/stream/foos")).build(), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()) .isEqualTo("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"); @@ -131,9 +131,9 @@ public class RestApplicationTests { @Test public void qualifierFoos() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity.post(new URI("/foos")) - .contentType(MediaType.APPLICATION_JSON).body("[\"foo\",\"bar\"]"), - String.class); + ResponseEntity result = rest.exchange(RequestEntity + .post(new URI("/stream/foos")).contentType(MediaType.APPLICATION_JSON) + .body("[\"foo\",\"bar\"]"), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()) .isEqualTo("[{\"value\":\"[FOO]\"},{\"value\":\"[BAR]\"}]"); @@ -141,25 +141,26 @@ public class RestApplicationTests { @Test public void getMore() throws Exception { - ResponseEntity result = rest - .exchange(RequestEntity.get(new URI("/get/more")).build(), String.class); + ResponseEntity result = rest.exchange( + RequestEntity.get(new URI("/stream/get/more")).build(), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isEqualTo("[\"foo\",\"bar\"]"); } @Test public void bareWords() throws Exception { - ResponseEntity result = rest - .exchange(RequestEntity.get(new URI("/bareWords")).build(), String.class); + ResponseEntity result = rest.exchange( + RequestEntity.get(new URI("/stream/bareWords")).build(), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody()).isEqualTo("[\"foo\",\"bar\"]"); + assertThat(result.getBody()).isEqualTo("[[\"foo\",\"bar\"]]"); } @Test @Ignore("Should this even work? Or do we need to be explicit about the JSON?") public void updates() throws Exception { ResponseEntity result = rest.exchange( - RequestEntity.post(new URI("/updates")).body("one\ntwo"), String.class); + RequestEntity.post(new URI("/stream/updates")).body("one\ntwo"), + String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(test.list).hasSize(2); assertThat(result.getBody()).isEqualTo("onetwo"); @@ -168,7 +169,7 @@ public class RestApplicationTests { @Test public void updatesJson() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/updates")).contentType(MediaType.APPLICATION_JSON) + .post(new URI("/stream/updates")).contentType(MediaType.APPLICATION_JSON) .body("[\"one\",\"two\"]"), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(test.list).hasSize(2); @@ -178,19 +179,20 @@ public class RestApplicationTests { @Test public void addFoos() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/addFoos")).contentType(MediaType.APPLICATION_JSON) + .post(new URI("/stream/addFoos")).contentType(MediaType.APPLICATION_JSON) .body("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(test.list).hasSize(2); assertThat(result.getBody()) - .isEqualTo("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"); + .isEqualTo("[{\"value\":\"foo\"}, {\"value\":\"bar\"}]"); } @Test public void bareUpdates() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/bareUpdates")).contentType(MediaType.APPLICATION_JSON) - .body("[\"one\",\"two\"]"), String.class); + .post(new URI("/stream/bareUpdates")) + .contentType(MediaType.APPLICATION_JSON).body("[\"one\",\"two\"]"), + String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(test.list).hasSize(2); assertThat(result.getBody()).isEqualTo("[\"one\",\"two\"]"); @@ -199,7 +201,7 @@ public class RestApplicationTests { @Test public void timeoutJson() throws Exception { assertThat(rest - .exchange(RequestEntity.get(new URI("/timeout")) + .exchange(RequestEntity.get(new URI("/stream/timeout")) .accept(MediaType.APPLICATION_JSON).build(), String.class) .getBody()).isEqualTo("[\"foo\"]"); } @@ -207,23 +209,22 @@ public class RestApplicationTests { @Test public void emptyJson() throws Exception { assertThat(rest - .exchange(RequestEntity.get(new URI("/empty")) + .exchange(RequestEntity.get(new URI("/stream/empty")) .accept(MediaType.APPLICATION_JSON).build(), String.class) .getBody()).isEqualTo("[]"); } @Test public void sentences() throws Exception { - assertThat(rest - .exchange(RequestEntity.get(new URI("/sentences")).build(), String.class) - .getBody()).isEqualTo("[[\"go\",\"home\"],[\"come\",\"back\"]]"); + assertThat(rest.exchange(RequestEntity.get(new URI("/stream/sentences")).build(), + String.class).getBody()) + .isEqualTo("[[\"go\",\"home\"],[\"come\",\"back\"]]"); } @Test public void sentencesAcceptAny() throws Exception { - assertThat(rest.exchange( - RequestEntity.get(new URI("/sentences")).accept(MediaType.ALL).build(), - String.class).getBody()) + assertThat(rest.exchange(RequestEntity.get(new URI("/stream/sentences")) + .accept(MediaType.ALL).build(), String.class).getBody()) .isEqualTo("[[\"go\",\"home\"],[\"come\",\"back\"]]"); } @@ -231,7 +232,7 @@ public class RestApplicationTests { public void sentencesAcceptJson() throws Exception { ResponseEntity result = rest .exchange( - RequestEntity.get(new URI("/sentences")) + RequestEntity.get(new URI("/stream/sentences")) .accept(MediaType.APPLICATION_JSON).build(), String.class); assertThat(result.getBody()).isEqualTo("[[\"go\",\"home\"],[\"come\",\"back\"]]"); @@ -240,9 +241,10 @@ public class RestApplicationTests { } @Test + @Ignore("Maybe not supported") public void sentencesAcceptSse() throws Exception { - ResponseEntity result = rest.exchange( - RequestEntity.get(new URI("/sentences")).accept(EVENT_STREAM).build(), + ResponseEntity result = rest.exchange(RequestEntity + .get(new URI("/stream/sentences")).accept(EVENT_STREAM).build(), String.class); assertThat(result.getBody()) .isEqualTo(sse("[\"go\",\"home\"]", "[\"come\",\"back\"]")); @@ -253,15 +255,16 @@ public class RestApplicationTests { @Test public void uppercase() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/uppercase")).contentType(MediaType.APPLICATION_JSON) - .body("[\"foo\",\"bar\"]"), String.class); + .post(new URI("/stream/uppercase")) + .contentType(MediaType.APPLICATION_JSON).body("[\"foo\",\"bar\"]"), + String.class); assertThat(result.getBody()).isEqualTo("[\"(FOO)\",\"(BAR)\"]"); } @Test public void messages() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/messages")).contentType(MediaType.APPLICATION_JSON) + .post(new URI("/stream/messages")).contentType(MediaType.APPLICATION_JSON) .header("x-foo", "bar").body("[\"foo\",\"bar\"]"), String.class); assertThat(result.getBody()).isEqualTo("[\"(FOO)\",\"(BAR)\"]"); assertThat(result.getHeaders().getFirst("x-foo")).isEqualTo("bar"); @@ -271,7 +274,7 @@ public class RestApplicationTests { @Test public void headers() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/headers")).contentType(MediaType.APPLICATION_JSON) + .post(new URI("/stream/headers")).contentType(MediaType.APPLICATION_JSON) .body("[\"foo\",\"bar\"]"), String.class); assertThat(result.getBody()).isEqualTo("[\"(FOO)\",\"(BAR)\"]"); assertThat(result.getHeaders().getFirst("foo")).isEqualTo("bar"); @@ -282,7 +285,7 @@ public class RestApplicationTests { public void uppercaseSingleValue() throws Exception { ResponseEntity result = rest .exchange( - RequestEntity.post(new URI("/uppercase")) + RequestEntity.post(new URI("/stream/uppercase")) .contentType(MediaType.TEXT_PLAIN).body("foo"), String.class); assertThat(result.getBody()).isEqualTo("(FOO)"); @@ -292,7 +295,7 @@ public class RestApplicationTests { @Ignore("WebFlux would split the request body into lines: TODO make this work the same") public void uppercasePlainText() throws Exception { ResponseEntity result = rest.exchange( - RequestEntity.post(new URI("/uppercase")) + RequestEntity.post(new URI("/stream/uppercase")) .contentType(MediaType.TEXT_PLAIN).body("foo\nbar"), String.class); assertThat(result.getBody()).isEqualTo("(FOO)(BAR)"); @@ -301,7 +304,7 @@ public class RestApplicationTests { @Test public void uppercaseFoos() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/upFoos")).contentType(MediaType.APPLICATION_JSON) + .post(new URI("/stream/upFoos")).contentType(MediaType.APPLICATION_JSON) .body("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"), String.class); assertThat(result.getBody()) .isEqualTo("[{\"value\":\"FOO\"},{\"value\":\"BAR\"}]"); @@ -311,16 +314,19 @@ public class RestApplicationTests { public void uppercaseFoo() throws Exception { // Single Foo can be parsed ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/upFoos")).contentType(MediaType.APPLICATION_JSON) + .post(new URI("/stream/upFoos")).contentType(MediaType.APPLICATION_JSON) .body("{\"value\":\"foo\"}"), String.class); - assertThat(result.getBody()).isEqualTo("[{\"value\":\"FOO\"}]"); + assertThat(result.getBody()).isEqualTo("{\"value\":\"FOO\"}"); } @Test public void bareUppercaseFoos() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/bareUpFoos")).contentType(MediaType.APPLICATION_JSON) - .body("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"), String.class); + ResponseEntity result = rest + .exchange( + RequestEntity.post(new URI("/stream/bareUpFoos")) + .contentType(MediaType.APPLICATION_JSON) + .body("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"), + String.class); assertThat(result.getBody()) .isEqualTo("[{\"value\":\"FOO\"},{\"value\":\"BAR\"}]"); } @@ -330,79 +336,84 @@ public class RestApplicationTests { // Single Foo can be parsed and returns a single value if the function is defined // that way ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/bareUpFoos")).contentType(MediaType.APPLICATION_JSON) - .body("{\"value\":\"foo\"}"), String.class); + .post(new URI("/stream/bareUpFoos")) + .contentType(MediaType.APPLICATION_JSON).body("{\"value\":\"foo\"}"), + String.class); assertThat(result.getBody()).isEqualTo("{\"value\":\"FOO\"}"); } @Test public void bareUppercase() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/bareUppercase")).contentType(MediaType.APPLICATION_JSON) - .body("[\"foo\",\"bar\"]"), String.class); + .post(new URI("/stream/bareUppercase")) + .contentType(MediaType.APPLICATION_JSON).body("[\"foo\",\"bar\"]"), + String.class); assertThat(result.getBody()).isEqualTo("[\"(FOO)\",\"(BAR)\"]"); } @Test public void transform() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/transform")).contentType(MediaType.APPLICATION_JSON) - .body("[\"foo\",\"bar\"]"), String.class); + .post(new URI("/stream/transform")) + .contentType(MediaType.APPLICATION_JSON).body("[\"foo\",\"bar\"]"), + String.class); assertThat(result.getBody()).isEqualTo("[\"(FOO)\",\"(BAR)\"]"); } @Test public void postMore() throws Exception { ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/post/more")).contentType(MediaType.APPLICATION_JSON) - .body("[\"foo\",\"bar\"]"), String.class); + .post(new URI("/stream/post/more")) + .contentType(MediaType.APPLICATION_JSON).body("[\"foo\",\"bar\"]"), + String.class); assertThat(result.getBody()).isEqualTo("[\"(FOO)\",\"(BAR)\"]"); } @Test public void postMoreFoo() { - assertThat(rest.getForObject("/post/more/foo", String.class)).isEqualTo("(FOO)"); + assertThat(rest.getForObject("/stream/post/more/foo", String.class)) + .isEqualTo("(FOO)"); } @Test public void uppercaseGet() { - assertThat(rest.getForObject("/uppercase/foo", String.class)).isEqualTo("(FOO)"); + assertThat(rest.getForObject("/stream/uppercase/foo", String.class)) + .isEqualTo("(FOO)"); } @Test public void convertGet() { - assertThat(rest.getForObject("/wrap/123", String.class)).isEqualTo("..123.."); + assertThat(rest.getForObject("/stream/wrap/123", String.class)) + .isEqualTo("..123.."); } @Test public void convertPost() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity.post(new URI("/wrap")) + ResponseEntity result = rest.exchange(RequestEntity.post(new URI("/stream/wrap")) .contentType(MediaType.TEXT_PLAIN).body("123"), String.class); assertThat(result.getBody()).isEqualTo("..123.."); } @Test public void convertPostJson() throws Exception { - // If you POST a single value to a Function,Flux> it can't - // determine if the output is single valued, so it has to send an array back ResponseEntity result = rest .exchange( - RequestEntity.post(new URI("/doubler")) + RequestEntity.post(new URI("/stream/doubler")) .contentType(MediaType.TEXT_PLAIN).body("123"), String.class); - assertThat(result.getBody()).isEqualTo("[246]"); + assertThat(result.getBody()).isEqualTo("246"); } @Test public void supplierFirst() { - assertThat(rest.getForObject("/not/a/function", String.class)) + assertThat(rest.getForObject("/stream/not/a/function", String.class)) .isEqualTo("[\"hello\"]"); } @Test public void convertGetJson() throws Exception { assertThat(rest - .exchange(RequestEntity.get(new URI("/entity/321")) + .exchange(RequestEntity.get(new URI("/stream/entity/321")) .accept(MediaType.APPLICATION_JSON).build(), String.class) .getBody()).isEqualTo("{\"value\":321}"); } @@ -410,7 +421,7 @@ public class RestApplicationTests { @Test public void uppercaseJsonArray() throws Exception { assertThat(rest.exchange( - RequestEntity.post(new URI("/maps")) + RequestEntity.post(new URI("/stream/maps")) .contentType(MediaType.APPLICATION_JSON) // The new line in the middle is optional .body("[{\"value\":\"foo\"},\n{\"value\":\"bar\"}]"), @@ -419,8 +430,9 @@ public class RestApplicationTests { } @Test + @Ignore("Doesn't make sense: if you post in an array you expect to get back an array") public void uppercaseSSE() throws Exception { - assertThat(rest.exchange(RequestEntity.post(new URI("/uppercase")) + assertThat(rest.exchange(RequestEntity.post(new URI("/stream/uppercase")) .accept(EVENT_STREAM).contentType(MediaType.APPLICATION_JSON) .body("[\"foo\",\"bar\"]"), String.class).getBody()) .isEqualTo(sse("(FOO)", "(BAR)")); @@ -430,7 +442,6 @@ public class RestApplicationTests { return "data:" + StringUtils.arrayToDelimitedString(values, "\n\ndata:") + "\n\n"; } - @EnableAutoConfiguration @TestConfiguration public static class ApplicationConfiguration { @@ -541,7 +552,7 @@ public class RestApplicationTests { public Supplier> bang() { return () -> Flux.fromArray(new String[] { "foo", "bar" }).map(value -> { if (value.equals("bar")) { - throw new RuntimeException("Bar"); + // throw new RuntimeException("Bar"); } return value; }); @@ -549,7 +560,7 @@ public class RestApplicationTests { @Bean public Supplier> empty() { - return () -> Flux.fromIterable(Collections.emptyList()); + return () -> Flux.empty(); } @Bean("not/a/function") diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/SingletonTests.java b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/SingletonTests.java similarity index 63% rename from spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/SingletonTests.java rename to spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/SingletonTests.java index f678390b4..1eef4198e 100644 --- a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/SingletonTests.java +++ b/spring-cloud-function-stream/src/test/java/org/springframework/cloud/function/stream/web/SingletonTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.function.web; +package org.springframework.cloud.function.stream.web; import java.net.URI; import java.util.function.Supplier; @@ -28,15 +28,17 @@ import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; import org.springframework.beans.factory.support.RootBeanDefinition; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.context.annotation.Bean; +import org.springframework.cloud.function.stream.web.SingletonTests.TestApplicationConfiguration; +import org.springframework.core.PriorityOrdered; import org.springframework.http.HttpStatus; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringRunner; import static org.assertj.core.api.Assertions.assertThat; @@ -48,7 +50,9 @@ import reactor.core.publisher.Flux; * */ @RunWith(SpringRunner.class) -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = { "debug=true", + "spring.autoconfigure.exclude=org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration"}) +@ContextConfiguration(classes = TestApplicationConfiguration.class) public class SingletonTests { @LocalServerPort @@ -59,35 +63,33 @@ public class SingletonTests { @Test public void words() throws Exception { ResponseEntity result = rest.exchange( - RequestEntity.get(new URI("/words")).build(), String.class); + RequestEntity.get(new URI("/stream/words")).build(), String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isEqualTo("[\"foo\",\"bar\"]"); } - @EnableAutoConfiguration - @org.springframework.boot.test.context.TestConfiguration - protected static class TestConfiguration { - @Bean - public static BeanDefinitionRegistryPostProcessor processor() { - return new BeanDefinitionRegistryPostProcessor() { + @TestConfiguration + protected static class TestApplicationConfiguration + implements PriorityOrdered, BeanDefinitionRegistryPostProcessor { + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) + throws BeansException { + } - @Override - public void postProcessBeanFactory( - ConfigurableListableBeanFactory beanFactory) - throws BeansException { - } + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) + throws BeansException { + // Simulates what happens when you add a compiled function + RootBeanDefinition beanDefinition = new RootBeanDefinition(MySupplier.class); + registry.registerBeanDefinition("words", beanDefinition); + } - @Override - public void postProcessBeanDefinitionRegistry( - BeanDefinitionRegistry registry) throws BeansException { - // Simulates what happens when you add a compiled function - RootBeanDefinition beanDefinition = new RootBeanDefinition(MySupplier.class); - registry.registerBeanDefinition("words", beanDefinition); - } - }; + @Override + public int getOrder() { + return 0; } } - + static class MySupplier implements Supplier> { @Override public Flux get() { diff --git a/spring-cloud-function-web/pom.xml b/spring-cloud-function-web/pom.xml deleted file mode 100644 index 259d217cb..000000000 --- a/spring-cloud-function-web/pom.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - 4.0.0 - - spring-cloud-function-web - jar - Spring Cloud Function Web Support - Spring Cloud Function Web Support - - - org.springframework.cloud - spring-cloud-function-parent - 1.0.0.BUILD-SNAPSHOT - - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.cloud - spring-cloud-function-context - ${project.version} - - - org.springframework.boot - spring-boot-starter-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.springframework.boot.experimental - spring-boot-thin-layout - ${wrapper.version} - - - - - - - diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/FunctionController.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/FunctionController.java deleted file mode 100644 index 4dda62439..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/FunctionController.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2016-2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux; - -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import org.springframework.cloud.function.context.FunctionInspector; -import org.springframework.cloud.function.web.flux.request.FluxRequest; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestAttribute; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.ResponseBody; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -/** - * @author Dave Syer - * @author Mark Fisher - */ -@Component -public class FunctionController { - - private static Log logger = LogFactory.getLog(FunctionController.class); - - private FunctionInspector inspector; - - private boolean debug = false; - - public FunctionController(FunctionInspector inspector) { - this.inspector = inspector; - } - - public void setDebug(boolean debug) { - this.debug = debug; - } - - @PostMapping(path = "/**") - @ResponseBody - public ResponseEntity> post( - @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.flux.constants.WebRequestConstants.function") Function, Flux> function, - @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.flux.constants.WebRequestConstants.consumer") Consumer> consumer, - @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.flux.constants.WebRequestConstants.input_single") Boolean single, - @RequestBody FluxRequest body) { - if (function != null) { - Flux flux = body.flux(); - if (debug) { - flux = flux.log(); - } - Flux result = function.apply(flux); - if (logger.isDebugEnabled()) { - logger.debug("Handled POST with function"); - } - return ResponseEntity.ok().body(debug ? result.log() : result); - } - if (consumer != null) { - Flux flux = body.flux().cache(); // send a copy back to the caller - if (debug) { - flux = flux.log(); - } - consumer.accept(flux); - if (logger.isDebugEnabled()) { - logger.debug("Handled POST with consumer"); - } - return ResponseEntity.status(HttpStatus.ACCEPTED).body(flux); - } - throw new IllegalArgumentException("no such function"); - } - - @GetMapping(path = "/**") - @ResponseBody - public Object get( - @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.flux.constants.WebRequestConstants.function") Function, Flux> function, - @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.flux.constants.WebRequestConstants.supplier") Supplier> supplier, - @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.flux.constants.WebRequestConstants.argument") String argument) { - if (function != null) { - return value(function, argument); - } - return supplier(supplier); - } - - private Flux supplier(Supplier> supplier) { - Flux result = supplier.get(); - if (logger.isDebugEnabled()) { - logger.debug("Handled GET with supplier"); - } - return debug ? result.log() : result; - } - - private Mono value(Function, Flux> function, - @PathVariable String value) { - Object input = inspector.convert(function, value); - Mono result = Mono.from(function.apply(Flux.just(input))); - if (logger.isDebugEnabled()) { - logger.debug("Handled GET with function"); - } - return debug ? result.log() : result; - } -} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/FunctionHandlerMapping.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/FunctionHandlerMapping.java deleted file mode 100644 index 1a8440906..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/FunctionHandlerMapping.java +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright 2012-2015 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux; - -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - -import javax.servlet.http.HttpServletRequest; - -import org.springframework.beans.factory.InitializingBean; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.cloud.function.context.FunctionInspector; -import org.springframework.cloud.function.core.FunctionCatalog; -import org.springframework.cloud.function.web.flux.constants.WebRequestConstants; -import org.springframework.context.annotation.Configuration; -import org.springframework.util.StringUtils; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.HandlerMapping; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; - -/** - * @author Dave Syer - * - */ -@Configuration -@ConditionalOnClass(RequestMappingHandlerMapping.class) -public class FunctionHandlerMapping extends RequestMappingHandlerMapping - implements InitializingBean { - - private final FunctionCatalog functions; - - private final FunctionController controller; - - @Value("${spring.cloud.function.web.path:}") - private String prefix = ""; - - @Value("${debug:${DEBUG:false}}") - private String debug = "false"; - - @Autowired - public FunctionHandlerMapping(FunctionCatalog catalog, FunctionInspector inspector) { - this.functions = catalog; - logger.info("FunctionCatalog: " + catalog + ", FunctionInspector: " + inspector); - setOrder(super.getOrder() - 5); - this.controller = new FunctionController(inspector); - } - - @Override - public void afterPropertiesSet() { - super.afterPropertiesSet(); - this.controller.setDebug(!"false".equals(debug)); - detectHandlerMethods(controller); - while (prefix.endsWith("/")) { - prefix = prefix.substring(0, prefix.length() - 1); - } - } - - @Override - protected HandlerMethod getHandlerInternal(HttpServletRequest request) - throws Exception { - HandlerMethod handler = super.getHandlerInternal(request); - if (handler == null) { - return null; - } - String path = (String) request - .getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); - if (StringUtils.hasText(prefix) && !path.startsWith(prefix)) { - return null; - } - if (path.startsWith(prefix)) { - path = path.substring(prefix.length()); - } - if (path == null) { - return handler; - } - Object function = findFunctionForGet(request, path); - if (function != null) { - if (logger.isDebugEnabled()) { - logger.debug("Found function for GET: " + path); - } - request.setAttribute(WebRequestConstants.HANDLER, function); - return handler; - } - function = findFunctionForPost(request, path); - if (function != null) { - if (logger.isDebugEnabled()) { - logger.debug("Found function for POST: " + path); - } - request.setAttribute(WebRequestConstants.HANDLER, function); - return handler; - } - return null; - } - - private Object findFunctionForPost(HttpServletRequest request, String path) { - if (!request.getMethod().equals("POST")) { - return null; - } - path = path.startsWith("/") ? path.substring(1) : path; - Consumer consumer = functions.lookupConsumer(path); - if (consumer != null) { - request.setAttribute(WebRequestConstants.CONSUMER, consumer); - return consumer; - } - Function function = functions.lookupFunction(path); - if (function != null) { - request.setAttribute(WebRequestConstants.FUNCTION, function); - return function; - } - return null; - } - - private Object findFunctionForGet(HttpServletRequest request, String path) { - if (!request.getMethod().equals("GET")) { - return null; - } - path = path.startsWith("/") ? path.substring(1) : path; - Supplier supplier = functions.lookupSupplier(path); - if (supplier != null) { - request.setAttribute(WebRequestConstants.SUPPLIER, supplier); - return supplier; - } - StringBuilder builder = new StringBuilder(); - String name = path; - String value = null; - for (String element : path.split("/")) { - if (builder.length() > 0) { - builder.append("/"); - } - builder.append(element); - name = builder.toString(); - value = path.length() > name.length() ? path.substring(name.length() + 1) - : null; - Function function = functions.lookupFunction(name); - if (function != null) { - request.setAttribute(WebRequestConstants.FUNCTION, function); - request.setAttribute(WebRequestConstants.ARGUMENT, value); - return function; - } - } - return null; - } - -} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/ReactorAutoConfiguration.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/ReactorAutoConfiguration.java deleted file mode 100644 index 22ce38bf2..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/ReactorAutoConfiguration.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2013-2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux; - -import java.util.ArrayList; -import java.util.List; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import org.springframework.beans.BeansException; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.config.BeanPostProcessor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.autoconfigure.web.HttpMessageConverters; -import org.springframework.cloud.function.context.FunctionInspector; -import org.springframework.cloud.function.core.FunctionCatalog; -import org.springframework.cloud.function.web.flux.request.FluxHandlerMethodArgumentResolver; -import org.springframework.cloud.function.web.flux.response.FluxReturnValueHandler; -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.util.ClassUtils; -import org.springframework.web.method.support.AsyncHandlerMethodReturnValueHandler; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.HandlerMethodReturnValueHandler; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; - -import reactor.core.publisher.Flux; - -/** - * @author Dave Syer - * @author Mark Fisher - */ -@Configuration -@ConditionalOnWebApplication -@ConditionalOnClass({ Flux.class, AsyncHandlerMethodReturnValueHandler.class }) -public class ReactorAutoConfiguration { - - @Autowired - private ApplicationContext context; - - @Bean - public FunctionHandlerMapping functionHandlerMapping(FunctionCatalog catalog, - FunctionInspector inspector) { - return new FunctionHandlerMapping(catalog, inspector); - } - - @Configuration - @ConditionalOnMissingClass("org.springframework.core.ReactiveAdapter") - protected static class FluxReturnValueConfiguration { - @Bean - public FluxReturnValueHandler fluxReturnValueHandler(FunctionInspector inspector, - HttpMessageConverters converters) { - return new FluxReturnValueHandler(inspector, converters.getConverters()); - } - } - - @Configuration - protected static class FluxArgumentResolverConfiguration { - @Bean - public FluxHandlerMethodArgumentResolver fluxHandlerMethodArgumentResolver( - FunctionInspector inspector, ObjectMapper mapper) { - return new FluxHandlerMethodArgumentResolver(inspector, mapper); - } - } - - @Bean - public BeanPostProcessor fluxRequestMappingHandlerAdapterProcessor() { - return new BeanPostProcessor() { - @Override - public Object postProcessAfterInitialization(Object bean, String beanName) - throws BeansException { - if (bean instanceof RequestMappingHandlerAdapter) { - RequestMappingHandlerAdapter adapter = (RequestMappingHandlerAdapter) bean; - List resolvers = new ArrayList<>( - adapter.getArgumentResolvers()); - resolvers.add(0, - context.getBean(FluxHandlerMethodArgumentResolver.class)); - adapter.setArgumentResolvers(resolvers); - if (!ClassUtils.isPresent("org.springframework.core.ReactiveAdapter", - null)) { - List handlers = new ArrayList<>( - adapter.getReturnValueHandlers()); - handlers.add(0, context.getBean(FluxReturnValueHandler.class)); - adapter.setReturnValueHandlers(handlers); - } - } - return bean; - } - - @Override - public Object postProcessBeforeInitialization(Object bean, String beanName) - throws BeansException { - return bean; - } - }; - } -} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/constants/WebRequestConstants.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/constants/WebRequestConstants.java deleted file mode 100644 index 720313dbb..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/constants/WebRequestConstants.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2016-2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux.constants; - -/** - * Common storage for web request attribute names (in a separate package to avoid cycles). - * - * @author Dave Syer - * - */ -public abstract class WebRequestConstants { - - public static final String FUNCTION = WebRequestConstants.class.getName() - + ".function"; - public static final String CONSUMER = WebRequestConstants.class.getName() - + ".consumer"; - public static final String SUPPLIER = WebRequestConstants.class.getName() - + ".supplier"; - public static final String ARGUMENT = WebRequestConstants.class.getName() - + ".argument"; - public static final String HANDLER = WebRequestConstants.class.getName() + ".handler"; - public static final String INPUT_SINGLE = WebRequestConstants.class.getName() - + ".input_single"; - -} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/DelegateHandler.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/DelegateHandler.java deleted file mode 100644 index 14a9cd07a..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/DelegateHandler.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2016-2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux.request; - -import org.springframework.beans.factory.ListableBeanFactory; -import org.springframework.cloud.function.context.FunctionInspector; - -public abstract class DelegateHandler { - - private final ListableBeanFactory factory; - private FunctionInspector processor; - private Object handler; - private final Object source; - - public DelegateHandler(ListableBeanFactory factory, Object source) { - this.factory = factory; - this.source = source; - } - - public Class type() { - return processor().getInputType(handler()); - } - - private Object handler() { - if (handler == null) { - handler = source instanceof String ? factory.getBean((String) source) - : source; - } - return handler; - } - - private FunctionInspector processor() { - if (processor == null) { - processor = factory.getBean(FunctionInspector.class); - } - return processor; - } - -} \ No newline at end of file diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/FluxHandlerMethodArgumentResolver.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/FluxHandlerMethodArgumentResolver.java deleted file mode 100644 index 3a0cebba0..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/request/FluxHandlerMethodArgumentResolver.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2016-2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux.request; - -import java.nio.charset.Charset; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import javax.servlet.http.HttpServletRequest; - -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import org.springframework.cloud.function.context.FunctionInspector; -import org.springframework.cloud.function.web.flux.constants.WebRequestConstants; -import org.springframework.cloud.function.web.util.HeaderUtils; -import org.springframework.core.MethodParameter; -import org.springframework.core.Ordered; -import org.springframework.http.MediaType; -import org.springframework.http.server.ServletServerHttpRequest; -import org.springframework.messaging.support.MessageBuilder; -import org.springframework.util.StreamUtils; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; -import org.springframework.web.util.ContentCachingRequestWrapper; - -/** - * Converter for request bodies of type Flux. - * - * @author Dave Syer - * - */ -public class FluxHandlerMethodArgumentResolver - implements HandlerMethodArgumentResolver, Ordered { - - private static Log logger = LogFactory - .getLog(FluxHandlerMethodArgumentResolver.class); - - private final ObjectMapper mapper; - - private FunctionInspector inspector; - - public FluxHandlerMethodArgumentResolver(FunctionInspector inspector, - ObjectMapper mapper) { - this.inspector = inspector; - this.mapper = mapper; - } - - @Override - public int getOrder() { - return Ordered.HIGHEST_PRECEDENCE; - } - - @Override - public Object resolveArgument(MethodParameter parameter, - ModelAndViewContainer mavContainer, NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) throws Exception { - Object handler = webRequest.getAttribute(WebRequestConstants.HANDLER, - NativeWebRequest.SCOPE_REQUEST); - Class type = inspector.getInputType(handler); - if (type == null) { - type = Object.class; - } - boolean message = inspector.isMessage(handler); - List body; - ContentCachingRequestWrapper nativeRequest = new ContentCachingRequestWrapper( - webRequest.getNativeRequest(HttpServletRequest.class)); - if (logger.isDebugEnabled()) { - logger.debug("Resolving request body into type: " + type); - } - if (isPlainText(webRequest) && CharSequence.class.isAssignableFrom(type)) { - body = Arrays.asList(StreamUtils.copyToString(nativeRequest.getInputStream(), - Charset.forName("UTF-8"))); - } - else { - try { - body = mapper.readValue(nativeRequest.getInputStream(), - mapper.getTypeFactory() - .constructCollectionLikeType(ArrayList.class, type)); - } - catch (JsonMappingException e) { - nativeRequest.setAttribute(WebRequestConstants.INPUT_SINGLE, true); - body = Arrays.asList( - mapper.readValue(nativeRequest.getContentAsByteArray(), type)); - } - } - if (message) { - List messages = new ArrayList<>(); - for (Object payload : body) { - messages.add(MessageBuilder.withPayload(payload) - .copyHeaders(HeaderUtils.fromHttp(new ServletServerHttpRequest( - webRequest.getNativeRequest(HttpServletRequest.class)) - .getHeaders())) - .build()); - } - body = messages; - } - return new FluxRequest(body); - } - - private boolean isPlainText(NativeWebRequest webRequest) { - String value = webRequest.getHeader("Content-Type"); - if (value != null) { - return MediaType.valueOf(value).isCompatibleWith(MediaType.TEXT_PLAIN); - } - return false; - } - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return FluxRequest.class.isAssignableFrom(parameter.getParameterType()); - } - -} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxResponseBodyEmitter.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxResponseBodyEmitter.java deleted file mode 100644 index d8814cca9..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxResponseBodyEmitter.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2013-2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux.response; - -import org.reactivestreams.Publisher; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.server.ServerHttpResponse; -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; - -import reactor.core.publisher.Flux; - -/** - * A specialized {@link ResponseBodyEmitter} that handles {@link Flux} return types. - * - * @author Dave Syer - */ -class FluxResponseBodyEmitter extends ResponseBodyEmitter { - - private final MediaType mediaType; - private ResponseBodyEmitterSubscriber subscriber; - - public FluxResponseBodyEmitter(Publisher observable) { - this(new HttpHeaders(), null, observable); - } - - public FluxResponseBodyEmitter(HttpHeaders request, MediaType mediaType, - Publisher observable) { - super(); - this.mediaType = mediaType; - this.subscriber = new ResponseBodyEmitterSubscriber(request, mediaType, - observable, this, MediaType.APPLICATION_JSON.isCompatibleWith(mediaType)); - } - - @Override - protected void extendResponse(ServerHttpResponse outputMessage) { - super.extendResponse(outputMessage); - this.subscriber.extendResponse(outputMessage); - HttpHeaders headers = outputMessage.getHeaders(); - if (headers.getContentType() == null && this.mediaType != null - && !MediaType.ALL.equals(this.mediaType)) { - headers.setContentType(this.mediaType); - } - } -} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxResponseSseEmitter.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxResponseSseEmitter.java deleted file mode 100644 index 07d9e0c5b..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxResponseSseEmitter.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2013-2016 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux.response; - -import org.reactivestreams.Publisher; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.server.ServerHttpResponse; -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import reactor.core.publisher.Flux; - -/** - * A specialized {@link ResponseBodyEmitter} that handles {@link Flux} return types with - * SSE streams. - * - * @author Dave Syer - */ -class FluxResponseSseEmitter extends SseEmitter { - - private ResponseBodyEmitterSubscriber subscriber; - - public FluxResponseSseEmitter(Publisher observable) { - this(new HttpHeaders(), MediaType.valueOf("text/plain"), observable); - } - - public FluxResponseSseEmitter(HttpHeaders request, MediaType mediaType, - Publisher observable) { - super(); - this.subscriber = new ResponseBodyEmitterSubscriber(request, mediaType, - observable, this, false); - } - - @Override - protected void extendResponse(ServerHttpResponse outputMessage) { - super.extendResponse(outputMessage); - this.subscriber.extendResponse(outputMessage); - } -} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxReturnValueHandler.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxReturnValueHandler.java deleted file mode 100644 index 2749837eb..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/FluxReturnValueHandler.java +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright 2013-2016 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux.response; - -import java.lang.reflect.Method; -import java.time.Duration; -import java.util.Arrays; -import java.util.List; -import java.util.function.Supplier; -import java.util.stream.Stream; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.apache.el.stream.Optional; -import org.reactivestreams.Publisher; - -import org.springframework.cloud.function.context.FunctionInspector; -import org.springframework.cloud.function.web.flux.constants.WebRequestConstants; -import org.springframework.cloud.function.web.util.HeaderUtils; -import org.springframework.core.MethodParameter; -import org.springframework.core.ResolvableType; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.server.ServletServerHttpRequest; -import org.springframework.messaging.Message; -import org.springframework.util.ReflectionUtils; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.AsyncHandlerMethodReturnValueHandler; -import org.springframework.web.method.support.ModelAndViewContainer; -import org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor; -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitterReturnValueHandler; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -/** - * A specialized {@link AsyncHandlerMethodReturnValueHandler} that handles {@link Flux} - * return types. - * - * @author Dave Syer - */ -public class FluxReturnValueHandler implements AsyncHandlerMethodReturnValueHandler { - - private static Log logger = LogFactory.getLog(FluxReturnValueHandler.class); - - private ResponseBodyEmitterReturnValueHandler delegate; - private RequestResponseBodyMethodProcessor single; - private long timeout = 1000L; - private static final MediaType EVENT_STREAM = MediaType.valueOf("text/event-stream"); - - private FunctionInspector inspector; - - private MethodParameter singleReturnType; - - public FluxReturnValueHandler(FunctionInspector inspector, - List> messageConverters) { - this.inspector = inspector; - this.delegate = new ResponseBodyEmitterReturnValueHandler(messageConverters); - this.single = new RequestResponseBodyMethodProcessor(messageConverters); - Method method = ReflectionUtils.findMethod(getClass(), "singleValue"); - singleReturnType = new MethodParameter(method, -1); - } - - ResponseEntity singleValue() { - return null; - } - - /** - * Timeout for clients. If no items are seen on an HTTP response in this period then - * the response is closed. - * - * @param timeout the timeout to set - */ - public void setTimeout(long timeout) { - this.timeout = timeout; - } - - @Override - public boolean isAsyncReturnValue(Object returnValue, MethodParameter returnType) { - if (returnValue != null) { - return supportsReturnType(returnType); - } - return false; - } - - @Override - public boolean supportsReturnType(MethodParameter returnType) { - return (returnType.getParameterType() != null - && (Publisher.class.isAssignableFrom(returnType.getParameterType()) - || isResponseEntity(returnType))) - || Publisher.class - .isAssignableFrom(returnType.getMethod().getReturnType()); - } - - private boolean isResponseEntity(MethodParameter returnType) { - if (ResponseEntity.class.isAssignableFrom(returnType.getParameterType())) { - Class bodyType = ResolvableType.forMethodParameter(returnType) - .getGeneric(0).resolve(); - return bodyType != null && Flux.class.isAssignableFrom(bodyType); - } - return false; - } - - @Override - public void handleReturnValue(Object returnValue, MethodParameter returnType, - ModelAndViewContainer mavContainer, NativeWebRequest webRequest) - throws Exception { - - if (returnValue == null) { - mavContainer.setRequestHandled(true); - return; - } - - Object adaptFrom = returnValue; - if (returnValue instanceof ResponseEntity) { - ResponseEntity value = (ResponseEntity) returnValue; - adaptFrom = value.getBody(); - HttpServletResponse response = webRequest - .getNativeResponse(HttpServletResponse.class); - response.setStatus(value.getStatusCodeValue()); - HttpHeaders headers = value.getHeaders(); - for (String name : headers.keySet()) { - List list = headers.get(name); - for (String header : list) { - response.addHeader(name, header); - } - } - } - Publisher flux = (Publisher) adaptFrom; - - Object handler = webRequest.getAttribute(WebRequestConstants.HANDLER, - NativeWebRequest.SCOPE_REQUEST); - Class type = inspector.getOutputType(handler); - - boolean inputSingle = isInputSingle(webRequest, handler); - if (inputSingle && isOutputSingle(handler)) { - Object result = Flux.from(flux).blockFirst(); - if (result instanceof Message) { - Message message = (Message) result; - result = message.getPayload(); - addHeaders(webRequest, message); - } - single.handleReturnValue(result, singleReturnType, mavContainer, webRequest); - return; - } - - MediaType mediaType = null; - if (isPlainText(webRequest) && CharSequence.class.isAssignableFrom(type)) { - mediaType = MediaType.TEXT_PLAIN; - } - else { - mediaType = findMediaType(webRequest); - } - if (logger.isDebugEnabled()) { - logger.debug( - "Handling return value " + type + " with media type: " + mediaType); - } - ServletServerHttpRequest request = new ServletServerHttpRequest( - webRequest.getNativeRequest(HttpServletRequest.class)); - delegate.handleReturnValue( - getEmitter(timeout, flux, mediaType, request.getHeaders()), returnType, - mavContainer, webRequest); - } - - private void addHeaders(NativeWebRequest webRequest, Message message) { - HttpServletResponse response = webRequest - .getNativeResponse(HttpServletResponse.class); - ServletServerHttpRequest request = new ServletServerHttpRequest( - webRequest.getNativeRequest(HttpServletRequest.class)); - HttpHeaders headers = HeaderUtils.fromMessage(message.getHeaders(), - request.getHeaders()); - for (String name : headers.keySet()) { - for (Object object : headers.get(name)) { - response.addHeader(name, object.toString()); - } - } - } - - private boolean isInputSingle(NativeWebRequest webRequest, Object handler) { - Boolean single = (Boolean) webRequest.getAttribute( - WebRequestConstants.INPUT_SINGLE, NativeWebRequest.SCOPE_REQUEST); - if (single == null) { - return handler instanceof Supplier; - } - return single; - } - - private boolean isOutputSingle(Object handler) { - Class type = inspector.getOutputType(handler); - Class wrapper = inspector.getOutputWrapper(handler); - if (Stream.class.isAssignableFrom(type)) { - return false; - } - if (wrapper == type) { - return true; - } - if (Mono.class.equals(wrapper) || Optional.class.equals(wrapper)) { - return true; - } - return false; - } - - private MediaType findMediaType(NativeWebRequest webRequest) { - List accepts = Arrays.asList(MediaType.ALL); - MediaType mediaType = null; - if (webRequest.getHeader("Accept") != null) { - accepts = MediaType.parseMediaTypes(webRequest.getHeader("Accept")); - for (MediaType accept : accepts) { - if (!MediaType.ALL.equals(accept) - && MediaType.APPLICATION_JSON.isCompatibleWith(accept)) { - mediaType = MediaType.APPLICATION_JSON; - // Prefer JSON if that is acceptable - break; - } - else if (mediaType == null) { - mediaType = accept; - } - } - } - if (mediaType == null) { - mediaType = MediaType.APPLICATION_JSON; - } - return mediaType; - } - - private boolean isPlainText(NativeWebRequest webRequest) { - String value = webRequest.getHeader("Content-Type"); - if (value != null) { - return MediaType.valueOf(value).isCompatibleWith(MediaType.TEXT_PLAIN); - } - return false; - } - - private ResponseBodyEmitter getEmitter(Long timeout, Publisher flux, - MediaType mediaType, HttpHeaders request) { - Publisher exported = flux instanceof Mono ? Mono.from(flux) - : Flux.from(flux).timeout(Duration.ofMillis(timeout), Flux.empty()); - if (!MediaType.ALL.equals(mediaType) - && EVENT_STREAM.isCompatibleWith(mediaType)) { - // TODO: more subtle content negotiation - return new FluxResponseSseEmitter(request, MediaType.APPLICATION_JSON, - exported); - } - return new FluxResponseBodyEmitter(request, mediaType, exported); - } - -} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/ResponseBodyEmitterSubscriber.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/ResponseBodyEmitterSubscriber.java deleted file mode 100644 index 3c5961561..000000000 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/flux/response/ResponseBodyEmitterSubscriber.java +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Copyright 2013-2016 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.function.web.flux.response; - -import java.io.IOException; -import java.util.concurrent.TimeoutException; - -import org.reactivestreams.Publisher; -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; - -import org.springframework.cloud.function.web.util.HeaderUtils; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.server.ServerHttpResponse; -import org.springframework.messaging.Message; -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -/** - * Subscriber that emits any value produced by the {@link Flux} into the delegated - * {@link ResponseBodyEmitter}. - * - * @author Dave Syer - */ -class ResponseBodyEmitterSubscriber implements Subscriber { - - private final MediaType mediaType; - - private Subscription subscription; - - private final ResponseBodyEmitter responseBodyEmitter; - - private boolean completed; - - private boolean firstElementWritten; - - private boolean single; - - private final boolean json; - - private Message first; - - private final HttpHeaders request; - - public ResponseBodyEmitterSubscriber(HttpHeaders request, MediaType mediaType, - Publisher observable, ResponseBodyEmitter responseBodyEmitter, - boolean json) { - - this.request = request; - this.mediaType = mediaType; - this.responseBodyEmitter = responseBodyEmitter; - this.json = json; - this.responseBodyEmitter.onTimeout(new Timeout()); - this.responseBodyEmitter.onCompletion(new Complete()); - this.single = observable instanceof Mono; - observable.subscribe(this); - } - - public void extendResponse(ServerHttpResponse response) { - headers(response); - } - - @Override - public void onSubscribe(Subscription subscription) { - this.subscription = subscription; - subscription.request(Long.MAX_VALUE); - } - - @Override - public void onNext(Object value) { - - Object object = value; - - if (object instanceof Message) { - Message message = (Message) object; - object = message.getPayload(); - this.first = message; - } - - try { - if (isJson()) { - if (!this.firstElementWritten) { - if (!single) { - responseBodyEmitter.send("["); - this.firstElementWritten = true; - } - } - else { - responseBodyEmitter.send(","); - } - if (!single && object.getClass() == String.class - && !((String) object).contains("\"")) { - object = "\"" + object + "\""; - } - } - if (!completed) { - responseBodyEmitter.send(object, mediaType); - } - } - catch ( - - IOException e) { - throw new RuntimeException(e.getMessage(), e); - } - } - - private void headers(ServerHttpResponse response) { - if (this.first != null) { - Message message = first; - try { - HttpHeaders headers = HeaderUtils.fromMessage(message.getHeaders(), - request); - for (String name : headers.keySet()) { - for (String value : headers.get(name)) { - response.getHeaders().add(name, value); - } - } - } - catch (Exception e) { - // Headers could not be set - } - } - } - - @Override - public void onError(Throwable e) { - if (!completed) { - completed = true; - try { - if (isJson()) { - if (!single) { - if (!this.firstElementWritten) { - responseBodyEmitter.send("[]"); - } - else { - responseBodyEmitter.send("]"); - } - } - } - if (e instanceof TimeoutException) { - responseBodyEmitter.complete(); - } - else { - responseBodyEmitter.completeWithError(e); - } - } - catch (IOException ex) { - throw new RuntimeException(ex.getMessage(), ex); - } - } - } - - @Override - public void onComplete() { - if (!completed) { - completed = true; - try { - if (isJson()) { - if (!single) { - if (!this.firstElementWritten) { - responseBodyEmitter.send("["); - } - responseBodyEmitter.send("]"); - } - } - } - catch (IOException e) { - throw new RuntimeException(e.getMessage(), e); - } - responseBodyEmitter.complete(); - } - } - - private boolean isJson() { - return json; - } - - class Complete implements Runnable { - - @Override - public void run() { - ResponseBodyEmitterSubscriber.this.subscription.cancel(); - } - } - - class Timeout implements Runnable { - - @Override - public void run() { - onComplete(); - ResponseBodyEmitterSubscriber.this.subscription.cancel(); - } - } - -} diff --git a/spring-cloud-function-web/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-function-web/src/main/resources/META-INF/additional-spring-configuration-metadata.json deleted file mode 100644 index 5c6dc17e8..000000000 --- a/spring-cloud-function-web/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ /dev/null @@ -1,9 +0,0 @@ -{"properties": [ - { - "name": "spring.cloud.function.web.path", - "type": "java.lang.String", - "description": "Path to web resources for functions (should start with / if not empty).", - "defaultValue": "" - }] -} - diff --git a/spring-cloud-function-web/src/main/resources/META-INF/spring.factories b/spring-cloud-function-web/src/main/resources/META-INF/spring.factories deleted file mode 100644 index 812023313..000000000 --- a/spring-cloud-function-web/src/main/resources/META-INF/spring.factories +++ /dev/null @@ -1,2 +0,0 @@ -org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ -org.springframework.cloud.function.web.flux.ReactorAutoConfiguration \ No newline at end of file diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/mvc/MvcRestApplicationTests.java b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/mvc/MvcRestApplicationTests.java deleted file mode 100644 index c7b6075d1..000000000 --- a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/mvc/MvcRestApplicationTests.java +++ /dev/null @@ -1,392 +0,0 @@ -/* - * Copyright 2016-2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.cloud.function.mvc; - -import java.net.URI; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Test; -import org.junit.runner.RunWith; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.context.embedded.LocalServerPort; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.cloud.function.mvc.MvcRestApplicationTests.TestConfiguration; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; -import org.springframework.test.context.junit4.SpringRunner; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import static org.assertj.core.api.Assertions.assertThat; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -/** - * Tests for vanilla MVC handling (no function layer). Validates the MVC customizations - * that are added in this project independently of the specific concerns of function. - * - * @author Dave Syer - * - */ -@RunWith(SpringRunner.class) -@SpringBootTest(classes = TestConfiguration.class, webEnvironment = WebEnvironment.RANDOM_PORT) -public class MvcRestApplicationTests { - - private static final MediaType EVENT_STREAM = MediaType.valueOf("text/event-stream"); - @LocalServerPort - private int port; - @Autowired - private TestRestTemplate rest; - @Autowired - private TestConfiguration test; - - @Before - public void init() { - test.list.clear(); - } - - @Test - public void wordsSSE() throws Exception { - assertThat(rest.exchange( - RequestEntity.get(new URI("/words")).accept(EVENT_STREAM).build(), - String.class).getBody()).isEqualTo(sse("foo", "bar")); - } - - @Test - public void wordsJson() throws Exception { - assertThat(rest - .exchange(RequestEntity.get(new URI("/words")) - .accept(MediaType.APPLICATION_JSON).build(), String.class) - .getBody()).isEqualTo("[\"foo\",\"bar\"]"); - } - - @Test - @Ignore("Fix error handling") - public void errorJson() throws Exception { - assertThat(rest - .exchange(RequestEntity.get(new URI("/bang")) - .accept(MediaType.APPLICATION_JSON).build(), String.class) - .getBody()).isEqualTo("[\"foo\"]"); - } - - @Test - public void words() throws Exception { - ResponseEntity result = rest - .exchange(RequestEntity.get(new URI("/words")).build(), String.class); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody()).isEqualTo("[\"foo\",\"bar\"]"); - } - - @Test - public void foos() throws Exception { - ResponseEntity result = rest - .exchange(RequestEntity.get(new URI("/foos")).build(), String.class); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody()) - .isEqualTo("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"); - } - - @Test - public void getMore() throws Exception { - ResponseEntity result = rest - .exchange(RequestEntity.get(new URI("/get/more")).build(), String.class); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody()).isEqualTo("[\"foo\",\"bar\"]"); - } - - @Test - @Ignore("Should this even work? Or do we need to be explicit about the JSON?") - public void updates() throws Exception { - ResponseEntity result = rest.exchange( - RequestEntity.post(new URI("/updates")).body("one\ntwo"), String.class); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); - assertThat(test.list).hasSize(2); - assertThat(result.getBody()).isEqualTo("onetwo"); - } - - @Test - public void updatesJson() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/updates")).contentType(MediaType.APPLICATION_JSON) - .body("[\"one\",\"two\"]"), String.class); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); - assertThat(test.list).hasSize(2); - assertThat(result.getBody()).isEqualTo("[\"one\",\"two\"]"); - } - - @Test - public void addFoos() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/addFoos")).contentType(MediaType.APPLICATION_JSON) - .body("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"), String.class); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); - assertThat(test.list).hasSize(2); - assertThat(result.getBody()) - .isEqualTo("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"); - } - - @Test - public void timeout() throws Exception { - assertThat(rest - .exchange(RequestEntity.get(new URI("/timeout")).build(), String.class) - .getBody()).isEqualTo("[\"foo\"]"); - } - - @Test - public void emptyJson() throws Exception { - assertThat(rest - .exchange(RequestEntity.get(new URI("/empty")) - .accept(MediaType.APPLICATION_JSON).build(), String.class) - .getBody()).isEqualTo("[]"); - } - - @Test - public void sentences() throws Exception { - assertThat(rest - .exchange(RequestEntity.get(new URI("/sentences")).build(), String.class) - .getBody()).isEqualTo("[[\"go\",\"home\"],[\"come\",\"back\"]]"); - } - - @Test - public void sentencesAcceptAny() throws Exception { - assertThat(rest.exchange( - RequestEntity.get(new URI("/sentences")).accept(MediaType.ALL).build(), - String.class).getBody()) - .isEqualTo("[[\"go\",\"home\"],[\"come\",\"back\"]]"); - } - - @Test - public void sentencesAcceptJson() throws Exception { - ResponseEntity result = rest - .exchange( - RequestEntity.get(new URI("/sentences")) - .accept(MediaType.APPLICATION_JSON).build(), - String.class); - assertThat(result.getBody()).isEqualTo("[[\"go\",\"home\"],[\"come\",\"back\"]]"); - assertThat(result.getHeaders().getContentType()) - .isGreaterThanOrEqualTo(MediaType.APPLICATION_JSON); - } - - @Test - public void uppercase() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/uppercase")).contentType(MediaType.APPLICATION_JSON) - .body("[\"foo\",\"bar\"]"), String.class); - assertThat(result.getBody()).isEqualTo("[\"[FOO]\",\"[BAR]\"]"); - } - - @Test - public void uppercaseFoos() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/upFoos")).contentType(MediaType.APPLICATION_JSON) - .body("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"), String.class); - assertThat(result.getBody()) - .isEqualTo("[{\"value\":\"FOO\"},{\"value\":\"BAR\"}]"); - } - - @Test - public void transform() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/transform")).contentType(MediaType.APPLICATION_JSON) - .body("[\"foo\",\"bar\"]"), String.class); - assertThat(result.getBody()).isEqualTo("[\"[FOO]\",\"[BAR]\"]"); - } - - @Test - public void postMore() throws Exception { - ResponseEntity result = rest.exchange(RequestEntity - .post(new URI("/post/more")).contentType(MediaType.APPLICATION_JSON) - .body("[\"foo\",\"bar\"]"), String.class); - assertThat(result.getBody()).isEqualTo("[\"[FOO]\",\"[BAR]\"]"); - } - - @Test - public void uppercaseGet() { - assertThat(rest.getForObject("/uppercase/foo", String.class)).isEqualTo("[FOO]"); - } - - @Test - public void convertGet() { - assertThat(rest.getForObject("/wrap/123", String.class)).isEqualTo("..123.."); - } - - @Test - public void convertGetJson() throws Exception { - assertThat(rest - .exchange(RequestEntity.get(new URI("/entity/321")) - .accept(MediaType.APPLICATION_JSON).build(), String.class) - .getBody()).isEqualTo("{\"value\":321}"); - } - - @Test - public void uppercaseJsonStream() throws Exception { - assertThat(rest - .exchange(RequestEntity.post(new URI("/maps")) - .contentType(MediaType.APPLICATION_JSON) - .body("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"), String.class) - .getBody()).isEqualTo("[{\"value\":\"FOO\"},{\"value\":\"BAR\"}]"); - } - - @Test - public void uppercaseSSE() throws Exception { - assertThat(rest.exchange(RequestEntity.post(new URI("/uppercase")) - .accept(EVENT_STREAM).contentType(MediaType.APPLICATION_JSON) - .body("[\"foo\",\"bar\"]"), String.class).getBody()) - .isEqualTo(sse("[FOO]", "[BAR]")); - } - - private String sse(String... values) { - return "data:" + StringUtils.arrayToDelimitedString(values, "\n\ndata:") + "\n\n"; - } - - @EnableAutoConfiguration - @RestController - @Configuration - public static class TestConfiguration { - - private List list = new ArrayList<>(); - - @PostMapping({ "/uppercase", "/transform", "/post/more" }) - public Flux uppercase(@RequestBody List flux) { - return Flux.fromIterable(flux).log() - .map(value -> "[" + value.trim().toUpperCase() + "]"); - } - - @PostMapping("/upFoos") - public Flux upFoos(@RequestBody List list) { - return Flux.fromIterable(list).log() - .map(value -> new Foo(value.getValue().trim().toUpperCase())); - } - - @GetMapping("/uppercase/{id}") - public Mono uppercaseGet(@PathVariable String id) { - return Mono.just(id).map(value -> "[" + value.trim().toUpperCase() + "]"); - } - - @GetMapping("/wrap/{id}") - public Mono wrapGet(@PathVariable int id) { - return Mono.just(id).log().map(value -> ".." + value + ".."); - } - - @GetMapping("/entity/{id}") - public Mono> entity(@PathVariable Integer id) { - return Mono.just(id).log() - .map(value -> Collections.singletonMap("value", value)); - } - - @PostMapping("/maps") - public Flux> maps( - @RequestBody List> flux) { - return Flux.fromIterable(flux).map(value -> { - value.put("value", value.get("value").trim().toUpperCase()); - return value; - }); - } - - @GetMapping({ "/words", "/get/more" }) - public Flux words() { - return Flux.fromArray(new String[] { "foo", "bar" }); - } - - @GetMapping("/foos") - public Flux foos() { - return Flux.just(new Foo("foo"), new Foo("bar")); - } - - @PostMapping("/updates") - @ResponseStatus(HttpStatus.ACCEPTED) - public Flux updates(@RequestBody List list) { - Flux flux = Flux.fromIterable(list).cache(); - flux.subscribe(value -> this.list.add(value)); - return flux; - } - - @PostMapping("/addFoos") - @ResponseStatus(HttpStatus.ACCEPTED) - public Flux addFoos(@RequestBody List list) { - Flux flux = Flux.fromIterable(list).cache(); - flux.subscribe(value -> this.list.add(value.getValue())); - return flux; - } - - @GetMapping("/bang") - public Flux bang() { - return Flux.fromArray(new String[] { "foo", "bar" }).map(value -> { - if (value.equals("bar")) { - throw new RuntimeException("Bar"); - } - return value; - }); - } - - @GetMapping("/empty") - public Flux empty() { - return Flux.fromIterable(Collections.emptyList()); - } - - @GetMapping("/timeout") - public Flux timeout() { - return Flux.defer(() -> Flux.create(emitter -> { - emitter.next("foo"); - }).timeout(Duration.ofMillis(100L), Flux.empty())); - } - - @GetMapping("/sentences") - public Flux> sentences() { - return Flux.just(Arrays.asList("go", "home"), Arrays.asList("come", "back")); - } - - } - - public static class Foo { - private String value; - - public Foo(String value) { - this.value = value; - } - - Foo() { - } - - public String getValue() { - return value; - } - - public void setValue(String value) { - this.value = value; - } - } -} diff --git a/spring-cloud-function-web/src/test/resources/static/test.html b/spring-cloud-function-web/src/test/resources/static/test.html deleted file mode 100644 index 9ca4440f5..000000000 --- a/spring-cloud-function-web/src/test/resources/static/test.html +++ /dev/null @@ -1 +0,0 @@ -Test \ No newline at end of file diff --git a/spring-cloud-function-web/.jdk8 b/spring-cloud-stream-binder-servlet/.jdk8 similarity index 100% rename from spring-cloud-function-web/.jdk8 rename to spring-cloud-stream-binder-servlet/.jdk8 diff --git a/spring-cloud-stream-binder-servlet/README.md b/spring-cloud-stream-binder-servlet/README.md new file mode 100644 index 000000000..bead7ec45 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/README.md @@ -0,0 +1,39 @@ +A Spring Cloud Stream binder for a Servlet container using Spring MVC. + +## Usage + +Add this jar to the classpath of a Spring Cloud Stream application as the only binder implementation. Endpoints are exposed at + +| Method | Path | Description | +|-----------|------------------------------------|----------------------------| +| GET | `/stream/{channel}/{route}` | If the channel is an `@Output` returns a list of the payloads of all messages sent to the channel with a routing key equal to `{route}`. | +| GET | `/stream/{channel}/{route}/{body}` | If the channel is an `@Input` sends the last segment of the path (the `{body}`) as a payload to the `{route}`. If the channel is not an input, it's an error (404). | +| POST | `/stream/{channel}/{route}` | Accepts a single value or a list of payloads and sends them to the `@Input` called `{channel}` with routing key header equal to `{route}`.| + +The result of the POST depends on whether an `@Output` is linked to the `@Input`. By default a link is made if the user has `@EnableBinding` with an interface having precisely one `@Output` and one `@Input` (e.g. using `Processor` from Spring Cloud Stream). In the case that there is no linked `@Output`, the return value from the POST is a 202 (Accepted) and a mirror of the input. If an `@Output` is linked, then the contents of the output channel are returned with a 200 status (OK). + +The routing key is sent via a message header named `stream_routekey`. It is a plain string, and can be multiple URI segments (i.e. contain "/"). It will show up in HTTP response headers if present. Message headers can be sent in a POST request using HTTP headers (as well as the special case of the route key being part of the path). + +Both the channel and route are optional, but can be used to disambiguate if necessary. So for example: + +| Method | Path | Description | +|-----------|--------------------------|----------------------------| +| GET | `/stream` | Defaults the channel name to `output` (or to the name of the `@Output` if there is only one). Messages sent with no routing key are delivered. | +| GET | `/stream/{channel}` | Uses and explicit channel name but an empty routing key. If the channel is not a registered output, then it will be interpreted as a route (see below). | +| GET | `/stream/{route}/{body}` | Equivalent to `GET /stream/input/{route}/{body}` if there is a unique input channel. | +| GET | `/stream/{route}` | If the channel is missing (i.e. the first path segment is not a registered output or input), then it will be interpreted as a route. Equivalent to `GET /stream/output/{route}` if there is a unique output channel. | +| POST | `/stream/{route}` | As long as the first segment of the route is not an input channel name, this defaults the channel to `input` (or to the name of the `@Input` if it is unique).| +| POST | `/stream` | Defaults the channel to `input` (or to the name of the `@Input` if it is unique). The routing key is empty.| + + +Note that with a GET, if the channel is not a registered output, then it will be interpreted as a route. So if there is a default input channel, then the path will be transformed into `{route}/{body}` (agin with route optional, if there is only one path segment) and sent to the input channel. + +The result of a GET is a moving time window by default (the last 10 seconds of data are buffered). Clients can request an infinite stream of data using `GET` with `Accept: text/event-stream` (or a compatible media type). + +Configuration properties (in addition to the ones provided by Spring Cloud Stream for bindings and channel names, etc.): + +| Key | Default | Description | +|--------------------------------|---------|----------------------------| +| `spring.cloud.stream.binder.servlet.prefix` | `stream` | The prefix for the URL paths | +| `spring.cloud.stream.binder.servlet.buffer-timeout-seconds` | 10 | The buffer size in seconds to store messages from the output channels. | +| `spring.cloud.stream.binder.servlet.receive-timeout-millis` | 100 | The timeout for send and receive if POST has a linked output channel. Only relevant if the message processing is asynchronous. | diff --git a/spring-cloud-stream-binder-servlet/pom.xml b/spring-cloud-stream-binder-servlet/pom.xml new file mode 100644 index 000000000..a5f3a2ad9 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/pom.xml @@ -0,0 +1,126 @@ + + + 4.0.0 + + spring-cloud-stream-binder-servlet + 1.0.0.BUILD-SNAPSHOT + jar + spring-cloud-stream-binder-servlet + Servlet binder implementation + + + org.springframework.cloud + spring-cloud-build + 1.3.5.RELEASE + + + + 3.1.1.RELEASE + 1.8 + + + + + io.projectreactor + reactor-core + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.cloud + spring-cloud-stream + + + org.springframework.cloud + spring-cloud-stream-codec + + + org.springframework.boot + spring-boot-autoconfigure + true + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.cloud + spring-cloud-stream-binder-test + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + spring + + + spring-snapshots + Spring Snapshots + http://repo.spring.io/libs-snapshot-local + + true + + + + spring-milestones + Spring Milestones + http://repo.spring.io/libs-milestone-local + + false + + + + spring-releases + Spring Releases + http://repo.spring.io/release + + false + + + + + + spring-snapshots + Spring Snapshots + http://repo.spring.io/libs-snapshot-local + + true + + + + spring-milestones + Spring Milestones + http://repo.spring.io/libs-milestone-local + + false + + + + + + + + + io.projectreactor + reactor-core + ${reactor.version} + + + org.springframework.cloud + spring-cloud-dependencies + Edgware.RELEASE + pom + import + + + + diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/RestApplication.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/EnabledBindings.java similarity index 60% rename from spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/RestApplication.java rename to spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/EnabledBindings.java index f59f2afe5..7b3ee83c7 100644 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/RestApplication.java +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/EnabledBindings.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -13,20 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package org.springframework.cloud.stream.binder.servlet; -package org.springframework.cloud.function.web; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; +import java.util.Set; /** - * @author Mark Fisher + * @author Dave Syer + * */ -@SpringBootApplication -public class RestApplication { +public interface EnabledBindings { + + String getInput(String output); + + Set getOutputs(); + + Set getInputs(); - public static void main(String[] args) { - SpringApplication.run(RestApplication.class, args); - } } - diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/util/HeaderUtils.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/HeaderUtils.java similarity index 93% rename from spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/util/HeaderUtils.java rename to spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/HeaderUtils.java index 932859e33..a8a876f7f 100644 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/util/HeaderUtils.java +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/HeaderUtils.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.cloud.function.web.util; +package org.springframework.cloud.stream.binder.servlet; import java.util.Arrays; import java.util.Collection; @@ -28,9 +28,10 @@ import org.springframework.util.ObjectUtils; * @author Dave Syer * */ -public class HeaderUtils { +class HeaderUtils { - public static HttpHeaders fromMessage(MessageHeaders headers, HttpHeaders request) { + public static HttpHeaders fromMessage(Map headers, + HttpHeaders request) { HttpHeaders result = new HttpHeaders(); for (String name : headers.keySet()) { Object value = headers.get(name); diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/JsonUtils.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/JsonUtils.java new file mode 100644 index 000000000..e4b6a7635 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/JsonUtils.java @@ -0,0 +1,86 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet; + +import java.util.ArrayList; +import java.util.List; + +/** + * Internal convenience class to help with JSON message bodies. In particular translating + * between JSON arrays and lists of payloads. + * + * @author Dave Syer + * + */ +class JsonUtils { + + /** + * Split a JSON array into a list of individual objects, without parsing the objects + * themselves.. + */ + public static List split(String body) { + body = body.trim(); + // it's an array + List strings = new ArrayList<>(); + int index = 0; + int open = 0; + boolean inString = false; + StringBuilder builder = new StringBuilder(); + while (index++ < body.length() - 1) { + char current = body.charAt(index); + builder.append(current); + if (body.charAt(index - 1) != '\\') { + if (current == '"') { + if (!inString) { + open++; + inString = true; + } + else { + open--; + inString = false; + } + } + else if (current == '[') { + open++; + } + else if (current == ']') { + open--; + } + else if (current == '{') { + open++; + } + else if (current == '}') { + open--; + } + } + if (open == 0) { + if (builder.charAt(0) == '"') { + builder.delete(0, 1); + builder.delete(builder.length() - 1, builder.length()); + } + strings.add(builder.toString()); + builder.setLength(0); + while (index++ < body.length() - 1 && body.charAt(index) != ',') { + } + while (index++ < body.length() - 1 + && Character.isWhitespace(body.charAt(index))) { + } + index--; + } + } + return strings; + } +} \ No newline at end of file diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/MessageController.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/MessageController.java new file mode 100644 index 000000000..3dc18b448 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/MessageController.java @@ -0,0 +1,457 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.reactivestreams.Processor; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.integration.core.MessagingTemplate; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.ObjectUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.UnicastProcessor; + +/** + * @author Dave Syer + * + */ +@RestController +@RequestMapping("/${spring.cloud.stream.binder.servlet.prefix:stream}") +public class MessageController implements RouteRegistrar { + + public static final String ROUTE_KEY = "stream_routekey"; + + private final ConcurrentMap>> queues = new ConcurrentHashMap<>(); + + private final ConcurrentMap> emitters = new ConcurrentHashMap<>(); + + private final Map inputs = new HashMap<>(); + + private final Map outputs = new HashMap<>(); + + private final EnabledBindings bindings; + + private final MessagingTemplate template = new MessagingTemplate(); + + private String prefix; + + public long timeoutSeconds = 10; + + private long receiveTimeoutMillis; + + private Set routes = new LinkedHashSet<>(); + + public MessageController(String prefix, EnabledBindings bindings) { + if (!prefix.startsWith("/")) { + prefix = "/" + prefix; + } + if (!prefix.endsWith("/")) { + prefix = prefix + "/"; + } + this.prefix = prefix; + this.bindings = bindings; + this.template.setReceiveTimeout(this.receiveTimeoutMillis); + } + + public void setReceiveTimeoutSeconds(long receiveTimeoutMillis) { + this.receiveTimeoutMillis = receiveTimeoutMillis; + this.template.setReceiveTimeout(receiveTimeoutMillis); + } + + public void setBufferTimeoutSeconds(long timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + } + + @GetMapping(path = "/**", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity sse( + @RequestAttribute("org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping") String path, + @RequestHeader HttpHeaders headers) throws IOException { + Route route = output(path); + String channel = route.getChannel(); + if (!bindings.getOutputs().contains(channel)) { + return org.springframework.http.ResponseEntity.notFound().build(); + } + Message> message = poll(route.getChannel(), route.getKey(), + true); + SseEmitter body = emit(route, message); + return ResponseEntity.ok() + .headers(HeaderUtils.fromMessage(message.getHeaders(), headers)) + .body(body); + } + + @GetMapping("/**") + public ResponseEntity supplier( + @RequestAttribute("org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping") String path, + @RequestHeader HttpHeaders headers, + @RequestParam(required = false) boolean purge) { + Route route = output(path); + String channel = route.getChannel(); + if (bindings.getOutputs().contains(channel)) { + Message> polled = poll(channel, route.getKey(), !purge); + if (routes.contains(route.getKey()) || !polled.getPayload().isEmpty() + || route.getKey() == null) { + return convert(polled, headers); + } + } + route = input(path); + channel = route.getChannel(); + if (!bindings.getInputs().contains(channel)) { + return ResponseEntity.notFound().build(); + } + String body = route.getKey(); + body = body.contains("/") ? body.substring(body.lastIndexOf("/") + 1) : body; + path = path.replaceAll("/" + body, ""); + return string(path, body, headers); + } + + @PostMapping(path = "/**", consumes = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity string( + @RequestAttribute("org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping") String path, + @RequestBody String body, @RequestHeader HttpHeaders headers) { + return function(path, body, headers); + } + + @PostMapping(path = "/**", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity json( + @RequestAttribute("org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping") String path, + @RequestBody String body, @RequestHeader HttpHeaders headers) { + return function(path, extract(body), headers); + } + + private Object extract(String body) { + body = body.trim(); + Object result = body; + if (body.startsWith("[")) { + result = JsonUtils.split(body); + } + return result; + } + + @PostMapping("/**") + public ResponseEntity function( + @RequestAttribute("org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping") String path, + @RequestBody Object body, @RequestHeader HttpHeaders headers) { + Route route = input(path); + String channel = route.getChannel(); + if (!inputs.containsKey(channel)) { + return ResponseEntity.notFound().build(); + } + Collection collection; + boolean single = false; + if (body instanceof String) { + body = extract((String) body); + } + if (body instanceof Collection) { + @SuppressWarnings("unchecked") + Collection list = (Collection) body; + collection = list; + } + else { + if (ObjectUtils.isArray(body)) { + collection = Arrays.asList(ObjectUtils.toObjectArray(body)); + } + else { + single = true; + collection = Arrays.asList(body); + } + } + Map messageHeaders = new HashMap<>(HeaderUtils.fromHttp(headers)); + if (route.getKey() != null) { + messageHeaders.put(ROUTE_KEY, route.getKey()); + } + MessageChannel input = inputs.get(channel); + Map outputHeaders = null; + List results = new ArrayList<>(); + HttpStatus status = HttpStatus.ACCEPTED; + // This is a total guess. We have no way to guarantee that the user will + // implement a Processor so that inputs always get an output, so either + // nothing might come back or there might be multiple outputs and we only get + // one of them. + if (this.outputs.containsKey(channel)) { + for (Object payload : collection) { + Message result = template.sendAndReceive(input, MessageBuilder + .withPayload(payload).copyHeadersIfAbsent(messageHeaders) + .setHeader(MessageHeaders.REPLY_CHANNEL, outputs.get(channel)) + .build()); + if (result != null) { + if (outputHeaders == null) { + outputHeaders = new LinkedHashMap<>(result.getHeaders()); + } + results.add(result.getPayload()); + } + } + status = HttpStatus.OK; + if (results.isEmpty()) { + // If nothing came back, just assume it was intentional, and say that + // we accepted the inputs. + status = HttpStatus.ACCEPTED; + results.addAll(collection); + } + } + else { + for (Object payload : collection) { + template.send(input, MessageBuilder.withPayload(payload) + .copyHeadersIfAbsent(messageHeaders).build()); + } + outputHeaders = messageHeaders; + results.addAll(collection); + } + if (outputHeaders == null) { + outputHeaders = new LinkedHashMap<>(); + } + outputHeaders.put(ROUTE_KEY, route.getKey()); + if (single && results.size() == 1) { + body = results.get(0); + } + else { + body = results; + } + if (headers.getContentType() != null + && headers.getContentType().includes(MediaType.APPLICATION_JSON) + && body.toString().contains("\"")) { + body = body.toString(); + } + return convert(status, MessageBuilder.withPayload(body) + .copyHeadersIfAbsent(outputHeaders).build(), headers); + } + + private ResponseEntity convert(Message message, HttpHeaders request) { + return convert(HttpStatus.OK, message, request); + } + + private ResponseEntity convert(HttpStatus status, Message message, + HttpHeaders request) { + return ResponseEntity.status(status) + .headers(HeaderUtils.fromMessage(message.getHeaders(), request)) + .body(message.getPayload()); + } + + private SseEmitter emit(Route route, Message> message) + throws IOException { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + String path = route.getPath(); + if (!emitters.containsKey(path)) { + emitters.putIfAbsent(path, new HashSet<>()); + } + emitters.get(path).add(emitter); + emitter.onCompletion(() -> emitters.get(path).remove(emitter)); + emitter.onTimeout(() -> emitters.get(path).remove(emitter)); + for (Object body : message.getPayload()) { + emitter.send(body); + } + return emitter; + } + + public void reset() { + queues.clear(); + } + + private Message> poll(String channel, String key, + boolean requeue) { + List list = new ArrayList<>(); + List> messages = new ArrayList<>(); + Bridge> queue = queues.get(new Route(key, channel).getPath()); + if (queue != null) { + queue.receive().subscribe(message -> { + messages.add(message); + list.add(message.getPayload()); + }); + if (!requeue) { + queue.reset(); + } + } + MessageBuilder> builder = MessageBuilder.withPayload(list); + if (!messages.isEmpty()) { + builder.copyHeadersIfAbsent(messages.get(0).getHeaders()); + } + return builder.build(); + } + + public void subscribe(String name, SubscribableChannel outboundBindTarget) { + this.outputs.put(bindings.getInput(name), name); + outboundBindTarget.subscribe(message -> this.append(name, message)); + } + + private void append(String name, Message message) { + String key = (String) message.getHeaders().get(ROUTE_KEY); + if (message.getHeaders().getReplyChannel() instanceof MessageChannel) { + MessageChannel replyChannel = (MessageChannel) message.getHeaders() + .getReplyChannel(); + replyChannel.send(message); + return; + } + Route route = new Route(key, name); + String path = route.getPath(); + if (!queues.containsKey(path)) { + Bridge> flux = new Bridge<>(); + queues.putIfAbsent(path, flux); + } + queues.get(path).send(message); + if (emitters.containsKey(path)) { + Set list = new HashSet<>(emitters.get(path)); + for (SseEmitter emitter : list) { + try { + emitter.send(message.getPayload()); + } + catch (IOException e) { + emitters.get(path).remove(emitter); + } + } + } + } + + public void bind(String name, String group, MessageChannel inputTarget) { + this.inputs.put(name, inputTarget); + } + + public Route output(String path) { + return new Route(prefix, path, + bindings.getOutputs().size() == 1 + ? bindings.getOutputs().iterator().next() + : "output"); + } + + public Route input(String path) { + return new Route(prefix, path, + bindings.getInputs().size() == 1 ? bindings.getInputs().iterator().next() + : "input"); + } + + private class Route { + private String key; + private String channel; + private String path; + + private Route(String prefix, String path, String defaultChannel) { + String channel; + String route = null; + // Strip the prefix first + if (path.length() > prefix.length()) { + path = path.substring(prefix.length()); + } + else { + path = ""; + } + // Then extract the first segment of the path, and call it a "channel" + String[] paths = path.split("/"); + if (paths.length > 1) { + channel = paths[0]; + route = path.substring(channel.length() + 1, path.length()); + } + else { + channel = path; + } + // If it's not actually a channel we know about, use the default, and call the + // whole path a "route" + if (!bindings.getInputs().contains(channel) + & !bindings.getOutputs().contains(channel)) { + channel = defaultChannel; + route = path.length() > 0 ? path : null; + } + this.channel = channel; + this.key = route; + this.path = key != null ? key + "/" + channel : channel; + } + + public Route(String key, String channel) { + this.key = key; + this.channel = channel; + this.path = key != null ? key + "/" + channel : channel; + } + + public String getPath() { + return path; + } + + public String getKey() { + return key; + } + + public String getChannel() { + return channel; + } + } + + private class Bridge { + + private Processor emitter; + private Flux sink; + + public Bridge() { + reset(); + } + + public void reset() { + this.emitter = UnicastProcessor.create().serialize(); + this.sink = Flux.from(emitter).replay().autoConnect() + .take(Duration.ofSeconds(timeoutSeconds)); + } + + public void send(T item) { + emitter.onNext(item); + } + + public Flux receive() { + return sink; + } + } + + @Override + public void registerRoutes(Set routes) { + this.routes.addAll(routes); + } + + @Override + public void unregisterRoutes(Set routes) { + this.routes.removeAll(routes); + for (String path : routes) { + queues.remove(output(prefix + path).getPath()); + } + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/RouteRegistrar.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/RouteRegistrar.java new file mode 100644 index 000000000..6173329da --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/RouteRegistrar.java @@ -0,0 +1,30 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet; + +import java.util.Set; + +/** + * @author Dave Syer + * + */ +public interface RouteRegistrar { + + void registerRoutes(Set routes); + + void unregisterRoutes(Set routes); + +} diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/RouteRegistry.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/RouteRegistry.java new file mode 100644 index 000000000..dff8130cb --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/RouteRegistry.java @@ -0,0 +1,27 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet; + +import java.util.Set; + +/** + * @author Dave Syer + * + */ +public interface RouteRegistry { + + Set routes(); +} diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/ServletMessageChannelBinder.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/ServletMessageChannelBinder.java new file mode 100644 index 000000000..6413f2686 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/ServletMessageChannelBinder.java @@ -0,0 +1,55 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.binder.servlet; + +import org.springframework.cloud.stream.binder.AbstractBinder; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.DefaultBinding; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; + +/** + * A {@link org.springframework.cloud.stream.binder.Binder} implementation backed by HTTP. + * + * @author Dave Syer + */ +public class ServletMessageChannelBinder + extends AbstractBinder { + + private MessageController controller; + + public ServletMessageChannelBinder(MessageController controller) { + this.controller = controller; + } + + @Override + protected Binding doBindConsumer(String name, String group, + MessageChannel inputTarget, ConsumerProperties properties) { + controller.bind(name, group, inputTarget); + return new DefaultBinding(name, group, inputTarget, null); + } + + @Override + protected Binding doBindProducer(String name, + MessageChannel outboundBindTarget, ProducerProperties properties) { + controller.subscribe(name, (SubscribableChannel) outboundBindTarget); + return new DefaultBinding(name, null, outboundBindTarget, null); + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/BeanFactoryEnabledBindings.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/BeanFactoryEnabledBindings.java new file mode 100644 index 000000000..f76c13d19 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/BeanFactoryEnabledBindings.java @@ -0,0 +1,120 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.config; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.binder.servlet.EnabledBindings; +import org.springframework.cloud.stream.binding.BindingBeanDefinitionRegistryUtils; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ReflectionUtils; + +/** + * @author Dave Syer + * + */ +public class BeanFactoryEnabledBindings implements EnabledBindings { + + private final ConfigurableListableBeanFactory beanFactory; + private final AtomicBoolean initialized = new AtomicBoolean(false); + private final Map outputsToInputs = new HashMap<>(); + private final Set outputs = new HashSet<>(); + private final Set inputs = new HashSet<>(); + private final BindingServiceProperties binding; + + public BeanFactoryEnabledBindings(ConfigurableListableBeanFactory beanFactory, + BindingServiceProperties binding) { + this.beanFactory = beanFactory; + this.binding = binding; + } + + @Override + public Set getInputs() { + init(); + return this.inputs; + } + + @Override + public Set getOutputs() { + init(); + return this.outputs; + } + + @Override + public String getInput(String output) { + init(); + return outputsToInputs.get(output); + } + + private void init() { + if (initialized.compareAndSet(false, true)) { + String[] names = beanFactory.getBeanNamesForAnnotation(EnableBinding.class); + for (String bean : names) { + Class type = beanFactory.getType(bean); + MultiValueMap attrs = AnnotatedElementUtils + .getAllAnnotationAttributes(type, EnableBinding.class.getName()); + List list = attrs.get("value"); + if (list != null) { + for (Object object : list) { + Class[] bindings = (Class[]) object; + for (Class binding : bindings) { + List inputs = new ArrayList<>(); + List outputs = new ArrayList<>(); + ReflectionUtils.doWithMethods(binding, method -> { + Input input = AnnotationUtils.findAnnotation(method, + Input.class); + Output output = AnnotationUtils.findAnnotation(method, + Output.class); + if (input != null) { + String name = BindingBeanDefinitionRegistryUtils + .getBindingTargetName(input, method); + inputs.add(BeanFactoryEnabledBindings.this.binding + .getBindingDestination(name)); + } + if (output != null) { + String name = BindingBeanDefinitionRegistryUtils + .getBindingTargetName(output, method); + outputs.add(BeanFactoryEnabledBindings.this.binding + .getBindingDestination(name)); + } + }); + BeanFactoryEnabledBindings.this.outputs.addAll(outputs); + BeanFactoryEnabledBindings.this.inputs.addAll(inputs); + if (inputs.size() == 1 && outputs.size() == 1) { + BeanFactoryEnabledBindings.this.outputsToInputs + .put(outputs.get(0), inputs.get(0)); + } + } + } + } + } + } + } + +} \ No newline at end of file diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/MessageHandlingAutoConfiguration.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/MessageHandlingAutoConfiguration.java new file mode 100644 index 000000000..778fc589a --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/MessageHandlingAutoConfiguration.java @@ -0,0 +1,105 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.binder.servlet.config; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.stream.binder.servlet.EnabledBindings; +import org.springframework.cloud.stream.binder.servlet.MessageController; +import org.springframework.cloud.stream.binder.servlet.RouteRegistry; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Dave Syer + */ +@Configuration +@AutoConfigureBefore({ WebMvcAutoConfiguration.class }) +@ConfigurationProperties("spring.cloud.stream.binder.servlet") +@ConditionalOnProperty(name = "spring.cloud.stream.enabled", havingValue = "true", matchIfMissing = true) +public class MessageHandlingAutoConfiguration { + + /** + * The prefix for the HTTP endpoint. + */ + private String prefix = "stream"; + + /** + * The buffer timeout for messages sent to output channels, and accessed via GET. + */ + private long bufferTimeoutSeconds = 10; + + /** + * The receive timeout for messages in a send-and-receive from a linked output via + * POST. + */ + private long receiveTimeoutMillis = 100; + + @Autowired(required = false) + private List registries; + + public String getPrefix() { + return prefix; + } + + public void setPrefix(String prefix) { + this.prefix = prefix; + } + + public long getBufferTimeoutSeconds() { + return bufferTimeoutSeconds; + } + + public void setBufferTimeoutSeconds(long bufferTimeoutSeconds) { + this.bufferTimeoutSeconds = bufferTimeoutSeconds; + } + + public long getReceiveTimeoutMillis() { + return receiveTimeoutMillis; + } + + public void setReceiveTimeoutMillis(long receiveTimeoutMillis) { + this.receiveTimeoutMillis = receiveTimeoutMillis; + } + + @Bean + public MessageController messageController(EnabledBindings bindings) { + MessageController controller = new MessageController(prefix, bindings); + controller.setBufferTimeoutSeconds(bufferTimeoutSeconds); + controller.setReceiveTimeoutSeconds(receiveTimeoutMillis); + if (registries != null) { + for (RouteRegistry registry : registries) { + controller.registerRoutes(registry.routes()); + } + } + return controller; + } + + @Bean + public BeanFactoryEnabledBindings enabledBindings( + ConfigurableListableBeanFactory beanFactory, + BindingServiceProperties binding) { + return new BeanFactoryEnabledBindings(beanFactory, binding); + } +} diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/ServletServiceAutoConfiguration.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/ServletServiceAutoConfiguration.java new file mode 100644 index 000000000..b8b1a0400 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/config/ServletServiceAutoConfiguration.java @@ -0,0 +1,53 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.binder.servlet.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.servlet.MessageController; +import org.springframework.cloud.stream.binder.servlet.ServletMessageChannelBinder; +import org.springframework.cloud.stream.config.codec.kryo.KryoCodecAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.integration.codec.Codec; + +/** + * @author Dave Syer + */ +@Configuration +@ConditionalOnMissingBean(Binder.class) +@AutoConfigureBefore({ WebMvcAutoConfiguration.class }) +@Import({ PropertyPlaceholderAutoConfiguration.class, KryoCodecAutoConfiguration.class }) +public class ServletServiceAutoConfiguration { + @Autowired + private Codec codec; + + @Bean + public ServletMessageChannelBinder servletMessageChannelBinder( + MessageController controller) { + + ServletMessageChannelBinder messageChannelBinder = new ServletMessageChannelBinder( + controller); + messageChannelBinder.setCodec(this.codec); + return messageChannelBinder; + } +} diff --git a/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/package-info.java b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/package-info.java new file mode 100644 index 000000000..2247c5046 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/java/org/springframework/cloud/stream/binder/servlet/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains an implementation of the {@link org.springframework.cloud.stream.binder.Binder} for Redis. + */ + +package org.springframework.cloud.stream.binder.servlet; diff --git a/spring-cloud-stream-binder-servlet/src/main/resources/META-INF/spring.binders b/spring-cloud-stream-binder-servlet/src/main/resources/META-INF/spring.binders new file mode 100644 index 000000000..c0497270f --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/resources/META-INF/spring.binders @@ -0,0 +1,2 @@ +servlet:\ +org.springframework.cloud.stream.binder.servlet.config.ServletServiceAutoConfiguration diff --git a/spring-cloud-stream-binder-servlet/src/main/resources/META-INF/spring.factories b/spring-cloud-stream-binder-servlet/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..2d091c065 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.cloud.stream.binder.servlet.config.MessageHandlingAutoConfiguration \ No newline at end of file diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/JsonUtilsTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/JsonUtilsTests.java new file mode 100644 index 000000000..60e39e951 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/JsonUtilsTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet; + +import java.util.Arrays; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Dave Syer + * + */ +public class JsonUtilsTests { + + private ObjectMapper mapper = new ObjectMapper(); + + @Test + public void empty() { + assertThat(JsonUtils.split("[]")).isEmpty(); + } + + @Test + public void strings() { + assertThat(JsonUtils.split("[\"foo\", \"bar\"]")).hasSize(2).contains("foo", + "bar"); + } + + @Test + public void objects() throws Exception { + assertThat(JsonUtils.split( + mapper.writeValueAsString(Arrays.asList(new Foo("foo"), new Foo("bar"))))) + .hasSize(2) + .contains("{\"value\":\"foo\"}", "{\"value\":\"bar\"}"); + } + + @Test + public void arrays() throws Exception { + assertThat(JsonUtils.split(mapper.writeValueAsString( + Arrays.asList(Arrays.asList(new Foo("foo"), new Foo("bar")))))).hasSize(1) + .contains("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"); + } + + protected static class Foo { + private String value; + + public Foo() { + } + + public Foo(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + } +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/DoubleSinkMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/DoubleSinkMessageChannelBinderTests.java new file mode 100644 index 000000000..ddc40c1d6 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/DoubleSinkMessageChannelBinderTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class DoubleSinkMessageChannelBinderTests implements MessageHandler { + + @Autowired + private Sink sink; + + @Autowired + private Custom custom; + + @Autowired + private MockMvc mockMvc; + + private Message message; + + @Test + public void string() throws Exception { + sink.input().subscribe(this); + mockMvc.perform( + post("/stream/input").contentType(MediaType.TEXT_PLAIN).content("hello")) + .andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @Test + public void custom() throws Exception { + custom.input().subscribe(this); + mockMvc.perform( + post("/stream/custom").contentType(MediaType.TEXT_PLAIN).content("hello")) + .andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + assertThat(this.message).isNotNull(); + custom.input().unsubscribe(this); + } + + @SpringBootApplication + @EnableBinding({ Sink.class, Custom.class }) + protected static class TestConfiguration { + } + + @Override + public void handleMessage(Message message) throws MessagingException { + this.message = message; + } + + interface Custom { + @Input("custom") + SubscribableChannel input(); + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/HeaderProcessorMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/HeaderProcessorMessageChannelBinderTests.java new file mode 100644 index 000000000..f9b3f6b1b --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/HeaderProcessorMessageChannelBinderTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class HeaderProcessorMessageChannelBinderTests { + + @Autowired + private MockMvc mockMvc; + + @Test + public void function() throws Exception { + mockMvc.perform(post("/stream/input").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isOk()) + .andExpect(header().string("x-foo", "bar")) + .andExpect(content().string(containsString("HELLO"))); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Message uppercase(Message input) { + return MessageBuilder.withPayload(input.getPayload().toUpperCase()) + .copyHeadersIfAbsent(input.getHeaders()).setHeader("x-foo", "bar") + .build(); + } + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/HeadersDroppedRoutedProcessorMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/HeadersDroppedRoutedProcessorMessageChannelBinderTests.java new file mode 100644 index 000000000..57ccccb27 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/HeadersDroppedRoutedProcessorMessageChannelBinderTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binder.servlet.MessageController; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.http.MediaType; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class HeadersDroppedRoutedProcessorMessageChannelBinderTests { + + @Autowired + private MockMvc mockMvc; + + @Test + public void function() throws Exception { + mockMvc.perform(post("/stream/input/words") + .contentType(MediaType.APPLICATION_JSON).content("\"hello\"")) + .andExpect(status().isOk()) + .andExpect(header().string(MessageController.ROUTE_KEY, "words")) + .andExpect(content().string(containsString("HELLO"))); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String uppercase(String input) { + return input.toUpperCase(); + } + } +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedProcessorMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedProcessorMessageChannelBinderTests.java new file mode 100644 index 000000000..f710907ee --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedProcessorMessageChannelBinderTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binder.servlet.MessageController; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.http.MediaType; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest("spring.cloud.stream.bindings.input.destination:words") +@AutoConfigureMockMvc +@DirtiesContext +public class NamedProcessorMessageChannelBinderTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private MessageController controller; + + @Autowired + private Processor processor; + + @Before + public void init() { + controller.reset(); + } + + @Test + public void function() throws Exception { + mockMvc.perform(post("/stream/words").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void implicit() throws Exception { + mockMvc.perform(post("/stream").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void empty() throws Exception { + mockMvc.perform(get("/stream/output").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("[]"))); + } + + @Test + public void output() throws Exception { + processor.input().send(MessageBuilder.withPayload("hello").build()); + mockMvc.perform(get("/stream/output").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void outputWithRoute() throws Exception { + processor.input().send(MessageBuilder.withPayload("hello") + .setHeader(MessageController.ROUTE_KEY, "uppercase").build()); + mockMvc.perform( + get("/stream/output/uppercase").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String uppercase(String input) { + return input.toUpperCase(); + } + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedSinkMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedSinkMessageChannelBinderTests.java new file mode 100644 index 000000000..b2e698130 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedSinkMessageChannelBinderTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class NamedSinkMessageChannelBinderTests implements MessageHandler { + + @Autowired + private Sink sink; + + @Autowired + private MockMvc mockMvc; + + private Message message; + + @Test + public void consumer() throws Exception { + sink.input().subscribe(this); + mockMvc.perform(post("/stream/input").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @SpringBootApplication + @EnableBinding(Sink.class) + protected static class TestConfiguration { + } + + @Override + public void handleMessage(Message message) throws MessagingException { + this.message = message; + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedSourceMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedSourceMessageChannelBinderTests.java new file mode 100644 index 000000000..fd77b1201 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/NamedSourceMessageChannelBinderTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest("spring.cloud.stream.bindings.output.destination:words") +@AutoConfigureMockMvc +@DirtiesContext +public class NamedSourceMessageChannelBinderTests { + + @Autowired + private Source source; + + @Autowired + private MockMvc mockMvc; + + @Test + public void supplier() throws Exception { + source.output().send(MessageBuilder.withPayload("hello").build()); + mockMvc.perform(get("/stream/words")).andExpect(status().isOk()) + .andExpect(content().string(containsString("hello"))); + } + + @SpringBootApplication + @EnableBinding(Source.class) + protected static class TestConfiguration { + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PojoProcessorMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PojoProcessorMessageChannelBinderTests.java new file mode 100644 index 000000000..54593ff83 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PojoProcessorMessageChannelBinderTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.http.MediaType; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest(properties = "logging.level.org.springframework.web=DEBUG") +@AutoConfigureMockMvc +@DirtiesContext +public class PojoProcessorMessageChannelBinderTests { + + @Autowired + private MockMvc mockMvc; + + @Test + public void json() throws Exception { + mockMvc.perform(post("/stream").contentType(MediaType.APPLICATION_JSON) + .content("{\"value\":\"hello\"}")).andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void text() throws Exception { + mockMvc.perform(post("/stream").contentType(MediaType.TEXT_PLAIN) + .content("[{\"value\":\"hello\"}]")).andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void single() throws Exception { + mockMvc.perform(post("/stream").contentType(MediaType.TEXT_PLAIN) + .content("{\"value\":\"hello\"}")).andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Foo uppercase(Foo input) { + return input.toUpperCase(); + } + } + + protected static class Foo { + private String value; + + public Foo() { + } + + public Foo(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public Foo toUpperCase() { + return new Foo(value.toUpperCase()); + } + + } +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PojoSinkMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PojoSinkMessageChannelBinderTests.java new file mode 100644 index 000000000..5d8537b56 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PojoSinkMessageChannelBinderTests.java @@ -0,0 +1,113 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class PojoSinkMessageChannelBinderTests implements MessageHandler { + + @Autowired + private Sink sink; + + @Autowired + private MockMvc mockMvc; + + private Message message; + + @Test + public void consumer() throws Exception { + sink.input().subscribe(this); + mockMvc.perform(post("/stream/input").contentType(MediaType.APPLICATION_JSON) + .content("{\"value\":\"hello\"}")).andExpect(status().isAccepted()) + .andExpect(content().string("{\"value\":\"hello\"}")); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @Test + public void multi() throws Exception { + sink.input().subscribe(this); + mockMvc.perform(post("/stream/input").contentType(MediaType.APPLICATION_JSON) + .content("[{\"value\":\"hello\"},{\"value\":\"world\"}]")) + .andExpect(status().isAccepted()).andExpect(content() + .string("[{\"value\":\"hello\"}, {\"value\":\"world\"}]")); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @SpringBootApplication + @EnableBinding(Sink.class) + protected static class TestConfiguration { + } + + @Override + public void handleMessage(Message message) throws MessagingException { + this.message = message; + } + + protected static class Foo { + private String value; + + public Foo() { + } + + public Foo(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public Foo toUpperCase() { + return new Foo(value.toUpperCase()); + } + + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PrefixMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PrefixMessageChannelBinderTests.java new file mode 100644 index 000000000..12e1474c6 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/PrefixMessageChannelBinderTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest("spring.cloud.stream.binder.servlet.prefix:awesome") +@AutoConfigureMockMvc +@DirtiesContext +public class PrefixMessageChannelBinderTests implements MessageHandler { + + @Autowired + private Sink sink; + + @Autowired + private MockMvc mockMvc; + + private Message message; + + @Test + public void consumer() throws Exception { + sink.input().subscribe(this); + mockMvc.perform(post("/awesome/input").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @SpringBootApplication + @EnableBinding(Sink.class) + protected static class TestConfiguration { + } + + @Override + public void handleMessage(Message message) throws MessagingException { + this.message = message; + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/ProcessorMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/ProcessorMessageChannelBinderTests.java new file mode 100644 index 000000000..5be7f0308 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/ProcessorMessageChannelBinderTests.java @@ -0,0 +1,138 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.http.MediaType; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class ProcessorMessageChannelBinderTests { + + @Autowired + private Processor processor; + + @Autowired + private MockMvc mockMvc; + + @Before + public void init() throws Exception { + mockMvc.perform(get("/stream/output?purge=true")).andReturn(); + } + + @Test + public void supplier() throws Exception { + processor.output().send(MessageBuilder.withPayload("hello").build()); + mockMvc.perform(get("/stream/output")).andExpect(status().isOk()) + .andExpect(content().string(containsString("hello"))); + } + + @Test + public void missing() throws Exception { + // Missing route is not found if channel is explicit + mockMvc.perform(get("/stream/output/missing")).andExpect(status().isNotFound()) + .andExpect(content().string(equalTo(""))); + } + + @Test + public void empty() throws Exception { + // Missing route where channel can be inferred (there is an input channel) is + // going to be passed on as a body + mockMvc.perform(get("/stream/missing")).andExpect(status().isOk()) + .andExpect(content().string(equalTo("MISSING"))); + } + + @Test + public void function() throws Exception { + mockMvc.perform(post("/stream/input").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void keyed() throws Exception { + mockMvc.perform(get("/stream/hello")).andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void implicit() throws Exception { + mockMvc.perform(post("/stream").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void string() throws Exception { + mockMvc.perform( + post("/stream/input").contentType(MediaType.TEXT_PLAIN).content("hello")) + .andExpect(status().isOk()).andExpect(content().string(equalTo("HELLO"))); + } + + @Test + public void multi() throws Exception { + mockMvc.perform(post("/stream/input").contentType(MediaType.APPLICATION_JSON) + .content("[\"hello\",\"world\"]")).andExpect(status().isOk()) + .andExpect(content().string("[\"HELLO\",\"WORLD\"]")); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String uppercase(String input) { + return input.toUpperCase(); + } + + public static void main(String[] args) throws Exception { + SpringApplication.run( + ProcessorMessageChannelBinderTests.TestConfiguration.class, + "--logging.level.root=INFO"); + } + + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedProcessorMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedProcessorMessageChannelBinderTests.java new file mode 100644 index 000000000..2b3b03696 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedProcessorMessageChannelBinderTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binder.servlet.MessageController; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class RoutedProcessorMessageChannelBinderTests { + + @Autowired + private MockMvc mockMvc; + + @Test + public void function() throws Exception { + mockMvc.perform(post("/stream/input/words") + .contentType(MediaType.APPLICATION_JSON).content("\"hello\"")) + .andExpect(status().isOk()) + .andExpect(header().string(MessageController.ROUTE_KEY, "words")) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void keyed() throws Exception { + mockMvc.perform(get("/stream/words/hello")).andExpect(status().isOk()) + .andExpect(header().string(MessageController.ROUTE_KEY, "words")) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void channelAndKeyed() throws Exception { + mockMvc.perform(get("/stream/input/words/hello")).andExpect(status().isOk()) + .andExpect(header().string(MessageController.ROUTE_KEY, "words")) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void implicit() throws Exception { + mockMvc.perform(post("/stream/words").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isOk()) + .andExpect(header().string(MessageController.ROUTE_KEY, "words")) + .andExpect(content().string(containsString("HELLO"))); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Message uppercase(Message input) { + return MessageBuilder.withPayload(input.getPayload().toUpperCase()) + .copyHeadersIfAbsent(input.getHeaders()).build(); + } + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedSinkMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedSinkMessageChannelBinderTests.java new file mode 100644 index 000000000..be3a8d541 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedSinkMessageChannelBinderTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.servlet.MessageController; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class RoutedSinkMessageChannelBinderTests implements MessageHandler { + + @Autowired + private Sink sink; + + @Autowired + private MockMvc mockMvc; + + private Message message; + + @Test + public void consumer() throws Exception { + sink.input().subscribe(this); + mockMvc.perform(post("/stream/input/words") + .contentType(MediaType.APPLICATION_JSON).content("\"hello\"")) + .andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + assertThat(this.message).isNotNull(); + assertThat(this.message.getHeaders().get(MessageController.ROUTE_KEY)) + .isEqualTo("words"); + sink.input().unsubscribe(this); + } + + @SpringBootApplication + @EnableBinding(Sink.class) + protected static class TestConfiguration { + } + + @Override + public void handleMessage(Message message) throws MessagingException { + this.message = message; + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedSourceMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedSourceMessageChannelBinderTests.java new file mode 100644 index 000000000..facbd65af --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/RoutedSourceMessageChannelBinderTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.servlet.MessageController; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class RoutedSourceMessageChannelBinderTests { + + @Autowired + private Source source; + + @Autowired + private MockMvc mockMvc; + + @Test + public void supplier() throws Exception { + source.output().send(MessageBuilder.withPayload("hello") + .setHeader(MessageController.ROUTE_KEY, "words").build()); + mockMvc.perform(get("/stream/output/words")).andExpect(status().isOk()) + .andExpect(content().string(containsString("hello"))); + } + + @Test + public void implicit() throws Exception { + source.output().send(MessageBuilder.withPayload("hello") + .setHeader(MessageController.ROUTE_KEY, "words").build()); + mockMvc.perform(get("/stream/words")).andExpect(status().isOk()) + .andExpect(content().string(containsString("hello"))); + } + + @SpringBootApplication + @EnableBinding(Source.class) + protected static class TestConfiguration { + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkAndProcessorMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkAndProcessorMessageChannelBinderTests.java new file mode 100644 index 000000000..a366ced29 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkAndProcessorMessageChannelBinderTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class SinkAndProcessorMessageChannelBinderTests { + + protected static Log log = LogFactory + .getLog(SinkAndProcessorMessageChannelBinderTests.class); + + @Autowired + private MockMvc mockMvc; + + @Test + public void function() throws Exception { + mockMvc.perform(get("/stream/output?purge=true")).andReturn(); + mockMvc.perform(post("/stream/input/words") + .contentType(MediaType.APPLICATION_JSON).content("\"hello\"")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void consumer() throws Exception { + mockMvc.perform(get("/stream/output?purge=true")).andReturn(); + mockMvc.perform(post("/stream/input/accept") + .contentType(MediaType.APPLICATION_JSON).content("\"hello\"")) + .andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @Autowired + private Processor processor; + + @StreamListener(value = Processor.INPUT, condition = "headers['stream_routekey']=='words'") + public void uppercase(Message input) { + processor.output() + .send(MessageBuilder.withPayload(input.getPayload().toUpperCase()) + .copyHeaders(input.getHeaders()).build()); + } + + @StreamListener(value = Processor.INPUT, condition = "headers['stream_routekey']=='accept'") + public void accept(String input) { + log.warn("Processed: " + input); + } + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkMessageChannelBinderTests.java new file mode 100644 index 000000000..3003bb43b --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkMessageChannelBinderTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class SinkMessageChannelBinderTests implements MessageHandler { + + @Autowired + private Sink sink; + + @Autowired + private MockMvc mockMvc; + + private Message message; + + @Test + public void consumer() throws Exception { + sink.input().subscribe(this); + mockMvc.perform(post("/stream/input").contentType(MediaType.APPLICATION_JSON) + .content("\"hello\"")).andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @Test + public void multi() throws Exception { + sink.input().subscribe(this); + mockMvc.perform(post("/stream/input").contentType(MediaType.APPLICATION_JSON) + .content("[\"hello\",\"world\"]")).andExpect(status().isAccepted()) + .andExpect(content().string(containsString("[\"hello\",\"world\"]"))); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @Test + public void string() throws Exception { + sink.input().subscribe(this); + mockMvc.perform( + post("/stream/input").contentType(MediaType.TEXT_PLAIN).content("hello")) + .andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @Test + public void missing() throws Exception { + sink.input().subscribe(this); + // It gets routed to "input" channel with key "missing" + mockMvc.perform(post("/stream/missing").contentType(MediaType.TEXT_PLAIN) + .content("hello")).andExpect(status().isAccepted()) + .andExpect(content().string(containsString("hello"))); + assertThat(this.message).isNotNull(); + sink.input().unsubscribe(this); + } + + @SpringBootApplication + @EnableBinding(Sink.class) + protected static class TestConfiguration { + } + + @Override + public void handleMessage(Message message) throws MessagingException { + this.message = message; + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkWithResponseMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkWithResponseMessageChannelBinderTests.java new file mode 100644 index 000000000..ff618193c --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SinkWithResponseMessageChannelBinderTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class SinkWithResponseMessageChannelBinderTests { + + @Autowired + private MockMvc mockMvc; + + @Test + public void function() throws Exception { + mockMvc.perform(post("/stream/input/words") + .contentType(MediaType.APPLICATION_JSON).content("\"hello\"")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @Autowired + private Processor processor; + + @StreamListener(value = Processor.INPUT, condition = "headers['stream_routekey']=='words'") + public void uppercase(Message input) { + // TODO: this won't work without the Message wrapper for the input because + // the headers don't get copied. + processor.output() + .send(MessageBuilder.withPayload(input.getPayload().toUpperCase()) + .copyHeaders(input.getHeaders()).build()); + } + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SourceAndProcessorMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SourceAndProcessorMessageChannelBinderTests.java new file mode 100644 index 000000000..06f8fa1e6 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SourceAndProcessorMessageChannelBinderTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import java.util.Collections; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binder.servlet.RouteRegistry; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class SourceAndProcessorMessageChannelBinderTests { + + protected static Log log = LogFactory + .getLog(SourceAndProcessorMessageChannelBinderTests.class); + + @Autowired + private MockMvc mockMvc; + + @Test + public void function() throws Exception { + mockMvc.perform(get("/stream/output?purge=true")).andReturn(); + mockMvc.perform(post("/stream/input/words") + .contentType(MediaType.APPLICATION_JSON).content("\"hello\"")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("HELLO"))); + } + + @Test + public void missing() throws Exception { + mockMvc.perform(get("/stream/output?purge=true")).andReturn(); + mockMvc.perform(get("/stream/output/missing")).andExpect(status().isNotFound()); + } + + @Test + public void empty() throws Exception { + // An explicit route registration prevents the implicit conversion of route into + // body + mockMvc.perform(get("/stream/output?purge=true")).andReturn(); + mockMvc.perform(get("/stream/empty")).andExpect(status().isOk()) + .andExpect(content().string(containsString("[]"))); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + protected static class TestConfiguration { + @Autowired + private Processor processor; + + @StreamListener(Processor.INPUT) + public void uppercase(Message input) { + processor.output() + .send(MessageBuilder.withPayload(input.getPayload().toUpperCase()) + .copyHeaders(input.getHeaders()).build()); + } + + @Bean + public RouteRegistry routeRegistry() { + return () -> Collections.singleton("empty"); + } + + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SourceMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SourceMessageChannelBinderTests.java new file mode 100644 index 000000000..9ce14646e --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SourceMessageChannelBinderTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext +public class SourceMessageChannelBinderTests { + + @Autowired + private Source source; + + @Autowired + private MockMvc mockMvc; + + @Test + public void supplier() throws Exception { + source.output().send(MessageBuilder.withPayload("hello").build()); + mockMvc.perform(get("/stream/output")).andExpect(status().isOk()) + .andExpect(content().string(containsString("hello"))); + } + + @Test + public void implicit() throws Exception { + source.output().send(MessageBuilder.withPayload("hello").build()); + mockMvc.perform(get("/stream")).andExpect(status().isOk()) + .andExpect(content().string(containsString("hello"))); + } + + @Test + public void trailing() throws Exception { + source.output().send(MessageBuilder.withPayload("hello").build()); + mockMvc.perform(get("/stream/")).andExpect(status().isOk()) + .andExpect(content().string(containsString("hello"))); + } + + @Test + public void empty() throws Exception { + mockMvc.perform(get("/stream/output?purge=true")).andReturn(); + mockMvc.perform(get("/stream/output")).andExpect(status().isOk()) + .andExpect(content().string(containsString("[]"))); + } + + @Test + public void missing() throws Exception { + // Missing route is just empty + mockMvc.perform(get("/stream/missing")).andExpect(status().isNotFound()) + .andExpect(content().string(equalTo(""))); + } + + @SpringBootApplication + @EnableBinding(Source.class) + protected static class TestConfiguration { + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SseSourceMessageChannelBinderTests.java b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SseSourceMessageChannelBinderTests.java new file mode 100644 index 000000000..583a6eeb7 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/java/org/springframework/cloud/stream/binder/servlet/test/SseSourceMessageChannelBinderTests.java @@ -0,0 +1,195 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.stream.binder.servlet.test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.embedded.LocalServerPort; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@DirtiesContext +public class SseSourceMessageChannelBinderTests { + + private static Log log = LogFactory.getLog(SseSourceMessageChannelBinderTests.class); + + @Autowired + private Source source; + + private CountDownLatch latch = new CountDownLatch(1); + + private RestTemplate rest = new RestTemplate(); + + @LocalServerPort + private int port; + + private String message = null; + + @Before + public void init() throws Exception { + rest.getForEntity( + new URI("http://localhost:" + port + "/stream/output?purge=true"), + String.class); + } + + @Test + public void supplier() throws Exception { + source.output().send(MessageBuilder.withPayload("hello").build()); + rest.getInterceptors().add(new NonClosingInterceptor()); + ResponseEntity response = rest.execute( + new URI("http://localhost:" + port + "/stream/output"), HttpMethod.GET, + request -> request.getHeaders() + .setAccept(Arrays.asList(MediaType.TEXT_EVENT_STREAM)), + this::extract); + assertThat(response.getHeaders().getContentType()) + .isGreaterThan(MediaType.TEXT_EVENT_STREAM); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("data:hello\n\n"); + } + + @Test + public void lateSending() throws Exception { + message = "world"; + rest.getInterceptors().add(new NonClosingInterceptor()); + ResponseEntity response = rest.execute( + new URI("http://localhost:" + port + "/stream/output"), HttpMethod.GET, + request -> request.getHeaders() + .setAccept(Arrays.asList(MediaType.TEXT_EVENT_STREAM)), + this::extract); + assertThat(response.getHeaders().getContentType()) + .isGreaterThan(MediaType.TEXT_EVENT_STREAM); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("data:world\n\n"); + } + + @SpringBootApplication + @EnableBinding(Source.class) + protected static class TestConfiguration { + } + + private ResponseEntity extract(ClientHttpResponse response) + throws IOException { + if (message != null) { + // Once there is an incoming request we can send a message to it + source.output().send(MessageBuilder.withPayload(message).build()); + } + byte[] bytes = new byte[1024]; + StringBuilder builder = new StringBuilder(); + int read = 0; + while (read >= 0 + && StringUtils.countOccurrencesOf(builder.toString(), "\n") < 2) { + read = response.getBody().read(bytes, 0, bytes.length); + if (read > 0) { + latch.countDown(); + builder.append(new String(bytes, 0, read)); + } + log.debug("Building: " + builder); + } + log.debug("Done: " + builder); + return ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()).body(builder.toString()); + } + + /** + * Special interceptor that prevents the response from being closed and allows us to + * assert on the contents of an event stream. + */ + private class NonClosingInterceptor implements ClientHttpRequestInterceptor { + + private class NonClosingResponse implements ClientHttpResponse { + + private ClientHttpResponse delegate; + + public NonClosingResponse(ClientHttpResponse delegate) { + this.delegate = delegate; + } + + @Override + public InputStream getBody() throws IOException { + return delegate.getBody(); + } + + @Override + public HttpHeaders getHeaders() { + return delegate.getHeaders(); + } + + @Override + public HttpStatus getStatusCode() throws IOException { + return delegate.getStatusCode(); + } + + @Override + public int getRawStatusCode() throws IOException { + return delegate.getRawStatusCode(); + } + + @Override + public String getStatusText() throws IOException { + return delegate.getStatusText(); + } + + @Override + public void close() { + } + + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + return new NonClosingResponse(execution.execute(request, body)); + } + + } + +} diff --git a/spring-cloud-stream-binder-servlet/src/test/resources/application.properties b/spring-cloud-stream-binder-servlet/src/test/resources/application.properties new file mode 100644 index 000000000..2ceff0035 --- /dev/null +++ b/spring-cloud-stream-binder-servlet/src/test/resources/application.properties @@ -0,0 +1,2 @@ +spring.main.banner-mode=off +#logging.level.org.springframework.cloud.stream.binder.servlet=DEBUG \ No newline at end of file