From ba34d4b81b4c71eb8a6b113213ac07f7f0ffc6b8 Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Thu, 18 Oct 2018 16:06:50 +0100 Subject: [PATCH] Add @FunctionalSpringBootTest and mini web server User can run a minimal HTTP app using an app that is a Function or an ApplicationContextInitializer. Can also test using @FunctionalSpringBootTest in place of @SpringBootTest. Add some tests and documentation for functional beans Make server.address configurable --- README.adoc | 8 +- .../src/main/asciidoc/adapters/aws-intro.adoc | 4 +- .../main/asciidoc/adapters/azure-intro.adoc | 4 +- docs/src/main/asciidoc/functional.adoc | 145 +++++++++++++ .../main/asciidoc/spring-cloud-function.adoc | 4 + .../README.adoc | 4 +- .../README.adoc | 20 +- spring-cloud-function-context/pom.xml | 2 +- .../test/FunctionalSpringBootTest.java | 50 +++++ .../test/FunctionalTestContextLoader.java | 37 ++++ .../context/test/FunctionalTests.java | 58 +++++ .../function/FunctionEndpointInitializer.java | 202 ++++++++++++++++++ .../main/resources/META-INF/spring.factories | 3 + .../test/ExplicitNonFunctionalTests.java | 63 ++++++ .../cloud/function/test/FunctionalTests.java | 58 +++++ .../test/ImplicitNonFunctionalTests.java | 63 ++++++ 16 files changed, 703 insertions(+), 22 deletions(-) create mode 100644 docs/src/main/asciidoc/functional.adoc create mode 100644 spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/test/FunctionalSpringBootTest.java create mode 100644 spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/test/FunctionalTestContextLoader.java create mode 100644 spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/test/FunctionalTests.java create mode 100644 spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/function/FunctionEndpointInitializer.java create mode 100644 spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/ExplicitNonFunctionalTests.java create mode 100644 spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/FunctionalTests.java create mode 100644 spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/ImplicitNonFunctionalTests.java diff --git a/README.adoc b/README.adoc index 4bf63fd4c..db227d7fe 100644 --- a/README.adoc +++ b/README.adoc @@ -183,16 +183,10 @@ http://eclipse.org/m2e/[m2eclipse] eclipse plugin for maven support. Other IDEs should also work without issue as long as they use Maven 3.3.3 or better. ==== Importing into eclipse with m2eclipse - We recommend the http://eclipse.org/m2e/[m2eclipse] eclipse plugin when working with eclipse. If you don't already have m2eclipse installed it is available from the "eclipse marketplace". -Also, you will need to install Kotlin plug-in from the "eclipse marketplace" to ensure projects -that depend on Kotlin complile under `m2eclipse` plugin (mentioned above). - -NOTE: If you still see compile errors after installing Kotlin plugin, simply right-click on the project with error and _remove_ and then _add_ Kotlin Nature via ***Configure Kotlin*** feature. - NOTE: Older versions of m2e do not support Maven 3.3, so once the projects are imported into Eclipse you will also need to tell m2eclipse to use the right profile for the projects. If you @@ -260,4 +254,4 @@ added after the original pull request but before a merge. other target branch in the main project). * When writing a commit message please follow http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[these conventions], if you are fixing an existing issue please add `Fixes gh-XXXX` at the end of the commit - message (where XXXX is the issue number). + message (where XXXX is the issue number). \ No newline at end of file diff --git a/docs/src/main/asciidoc/adapters/aws-intro.adoc b/docs/src/main/asciidoc/adapters/aws-intro.adoc index 1424a1307..515519b7f 100644 --- a/docs/src/main/asciidoc/adapters/aws-intro.adoc +++ b/docs/src/main/asciidoc/adapters/aws-intro.adoc @@ -26,4 +26,6 @@ The input type for the function in the AWS sample is a Foo with a single propert { "value": "test" } ----- \ No newline at end of file +---- + +NOTE: The AWS sample app is written in the "functional" style (as an `ApplicationContextInitializer`). This is much faster on startup in Lambda than the traditional `@Bean` style, so if you don't need `@Beans` (or `@EnableAutoConfiguration`) it's a good choice. Warm starts are not affected. \ No newline at end of file diff --git a/docs/src/main/asciidoc/adapters/azure-intro.adoc b/docs/src/main/asciidoc/adapters/azure-intro.adoc index 66abe5ebf..511ddb4a1 100644 --- a/docs/src/main/asciidoc/adapters/azure-intro.adoc +++ b/docs/src/main/asciidoc/adapters/azure-intro.adoc @@ -42,7 +42,7 @@ You can run the sample locally, just like the other Spring Cloud Function sample and `curl -H "Content-Type: text/plain" localhost:8080/function -d '{"value": "hello foobar"}'`. -You will need the `az` CLI app and some node.js fu (see https://docs.microsoft.com/en-us/azure/azure-functions/functions-create-first-java-maven for more detail). To deploy the function on Azure runtime: +You will need the `az` CLI app (see https://docs.microsoft.com/en-us/azure/azure-functions/functions-create-first-java-maven for more detail). To deploy the function on Azure runtime: ---- $ az login @@ -58,3 +58,5 @@ The input type for the function in the Azure sample is a Foo with a single prope "value": "foobar" } ---- + +NOTE: The Azure sample app is written in the "non-functional" style (using `@Bean`). The functional style (with just `Function` or `ApplicationContextInitializer`) is is much faster on startup in Azure than the traditional `@Bean` style, so if you don't need `@Beans` (or `@EnableAutoConfiguration`) it's a good choice. Warm starts are not affected. \ No newline at end of file diff --git a/docs/src/main/asciidoc/functional.adoc b/docs/src/main/asciidoc/functional.adoc new file mode 100644 index 000000000..3e0d8f117 --- /dev/null +++ b/docs/src/main/asciidoc/functional.adoc @@ -0,0 +1,145 @@ +Spring Cloud Function supports a "functional" style of bean declarations for small apps where you need fast startup. The functional style of bean declaration was a feature of Spring Framework 5.0 with significant enhancements in 5.1. + +== Comparing Functional with Traditional Bean Definitions + +Here's a vanilla Spring Cloud Function application from with the +familiar `@Configuration` and `@Bean` declaration style: + +```java +@SpringBootApplication +public class DemoApplication { + + @Bean + public Function uppercase() { + return value -> value.toUpperCase(); + } + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } + +} +``` + +You can run the above in a serverless platform, like AWS Lambda or Azure Functions, or you can run it in its own HTTP server just by including `spring-cloud-function-starter-web` on the classpath. Running the main method would expose an endpoint that you can use to ping that `uppercase` function: + +``` +$ curl localhost:8080 -d foo +FOO +``` + +The web adapter in `spring-cloud-function-starter-web` uses Spring MVC, so you needed a Servlet container. You can also use Webflux where the default server is netty (even though you can still use Servlet containers if you want to) - just include the `spring-cloud-starter-function-webflux` dependency instead. The functionality is the same, and the user application code can be used in both. + +Now for the functional beans: the user application code can be recast into "functional" +form, like this: + +```java +@SpringBootConfiguration +public class DemoApplication implements ApplicationContextInitializer { + + public static void main(String[] args) { + FunctionalSpringApplication.run(DemoApplication.class, args); + } + + public Function uppercase() { + return value -> value.toUpperCase(); + } + + @Override + public void initialize(GenericApplicationContext context) { + context.registerBean("demo", FunctionRegistration.class, + () -> new FunctionRegistration<>(uppercase()) + .type(FunctionType.from(String.class).to(String.class))); + } + +} +``` + +The main differences are: + +* The main class is an `ApplicationContextInitializer`. + +* The `@Bean` methods have been converted to calls to `context.registerBean()` + +* The `@SpringBootApplication` has been replaced with +`@SpringBootConfiguration` to signify that we are not enabling Spring +Boot autoconfiguration, and yet still marking the class as an "entry +point". + +* The `SpringApplication` from Spring Boot has been replaced with a +`FunctionalSpringApplication` from Spring Cloud Function (it's a +subclass). + +The business logic beans that you register in a Spring Cloud Function app are of type `FunctionRegistration`. This is a wrapper that contains both the function and information about the input and output types. In the `@Bean` form of the application that information can be derived reflectively, but in a functional bean registration some of it is lost unless we use a `FunctionRegistration`. + +An alternative to using an `ApplicationContextInitializer` and `FunctionRegistration` is to make the application itself implement `Function` (or `Consumer` or `Supplier`). Example (equivalent to the above): + +```java +@SpringBootConfiguration +public class DemoApplication implements Function { + + public static void main(String[] args) { + FunctionalSpringApplication.run(DemoApplication.class, args); + } + + @Override + public String uppercase(String value) { + return value.toUpperCase(); + } + +} +``` + +It would also work to add a separate, standalone class of type `Function` and register it with the `SpringApplication` using an alternative form of the `run()` method. The main thing is that the generic type information is available at runtime through the class declaration. + +The app runs in its own HTTP server if you add `spring-cloud-starter-function-webflux` (it won't work with the MVC starter at the moment because the functional form of the embedded Servlet container hasn't been implemented). The app also runs just fine in AWS Lambda or Azure Functions, and the improvements in startup time are dramatic. + +== Testing Functional Applications + +Spring Cloud Function also has some utilities for integration testing that will be very familiar to Spring Boot users. For example, here is an integration test for the HTTP server wrapping the app above: + +```java +@RunWith(SpringRunner.class) +@FunctionalSpringBootTest +@AutoConfigureWebTestClient +public class FunctionalTests { + + @Autowired + private WebTestClient client; + + @Test + public void words() throws Exception { + client.post().uri("/").body(Mono.just("foo"), String.class).exchange() + .expectStatus().isOk().expectBody(String.class).isEqualTo("FOO"); + } + +} +``` + +This test is almost identical to the one you would write for the `@Bean` version of the same app - the only difference is the `@FunctionalSpringBootTest` annotation, instead of the regular `@SpringBootTest`. All the other pieces, like the `@Autowired` `WebTestClient`, are standard Spring Boot features. + +Or you could write a test for a non-HTTP app using just the `FunctionCatalog`. For example: + +```java +@RunWith(SpringRunner.class) +@FunctionalSpringBootTest +public class FunctionalTests { + + @Autowired + private FunctionCatalog catalog; + + @Test + public void words() throws Exception { + Function, Flux> function = catalog.lookup(Function.class, + "function"); + assertThat(function.apply(Flux.just("foo")).blockFirst()).isEqualTo("FOO"); + } + +} +``` + +(The `FunctionCatalog` always returns functions from `Flux` to `Flux`, even if the user declares them with a simpler signature.) + +== Limitations of Functional Bean Declaration + +Most Spring Cloud Function apps have a relatively small scope compared to the whole of Spring Boot, so we are able to adapt it to these functional bean definitions easily. If you step outside that limited scope, you can extend your Spring Cloud Function app by switching back to `@Bean` style configuration, or by using a hybrid approach. If you want to take advantage of Spring Boot autoconfiguration for integrations with external datastores, for example, you will need to use `@EnableAutoConfiguration`. Your functions can still be defined using the functional declarations if you want (i.e. the "hybrid" style), but in that case you will need to explicitly switch off the "full functional mode" using `spring.functional.enabled=false` so that Spring Boot can take back control. \ No newline at end of file diff --git a/docs/src/main/asciidoc/spring-cloud-function.adoc b/docs/src/main/asciidoc/spring-cloud-function.adoc index 970808eb7..03925489d 100644 --- a/docs/src/main/asciidoc/spring-cloud-function.adoc +++ b/docs/src/main/asciidoc/spring-cloud-function.adoc @@ -136,6 +136,10 @@ Spring Cloud Function provides a "deployer" library that allows you to launch a The standard entry point of the API is the Spring configuration annotation `@EnableFunctionDeployer`. If that is used in a Spring Boot application the deployer kicks in and looks for some configuration to tell it where to find the function jar. At a minimum the user has to provide a `function.location` which is a URL or resource location for the archive containing the functions. It can optionally use a `maven:` prefix to locate the artifact via a dependency lookup (see `FunctionProperties` for complete details). A Spring Boot application is bootstrapped from the jar file, using the `MANIFEST.MF` to locate a start class, so that a standard Spring Boot fat jar works well, for example. If the target jar can be launched successfully then the result is a function registered in the main application's `FunctionCatalog`. The registered function can be applied by code in the main application, even though it was created in an isolated class loader (by deault). +== Functional Bean Definitions + +include::functional.adoc[leveloffset=+1] + == Dynamic Compilation There is a sample app that uses the function compiler to create a 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 a80a94233..d0071ffab 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 @@ -30,4 +30,6 @@ The input type for the function in the AWS sample is a Foo with a single propert { "value": "test" } ----- \ No newline at end of file +---- + +NOTE: The AWS sample app is written in the "functional" style (as an `ApplicationContextInitializer`). This is much faster on startup in Lambda than the traditional `@Bean` style, so if you don't need `@Beans` (or `@EnableAutoConfiguration`) it's a good choice. Warm starts are not affected. \ No newline at end of file diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-azure/README.adoc b/spring-cloud-function-adapters/spring-cloud-function-adapter-azure/README.adoc index 2e297ddcc..3d4ddc8c9 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-azure/README.adoc +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-azure/README.adoc @@ -3,7 +3,10 @@ This project provides an adapter layer for a Spring Cloud Function application onto Azure. You can write an app with a single `@Bean` of type `Function` and it will be deployable in Azure if you get the JAR file laid out right. -There is a `AzureSpringBootRequestHandler` which you must extend, and provide the input and output types as annotated method parameters (enabling Azure to inspect the class and create JSON bindings). The base class has two useful methods (`handleRequest` and `handleOutput`) to which you can delegate the actual function call, so mostly the function will only ever have one line. +This project provides an adapter layer for a Spring Cloud Function application onto Azure. +You can write an app with a single `@Bean` of type `Function` and it will be deployable in Azure if you get the JAR file laid out right. + +There is an `AzureSpringBootRequestHandler` which you must extend, and provide the input and output types as annotated method parameters (enabling Azure to inspect the class and create JSON bindings). The base class has two useful methods (`handleRequest` and `handleOutput`) to which you can delegate the actual function call, so mostly the function will only ever have one line. Example: @@ -22,12 +25,11 @@ public class FooHandler extends AzureSpringBootRequestHandler { This Azure handler will delegate to a `Function` bean (or a `Function,Publisher>`). Some Azure triggers (e.g. `@CosmosDBTrigger`) result in a input type of `List` and in that case you can bind to `List` in the Azure handler, or `String` (the raw JSON). The `List` input delegates to a `Function` with input type `Map`, or `Publisher` or `List` of the same type. The output of the `Function` can be a `List` (one-for-one) or a single value (aggregation), and the output binding in the Azure declaration should match. -If your app has more than one `@Bean` of type `Function` etc. then you can choose the one to use by configuring `function.name`. Or if you make the `@FunctionName` in the Azure handler method match the function name it should work that way (also for function apps with multiple functions). The functions are extracted from the Spring Cloud `FunctionCatalog`. +If your app has more than one `@Bean` of type `Function` etc. then you can choose the one to use by configuring `function.name`. Or if you make the `@FunctionName` in the Azure handler method match the function name it should work that way (also for function apps with multiple functions). The functions are extracted from the Spring Cloud `FunctionCatalog` so the default function names are the same as the bean names. === Notes on JAR Layout -You don't need the Spring Cloud Function Web at runtime in Azure, so you can optionally exclude this before you create the JAR you deploy to Azure. -A function application on Azure is an archive generated by the Maven plugin. The function lives in the JAR file generated by this project. The sample creates it as an executable jar, using the thin layout, so that Azure can find the handler classes. If you prefer you can just use a regular flat JAR file. The dependencies should *not* be included. +You don't need the Spring Cloud Function Web at runtime in Azure, so you can exclude this before you create the JAR you deploy to Azure, but it won't be used if you include it so it doesn't hurt to leave it in. A function application on Azure is an archive generated by the Maven plugin. The function lives in the JAR file generated by this project. The sample creates it as an executable jar, using the thin layout, so that Azure can find the handler classes. If you prefer you can just use a regular flat JAR file. The dependencies should *not* be included. == Build @@ -45,13 +47,7 @@ You can run the sample locally, just like the other Spring Cloud Function sample and `curl -H "Content-Type: text/plain" localhost:8080/function -d '{"value": "hello foobar"}'`. -Or you can run locally in an Azure host: - ---- -./mvnw azure-functions:run ---- - -You will need the `az` and `func` CLI apps (see https://docs.microsoft.com/en-us/azure/azure-functions/functions-create-first-java-maven for more detail). To deploy the function on Azure runtime: +You will need the `az` CLI app (see https://docs.microsoft.com/en-us/azure/azure-functions/functions-create-first-java-maven for more detail). To deploy the function on Azure runtime: ---- $ az login @@ -68,6 +64,8 @@ The input type for the function in the Azure sample is a Foo with a single prope } ---- +NOTE: The Azure sample app is written in the "non-functional" style (using `@Bean`). The functional style (with just `Function` or `ApplicationContextInitializer`) is is much faster on startup in Azure than the traditional `@Bean` style, so if you don't need `@Beans` (or `@EnableAutoConfiguration`) it's a good choice. Warm starts are not affected. + == Sample Function Go to the link:../../spring-cloud-function-samples/function-sample-azure/[function-sample-azure] to learn about how the sample works, and how to run and test it. \ No newline at end of file diff --git a/spring-cloud-function-context/pom.xml b/spring-cloud-function-context/pom.xml index 79bb9cc66..b300db026 100644 --- a/spring-cloud-function-context/pom.xml +++ b/spring-cloud-function-context/pom.xml @@ -53,7 +53,7 @@ org.springframework.boot spring-boot-starter-test - test + true org.springframework.cloud diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/test/FunctionalSpringBootTest.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/test/FunctionalSpringBootTest.java new file mode 100644 index 000000000..1cc512238 --- /dev/null +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/test/FunctionalSpringBootTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2018 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.test; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.ContextConfiguration; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@org.springframework.boot.test.context.SpringBootTest(properties = "spring.functional.enabled=true", webEnvironment = WebEnvironment.NONE) +@ContextConfiguration(loader = FunctionalTestContextLoader.class) +public @interface FunctionalSpringBootTest { + + @AliasFor(annotation=org.springframework.boot.test.context.SpringBootTest.class, attribute="properties") + String[] value() default {}; + + @AliasFor(annotation=org.springframework.boot.test.context.SpringBootTest.class, attribute="value") + String[] properties() default {}; + + @AliasFor(annotation=org.springframework.boot.test.context.SpringBootTest.class, attribute="classes") + Class[] classes() default {}; + + @AliasFor(annotation=org.springframework.boot.test.context.SpringBootTest.class, attribute="webEnvironment") + WebEnvironment webEnvironment() default WebEnvironment.MOCK; + +} diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/test/FunctionalTestContextLoader.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/test/FunctionalTestContextLoader.java new file mode 100644 index 000000000..1764e4dc1 --- /dev/null +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/test/FunctionalTestContextLoader.java @@ -0,0 +1,37 @@ +/* + * Copyright 2018 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.test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.SpringBootContextLoader; +import org.springframework.cloud.function.context.FunctionalSpringApplication; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogInitializer; + +/** + * A test context loader for Spring Boot applications that use the + * {@link ContextFunctionCatalogInitializer}. + * + * @author Dave Syer + * + */ +public class FunctionalTestContextLoader extends SpringBootContextLoader { + + @Override + protected SpringApplication getSpringApplication() { + return new FunctionalSpringApplication(); + } +} diff --git a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/test/FunctionalTests.java b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/test/FunctionalTests.java new file mode 100644 index 000000000..9313cbce3 --- /dev/null +++ b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/test/FunctionalTests.java @@ -0,0 +1,58 @@ +/* + * 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.context.test; + +import java.util.function.Function; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +import reactor.core.publisher.Flux; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@FunctionalSpringBootTest +public class FunctionalTests { + + @Autowired + private FunctionCatalog catalog; + + @Test + public void words() throws Exception { + Function, Flux> function = catalog.lookup(Function.class, + "function"); + assertThat(function.apply(Flux.just("foo")).blockFirst()).isEqualTo("FOO"); + } + + @SpringBootConfiguration + protected static class TestConfiguration implements Function { + @Override + public String apply(String value) { + return value.toUpperCase(); + } + } +} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/function/FunctionEndpointInitializer.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/function/FunctionEndpointInitializer.java new file mode 100644 index 000000000..0d856fb7c --- /dev/null +++ b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/function/FunctionEndpointInitializer.java @@ -0,0 +1,202 @@ +package org.springframework.cloud.function.web.function; + +import java.lang.management.ManagementFactory; +import java.time.Duration; +import java.util.Set; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.autoconfigure.web.ErrorProperties; +import org.springframework.boot.autoconfigure.web.ResourceProperties; +import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler; +import org.springframework.boot.web.reactive.error.DefaultErrorAttributes; +import org.springframework.boot.web.reactive.error.ErrorAttributes; +import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.cloud.function.context.catalog.FunctionInspector; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.SmartApplicationListener; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.env.Environment; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.util.ClassUtils; +import org.springframework.web.reactive.function.server.HandlerStrategies; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.server.WebExceptionHandler; +import org.springframework.web.server.adapter.HttpWebHandlerAdapter; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.springframework.web.reactive.function.server.RequestPredicates.POST; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; +import static org.springframework.web.reactive.function.server.ServerResponse.ok; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; + +public class FunctionEndpointInitializer + implements ApplicationContextInitializer { + + @Override + public void initialize(GenericApplicationContext context) { + if (context.getEnvironment().getProperty("spring.functional.enabled", + Boolean.class, false) && ClassUtils.isPresent("org.springframework.http.server.reactive.HttpHandler", null)) { + registerEndpoint(context); + registerWebFluxAutoConfiguration(context); + } + } + + private void registerWebFluxAutoConfiguration(GenericApplicationContext context) { + context.registerBean(DefaultErrorWebExceptionHandler.class, + () -> errorHandler(context)); + context.registerBean(WebHttpHandlerBuilder.WEB_HANDLER_BEAN_NAME, + HttpWebHandlerAdapter.class, () -> httpHandler(context)); + context.addApplicationListener(new ServerListener(context)); + } + + private void registerEndpoint(GenericApplicationContext context) { + context.registerBean(FunctionEndpointFactory.class, + () -> new FunctionEndpointFactory(context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getEnvironment())); + context.registerBean(RouterFunction.class, + () -> context.getBean(FunctionEndpointFactory.class).functionEndpoints()); + } + + private HttpWebHandlerAdapter httpHandler(GenericApplicationContext context) { + return (HttpWebHandlerAdapter) RouterFunctions.toHttpHandler( + context.getBean(RouterFunction.class), + HandlerStrategies.empty() + .exceptionHandler(context.getBean(WebExceptionHandler.class)) + .codecs(config -> config.registerDefaults(true)).build()); + } + + private DefaultErrorWebExceptionHandler errorHandler( + GenericApplicationContext context) { + context.registerBean(ErrorAttributes.class, () -> new DefaultErrorAttributes()); + context.registerBean(ErrorProperties.class, () -> new ErrorProperties()); + context.registerBean(ResourceProperties.class, () -> new ResourceProperties()); + DefaultErrorWebExceptionHandler handler = new DefaultErrorWebExceptionHandler( + context.getBean(ErrorAttributes.class), + context.getBean(ResourceProperties.class), + context.getBean(ErrorProperties.class), context); + ServerCodecConfigurer codecs = ServerCodecConfigurer.create(); + handler.setMessageWriters(codecs.getWriters()); + handler.setMessageReaders(codecs.getReaders()); + return handler; + } + + private static class ServerListener implements SmartApplicationListener { + + private static Log logger = LogFactory.getLog(ServerListener.class); + + private GenericApplicationContext context; + + public ServerListener(GenericApplicationContext context) { + this.context = context; + } + + @Override + public void onApplicationEvent(ApplicationEvent event) { + ApplicationContext context = ((ContextRefreshedEvent) event) + .getApplicationContext(); + if (context != this.context) { + return; + } + if (!ClassUtils.isPresent( + "org.springframework.http.server.reactive.HttpHandler", null)) { + logger.info("No web server classes found so no server to start"); + return; + } + Integer port = Integer.valueOf(context.getEnvironment() + .resolvePlaceholders("${server.port:${PORT:8080}}")); + String address = context.getEnvironment() + .resolvePlaceholders("${server.address:0.0.0.0}"); + if (port >= 0) { + HttpHandler handler = context.getBean(HttpHandler.class); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter( + handler); + HttpServer httpServer = HttpServer.create().host(address).port(port) + .handle(adapter); + Thread thread = new Thread(() -> httpServer + .bindUntilJavaShutdown(Duration.ofSeconds(60), this::callback), + "server-startup"); + thread.setDaemon(false); + thread.start(); + } + } + + private void callback(DisposableServer server) { + logger.info("Server started"); + try { + double uptime = ManagementFactory.getRuntimeMXBean().getUptime(); + System.err.println("JVM running for " + uptime + "ms"); + } + catch (Throwable e) { + } + } + + @Override + public boolean supportsEventType(Class eventType) { + return eventType.isAssignableFrom(ContextRefreshedEvent.class); + } + + } + +} + +class FunctionEndpointFactory { + + private static Log logger = LogFactory.getLog(FunctionEndpointFactory.class); + + private Function, Flux> function; + + private FunctionInspector inspector; + + public FunctionEndpointFactory(FunctionCatalog catalog, FunctionInspector inspector, + Environment environment) { + String handler = environment.resolvePlaceholders("${function.handler}"); + if (handler.startsWith("$")) { + handler = null; + } + this.inspector = inspector; + this.function = extract(catalog, handler); + } + + private Function, Flux> extract(FunctionCatalog catalog, String handler) { + Set names = catalog.getNames(Function.class); + if (!names.isEmpty()) { + logger.info("Found functions: " + names); + if (handler != null) { + logger.info("Configured function: " + handler); + if (!names.contains(handler)) { + throw new IllegalStateException("Cannot locate function: " + handler); + } + return catalog.lookup(Function.class, handler); + } + return catalog.lookup(Function.class, names.iterator().next()); + } + throw new IllegalStateException("No function defined"); + } + + @SuppressWarnings({ "unchecked" }) + public RouterFunction functionEndpoints() { + return route(POST("/"), request -> { + Class inputType = this.inspector.getInputType(this.function); + Class outputType = (Class) this.inspector.getOutputType(this.function); + return ok().body( + Mono.from( + (Flux) this.function.apply(request.bodyToFlux(inputType))), + outputType); + }); + } + +} \ No newline at end of file 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 index acaaf1686..49621b7cc 100644 --- a/spring-cloud-function-web/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-function-web/src/main/resources/META-INF/spring.factories @@ -6,3 +6,6 @@ org.springframework.cloud.function.web.source.SupplierAutoConfiguration org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc=\ org.springframework.cloud.function.web.flux.ReactorAutoConfiguration,\ org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration + +org.springframework.context.ApplicationContextInitializer=\ +org.springframework.cloud.function.web.function.FunctionEndpointInitializer \ No newline at end of file diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/ExplicitNonFunctionalTests.java b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/ExplicitNonFunctionalTests.java new file mode 100644 index 000000000..df1e21782 --- /dev/null +++ b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/ExplicitNonFunctionalTests.java @@ -0,0 +1,63 @@ +/* + * 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.test; + +import java.util.function.Function; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.WebTestClient; + +import reactor.core.publisher.Mono; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest({ "spring.main.web-application-type=REACTIVE", + "spring.functional.enabled=false" }) +@AutoConfigureWebTestClient +@DirtiesContext +public class ExplicitNonFunctionalTests { + + @Autowired + private WebTestClient client; + + @Test + public void words() throws Exception { + client.post().uri("/").body(Mono.just("foo"), String.class).exchange() + .expectStatus().isOk().expectBody(String.class).isEqualTo("FOO"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + protected static class TestConfiguration implements Function { + @Override + public String apply(String value) { + return value.toUpperCase(); + } + } +} diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/FunctionalTests.java b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/FunctionalTests.java new file mode 100644 index 000000000..18a6fdb76 --- /dev/null +++ b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/FunctionalTests.java @@ -0,0 +1,58 @@ +/* + * 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.test; + +import java.util.function.Function; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.cloud.function.context.test.FunctionalSpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.WebTestClient; + +import reactor.core.publisher.Mono; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@FunctionalSpringBootTest +@AutoConfigureWebTestClient +public class FunctionalTests { + + @Autowired + private WebTestClient client; + + @Test + public void words() throws Exception { + client.post().uri("/").body(Mono.just("foo"), String.class).exchange() + .expectStatus().isOk().expectBody(String.class).isEqualTo("FOO"); + } + + @SpringBootConfiguration + protected static class TestConfiguration implements Function { + @Override + public String apply(String value) { + return value.toUpperCase(); + } + } +} diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/ImplicitNonFunctionalTests.java b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/ImplicitNonFunctionalTests.java new file mode 100644 index 000000000..a82b5e95c --- /dev/null +++ b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/test/ImplicitNonFunctionalTests.java @@ -0,0 +1,63 @@ +/* + * 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.test; + +import java.util.function.Function; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.WebTestClient; + +import reactor.core.publisher.Mono; + +/** + * @author Dave Syer + * + */ +@RunWith(SpringRunner.class) +@SpringBootTest("spring.main.web-application-type=REACTIVE") +@AutoConfigureWebTestClient +@DirtiesContext +public class ImplicitNonFunctionalTests { + + @Autowired + private WebTestClient client; + + @Test + public void words() throws Exception { + client.post().uri("/").body(Mono.just("foo"), String.class).exchange() + .expectStatus().isOk().expectBody(String.class).isEqualTo("FOO"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + protected static class TestConfiguration { + @Bean + public Function uppercase() { + return value -> value.toUpperCase(); + } + } +}