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/ContextFunctionCatalogAutoConfiguration.java index 8fa764b9b..2d96c65ef 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/ContextFunctionCatalogAutoConfiguration.java @@ -98,56 +98,80 @@ public class ContextFunctionCatalogAutoConfiguration { Map> suppliers) { Map> result = new HashMap<>(); for (String key : suppliers.keySet()) { - if (this.suppliers.contains(key)) { - @SuppressWarnings("unchecked") - Supplier> supplier = (Supplier>) suppliers.get(key); - result.put(key, wrapSupplier(supplier, mapper, key)); - } - else { - result.put(key, suppliers.get(key)); + Supplier target = target(suppliers.get(key), mapper, key); + result.put(key, target); + for (String name : registry.getAliases(key)) { + result.put(name, target); } } return result; } + private Supplier target(Supplier target, ObjectMapper mapper, String key) { + if (this.suppliers.contains(key)) { + @SuppressWarnings("unchecked") + Supplier> supplier = (Supplier>) target; + return wrapSupplier(supplier, mapper, key); + } + else { + return target; + } + } + public Map> wrapFunctions(ObjectMapper mapper, Map> functions) { Map> result = new HashMap<>(); for (String key : functions.keySet()) { - if (this.functions.contains(key)) { - @SuppressWarnings("unchecked") - Function, Flux> function = (Function, Flux>) functions - .get(key); - result.put(key, wrapFunction(function, mapper, key)); - } - else if (!isFluxFunction(key, functions.get(key))) { - @SuppressWarnings({ "unchecked", "rawtypes" }) - FluxFunction value = new FluxFunction(functions.get(key)); - result.put(key, value); - } - else { - result.put(key, functions.get(key)); + Function target = target(functions.get(key), mapper, key); + result.put(key, target); + for (String name : registry.getAliases(key)) { + result.put(name, target); } } return result; } + private Function target(Function target, ObjectMapper mapper, + String key) { + if (this.functions.contains(key)) { + @SuppressWarnings("unchecked") + Function, Flux> function = (Function, Flux>) target; + return wrapFunction(function, mapper, key); + } + else if (!isFluxFunction(key, target)) { + @SuppressWarnings({ "unchecked", "rawtypes" }) + FluxFunction value = new FluxFunction(target); + return value; + } + else { + return target; + } + } + public Map> wrapConsumers(ObjectMapper mapper, Map> consumers) { Map> result = new HashMap<>(); for (String key : consumers.keySet()) { - if (this.consumers.contains(key)) { - @SuppressWarnings("unchecked") - Consumer> consumer = (Consumer>) consumers.get(key); - result.put(key, wrapConsumer(consumer, mapper, key)); - } - else { - result.put(key, consumers.get(key)); + Consumer target = target(consumers.get(key), mapper, key); + result.put(key, target); + for (String name : registry.getAliases(key)) { + result.put(name, target); } } return result; } + private Consumer target(Consumer target, ObjectMapper mapper, String key) { + if (this.consumers.contains(key)) { + @SuppressWarnings("unchecked") + Consumer> consumer = (Consumer>) target; + return wrapConsumer(consumer, mapper, key); + } + else { + return target; + } + } + @Override public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) throws BeansException { diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionController.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionController.java index 6c43d968c..a38e4941c 100644 --- a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionController.java +++ b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionController.java @@ -20,20 +20,18 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; -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.registry.FunctionCatalog; import org.springframework.cloud.function.support.FluxSupplier; import org.springframework.cloud.function.support.FunctionUtils; 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.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.ResponseBody; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -42,46 +40,46 @@ import reactor.core.publisher.Mono; * @author Dave Syer * @author Mark Fisher */ -@RestController -@ConditionalOnClass(RestController.class) -@RequestMapping("${spring.cloud.function.web.path:}") +@Component public class FunctionController { @Value("${debug:${DEBUG:false}}") private boolean debug = false; - - private final FunctionCatalog functions; - - @Autowired - public FunctionController(FunctionCatalog catalog) { - this.functions = catalog; - } - - @PostMapping(path = "/{name}") - public ResponseEntity> function(@PathVariable String name, + + @PostMapping(path = "/**") + @ResponseBody + public ResponseEntity> post( + @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.FunctionHandlerMapping.function") Function, Flux> function, + @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.FunctionHandlerMapping.consumer") Consumer> consumer, @RequestBody Flux body) { - Function, Flux> function = functions.lookupFunction(name); if (function != null) { @SuppressWarnings("unchecked") Flux result = (Flux) function.apply(body); return ResponseEntity.ok().body(debug ? result.log() : result); } - Consumer> consumer = functions.lookupConsumer(name); if (consumer != null) { body = body.cache(); // send a copy back to the caller consumer.accept(body); return ResponseEntity.status(HttpStatus.ACCEPTED).body(body); } - throw new IllegalArgumentException("no such function: " + name); + throw new IllegalArgumentException("no such function"); } - @GetMapping(path = "/{name}") - @SuppressWarnings({ "unchecked", "rawtypes" }) - public Flux supplier(@PathVariable String name) { - Supplier supplier = functions.lookupSupplier(name); - if (supplier == null) { - throw new IllegalArgumentException("no such supplier: " + name); + @GetMapping(path = "/**") + @ResponseBody + public Object get( + @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.FunctionHandlerMapping.function") Function, Flux> function, + @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.FunctionHandlerMapping.supplier") Supplier> supplier, + @RequestAttribute(required = false, name = "org.springframework.cloud.function.web.FunctionHandlerMapping.argument") String argument, + @RequestAttribute("org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping") String path) { + if (function != null) { + return value(function, argument); } + return supplier(supplier); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private Flux supplier(Supplier> supplier) { if (!FunctionUtils.isFluxSupplier(supplier)) { supplier = new FluxSupplier(supplier); } @@ -89,15 +87,10 @@ public class FunctionController { return debug ? result.log() : result; } - @GetMapping(path = "/{name}/{value}") - public Mono value(@PathVariable String name, @PathVariable String value) { - Function, Flux> function = functions.lookupFunction(name); - if (function != null) { - @SuppressWarnings({ "unchecked" }) - Mono result = Mono - .from((Flux) function.apply(Flux.just(value))); - return debug ? result.log() : result; - } - throw new IllegalArgumentException("no such function: " + name); + private Mono value(Function, Flux> function, + @PathVariable String value) { + @SuppressWarnings({ "unchecked" }) + Mono result = Mono.from((Flux) function.apply(Flux.just(value))); + return debug ? result.log() : result; } } diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionHandlerMapping.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionHandlerMapping.java new file mode 100644 index 000000000..9b9f46c3c --- /dev/null +++ b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionHandlerMapping.java @@ -0,0 +1,145 @@ +/* + * 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; + +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.registry.FunctionCatalog; +import org.springframework.context.annotation.Configuration; +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 { + + public static final String FUNCTION = FunctionHandlerMapping.class.getName() + + ".function"; + public static final String CONSUMER = FunctionHandlerMapping.class.getName() + + ".consumer"; + public static final String SUPPLIER = FunctionHandlerMapping.class.getName() + + ".supplier"; + public static final String ARGUMENT = FunctionHandlerMapping.class.getName() + + ".argument"; + private final FunctionCatalog functions; + + private final FunctionController controller; + + @Value("${spring.cloud.function.web.path:}") + private String prefix = ""; + + @Autowired + public FunctionHandlerMapping(FunctionCatalog catalog) { + this.functions = catalog; + setOrder(super.getOrder() - 5); + this.controller = new FunctionController(); + } + + @Override + public void afterPropertiesSet() { + super.afterPropertiesSet(); + 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 (path == null) { + return handler; + } + if (findFunctionForGet(request, path) != null) { + return handler; + } + if (findFunctionForPost(request, path) != null) { + 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(CONSUMER, consumer); + return consumer; + } + Function function = functions.lookupFunction(path); + if (function != null) { + request.setAttribute(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(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(FUNCTION, function); + request.setAttribute(ARGUMENT, value); + return function; + } + } + return null; + } + +} diff --git a/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionMapping.java b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionMapping.java new file mode 100644 index 000000000..2d3343df4 --- /dev/null +++ b/spring-cloud-function-web/src/main/java/org/springframework/cloud/function/web/FunctionMapping.java @@ -0,0 +1,42 @@ +/* + * 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; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * @author Dave Syer + * + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface FunctionMapping { + + String name() default ""; + + @AliasFor("path") + String[] value() default {}; + + @AliasFor("value") + String[] path() default {};} 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 8c24235c9..682f401a7 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 @@ -1,3 +1,3 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ -org.springframework.cloud.function.web.FunctionController,\ +org.springframework.cloud.function.web.FunctionHandlerMapping,\ 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/web/PrefixTests.java b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PrefixTests.java new file mode 100644 index 000000000..805729b42 --- /dev/null +++ b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/PrefixTests.java @@ -0,0 +1,70 @@ +/* + * 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; + +import java.net.URI; +import java.util.function.Supplier; + +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.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +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) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = "spring.cloud.function.web.path=/functions") +public class PrefixTests { + + @LocalServerPort + private int port; + @Autowired + private TestRestTemplate rest; + + @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("foobar"); + } + + @EnableAutoConfiguration + @org.springframework.boot.test.context.TestConfiguration + protected static class TestConfiguration { + @Bean({ "words", "get/more" }) + public Supplier> words() { + return () -> Flux.fromArray(new String[] { "foo", "bar" }); + } + } +} diff --git a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/RestApplicationTests.java b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/RestApplicationTests.java index e742b2975..0dcc3bfd1 100644 --- a/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/RestApplicationTests.java +++ b/spring-cloud-function-web/src/test/java/org/springframework/cloud/function/web/RestApplicationTests.java @@ -26,12 +26,13 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; +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.SpringBootApplication; +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; @@ -64,6 +65,16 @@ public class RestApplicationTests { @Autowired private TestConfiguration test; + @Before + public void init() { + test.list.clear(); + } + + @Test + public void staticResource() throws Exception { + assertThat(rest.getForObject("/test.html", String.class)).contains("Test"); + } + @Test public void wordsSSE() throws Exception { assertThat(rest.exchange( @@ -96,6 +107,14 @@ public class RestApplicationTests { assertThat(result.getBody()).isEqualTo("foobar"); } + @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("foobar"); + } + @Test public void updates() throws Exception { ResponseEntity result = rest.exchange( @@ -165,6 +184,23 @@ public class RestApplicationTests { .isEqualTo("[FOO][BAR]"); } + @Test + public void transform() { + assertThat(rest.postForObject("/transform", "foo\nbar", String.class)) + .isEqualTo("[FOO][BAR]"); + } + + @Test + public void postMore() { + assertThat(rest.postForObject("/post/more", "foo\nbar", String.class)) + .isEqualTo("[FOO][BAR]"); + } + + @Test + public void postMoreFoo() { + assertThat(rest.getForObject("/post/more/foo", String.class)).isEqualTo("[FOO]"); + } + @Test public void uppercaseGet() { assertThat(rest.getForObject("/uppercase/foo", String.class)).isEqualTo("[FOO]"); @@ -196,12 +232,14 @@ public class RestApplicationTests { @Test public void uppercaseJsonStream() throws Exception { - assertThat(rest - .exchange(RequestEntity.post(new URI("/maps")) - .contentType(MediaType.APPLICATION_JSON) - // TODO: make this work without newline separator - .body("{\"value\":\"foo\"}\n{\"value\":\"bar\"}"), String.class) - .getBody()).isEqualTo("{\"value\":\"FOO\"}{\"value\":\"BAR\"}"); + assertThat( + rest.exchange( + RequestEntity.post(new URI("/maps")) + .contentType(MediaType.APPLICATION_JSON) + // TODO: make this work without newline separator + .body("{\"value\":\"foo\"}\n{\"value\":\"bar\"}"), + String.class).getBody()) + .isEqualTo("{\"value\":\"FOO\"}{\"value\":\"BAR\"}"); } @Test @@ -217,12 +255,13 @@ public class RestApplicationTests { return "data:" + StringUtils.arrayToDelimitedString(values, "\n\ndata:") + "\n\n"; } - @SpringBootApplication + @EnableAutoConfiguration + @org.springframework.boot.test.context.TestConfiguration public static class TestConfiguration { private List list = new ArrayList<>(); - @Bean + @Bean({ "uppercase", "transform", "post/more" }) public Function, Flux> uppercase() { return flux -> flux.log() .map(value -> "[" + value.trim().toUpperCase() + "]"); @@ -247,7 +286,7 @@ public class RestApplicationTests { }); } - @Bean + @Bean({ "words", "get/more" }) public Supplier> words() { return () -> Flux.fromArray(new String[] { "foo", "bar" }); } diff --git a/spring-cloud-function-web/src/test/resources/static/test.html b/spring-cloud-function-web/src/test/resources/static/test.html new file mode 100644 index 000000000..9ca4440f5 --- /dev/null +++ b/spring-cloud-function-web/src/test/resources/static/test.html @@ -0,0 +1 @@ +Test \ No newline at end of file