From 9687a5041ed658da3ef9994d001b602e6de08a6c Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 20 Jul 2017 16:35:44 +0100 Subject: [PATCH] Add support for making endpoints accessible via HTTP This commit adds support for exposing endpoint operations over HTTP. Jersey, Spring MVC, and WebFlux are all supported but the programming model remains web framework agnostic. When using WebFlux, blocking operations are automatically performed on a separate thread using Reactor's scheduler support. Support for web-specific extensions is provided via a new `@WebEndpointExtension` annotation. Closes gh-7970 Closes gh-9946 Closes gh-9947 --- eclipse/org.eclipse.jdt.core.prefs | 1 + .../src/checkstyle/import-control.xml | 18 +- spring-boot/pom.xml | 22 +- .../endpoint/web/EndpointLinksResolver.java | 68 ++ .../boot/endpoint/web/Link.java | 66 ++ .../web/OperationRequestPredicate.java | 136 ++++ .../web/WebAnnotationEndpointDiscoverer.java | 219 ++++++ .../endpoint/web/WebEndpointExtension.java | 46 ++ .../endpoint/web/WebEndpointHttpMethod.java | 37 ++ .../endpoint/web/WebEndpointOperation.java | 69 ++ .../endpoint/web/WebEndpointResponse.java | 86 +++ .../jersey/JerseyEndpointResourceFactory.java | 208 ++++++ .../endpoint/web/jersey/package-info.java | 20 + .../mvc/WebEndpointServletHandlerMapping.java | 243 +++++++ .../boot/endpoint/web/mvc/package-info.java | 20 + .../boot/endpoint/web/package-info.java | 20 + .../WebEndpointReactiveHandlerMapping.java | 254 +++++++ .../endpoint/web/reactive/package-info.java | 20 + .../AbstractWebEndpointIntegrationTests.java | 481 ++++++++++++++ .../web/EndpointLinksResolverTests.java | 88 +++ .../web/OperationRequestPredicateTests.java | 71 ++ .../WebAnnotationEndpointDiscovererTests.java | 628 ++++++++++++++++++ .../JerseyWebEndpointIntegrationTests.java | 108 +++ .../mvc/MvcWebEndpointIntegrationTests.java | 96 +++ .../ReactiveWebEndpointIntegrationTests.java | 107 +++ 25 files changed, 3130 insertions(+), 2 deletions(-) create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/EndpointLinksResolver.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/Link.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/OperationRequestPredicate.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscoverer.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointExtension.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointHttpMethod.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointOperation.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointResponse.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/JerseyEndpointResourceFactory.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/package-info.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/package-info.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/package-info.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/WebEndpointReactiveHandlerMapping.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/package-info.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/AbstractWebEndpointIntegrationTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/EndpointLinksResolverTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/OperationRequestPredicateTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscovererTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/mvc/MvcWebEndpointIntegrationTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/reactive/ReactiveWebEndpointIntegrationTests.java diff --git a/eclipse/org.eclipse.jdt.core.prefs b/eclipse/org.eclipse.jdt.core.prefs index e44b6fc366..3ec70345df 100644 --- a/eclipse/org.eclipse.jdt.core.prefs +++ b/eclipse/org.eclipse.jdt.core.prefs @@ -10,6 +10,7 @@ org.eclipse.jdt.core.codeComplete.staticFieldSuffixes= org.eclipse.jdt.core.codeComplete.staticFinalFieldPrefixes= org.eclipse.jdt.core.codeComplete.staticFinalFieldSuffixes= org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.methodParameters=generate org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve org.eclipse.jdt.core.compiler.compliance=1.6 diff --git a/spring-boot-parent/src/checkstyle/import-control.xml b/spring-boot-parent/src/checkstyle/import-control.xml index b73b7cbd40..cc52288841 100644 --- a/spring-boot-parent/src/checkstyle/import-control.xml +++ b/spring-boot-parent/src/checkstyle/import-control.xml @@ -29,6 +29,22 @@ + + + + + + + + + + + + + + + + @@ -109,4 +125,4 @@ - + \ No newline at end of file diff --git a/spring-boot/pom.xml b/spring-boot/pom.xml index 56f060e620..21d22b2c0e 100644 --- a/spring-boot/pom.xml +++ b/spring-boot/pom.xml @@ -159,6 +159,11 @@ jetty-webapp true + + org.glassfish.jersey.core + jersey-server + true + org.hamcrest hamcrest-library @@ -271,6 +276,11 @@ h2 test + + com.jayway.jsonpath + json-path + test + com.microsoft.sqlserver mssql-jdbc @@ -316,6 +326,16 @@ jaybird-jdk18 test + + org.glassfish.jersey.containers + jersey-container-servlet-core + test + + + org.glassfish.jersey.media + jersey-media-json-jackson + test + org.hsqldb hsqldb @@ -352,4 +372,4 @@ test - + \ No newline at end of file diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/EndpointLinksResolver.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/EndpointLinksResolver.java new file mode 100644 index 0000000000..647a59c69f --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/EndpointLinksResolver.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.boot.endpoint.EndpointInfo; + +/** + * A resolver for {@link Link links} to web endpoints. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class EndpointLinksResolver { + + /** + * Resolves links to the operations of the given {code webEndpoints} based on a + * request with the given {@code requestUrl}. + * + * @param webEndpoints the web endpoints + * @param requestUrl the url of the request for the endpoint links + * @return the links + */ + public Map resolveLinks( + Collection> webEndpoints, + String requestUrl) { + String normalizedUrl = normalizeRequestUrl(requestUrl); + Map links = new LinkedHashMap(); + links.put("self", new Link(normalizedUrl)); + for (EndpointInfo endpoint : webEndpoints) { + for (WebEndpointOperation operation : endpoint.getOperations()) { + webEndpoints.stream().map(EndpointInfo::getId).forEach((id) -> links + .put(operation.getId(), createLink(normalizedUrl, operation))); + } + } + return links; + } + + private String normalizeRequestUrl(String requestUrl) { + if (requestUrl.endsWith("/")) { + return requestUrl.substring(0, requestUrl.length() - 1); + } + return requestUrl; + } + + private Link createLink(String requestUrl, WebEndpointOperation operation) { + String path = operation.getRequestPredicate().getPath(); + return new Link(requestUrl + (path.startsWith("/") ? path : "/" + path)); + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/Link.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/Link.java new file mode 100644 index 0000000000..9b2ecec8b7 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/Link.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import org.springframework.core.style.ToStringCreator; + +/** + * Details for a link in a + * HAL-formatted + * response. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class Link { + + private final String href; + + private final boolean templated; + + /** + * Creates a new {@link Link} with the given {@code href}. + * @param href the href + */ + public Link(String href) { + this.href = href; + this.templated = href.contains("{"); + + } + + /** + * Returns the href of the link. + * @return the href + */ + public String getHref() { + return this.href; + } + + /** + * Returns whether or not the {@link #getHref() href} is templated. + * @return {@code true} if the href is templated, otherwise {@code false} + */ + public boolean isTemplated() { + return this.templated; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("href", this.href).toString(); + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/OperationRequestPredicate.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/OperationRequestPredicate.java new file mode 100644 index 0000000000..269877976b --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/OperationRequestPredicate.java @@ -0,0 +1,136 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.core.style.ToStringCreator; + +/** + * A predicate for a request to an operation on a web endpoint. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class OperationRequestPredicate { + + private final String path; + + private final String canonicalPath; + + private final WebEndpointHttpMethod httpMethod; + + private final Collection consumes; + + private final Collection produces; + + /** + * Creates a new {@code WebEndpointRequestPredict}. + * + * @param path the path for the operation + * @param httpMethod the HTTP method that the operation supports + * @param produces the media types that the operation produces + * @param consumes the media types that the operation consumes + */ + public OperationRequestPredicate(String path, WebEndpointHttpMethod httpMethod, + Collection consumes, Collection produces) { + this.path = path; + this.canonicalPath = path.replaceAll("\\{.*?}", "{*}"); + this.httpMethod = httpMethod; + this.consumes = consumes; + this.produces = produces; + } + + /** + * Returns the path for the operation. + * @return the path + */ + public String getPath() { + return this.path; + } + + /** + * Returns the HTTP method for the operation. + * @return the HTTP method + */ + public WebEndpointHttpMethod getHttpMethod() { + return this.httpMethod; + } + + /** + * Returns the media types that the operation consumes. + * @return the consumed media types + */ + public Collection getConsumes() { + return Collections.unmodifiableCollection(this.consumes); + } + + /** + * Returns the media types that the operation produces. + * @return the produced media types + */ + public Collection getProduces() { + return Collections.unmodifiableCollection(this.produces); + } + + @Override + public String toString() { + return new ToStringCreator(this).append("httpMethod", this.httpMethod) + .append("path", this.path).append("consumes", this.consumes) + .append("produces", this.produces).toString(); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + this.consumes.hashCode(); + result = prime * result + this.httpMethod.hashCode(); + result = prime * result + this.canonicalPath.hashCode(); + result = prime * result + this.produces.hashCode(); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + OperationRequestPredicate other = (OperationRequestPredicate) obj; + if (!this.consumes.equals(other.consumes)) { + return false; + } + if (this.httpMethod != other.httpMethod) { + return false; + } + if (!this.canonicalPath.equals(other.canonicalPath)) { + return false; + } + if (!this.produces.equals(other.produces)) { + return false; + } + return true; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscoverer.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscoverer.java new file mode 100644 index 0000000000..35390beb12 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscoverer.java @@ -0,0 +1,219 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.reactivestreams.Publisher; + +import org.springframework.boot.endpoint.AnnotationEndpointDiscoverer; +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.CachingOperationInvoker; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.EndpointType; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.OperationParameterMapper; +import org.springframework.boot.endpoint.ReflectiveOperationInvoker; +import org.springframework.boot.endpoint.Selector; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.io.Resource; +import org.springframework.util.ClassUtils; + +/** + * Discovers the {@link Endpoint endpoints} in an {@link ApplicationContext} with + * {@link WebEndpointExtension web extensions} applied to them. + * + * @author Andy Wilkinson + * @author Stephane Nicoll + * @since 2.0.0 + */ +public class WebAnnotationEndpointDiscoverer extends + AnnotationEndpointDiscoverer { + + /** + * Creates a new {@link WebAnnotationEndpointDiscoverer} that will discover + * {@link Endpoint endpoints} and {@link WebEndpointExtension web extensions} using + * the given {@link ApplicationContext}. + * @param applicationContext the application context + * @param operationParameterMapper the {@link OperationParameterMapper} used to + * convert arguments when an operation is invoked + * @param cachingConfigurationFactory the {@link CachingConfiguration} factory to use + * @param consumedMediaTypes the media types consumed by web endpoint operations + * @param producedMediaTypes the media types produced by web endpoint operations + */ + public WebAnnotationEndpointDiscoverer(ApplicationContext applicationContext, + OperationParameterMapper operationParameterMapper, + Function cachingConfigurationFactory, + Collection consumedMediaTypes, + Collection producedMediaTypes) { + super(applicationContext, + new WebEndpointOperationFactory(operationParameterMapper, + consumedMediaTypes, producedMediaTypes), + WebEndpointOperation::getRequestPredicate, cachingConfigurationFactory); + } + + @Override + public Collection> discoverEndpoints() { + Collection> endpoints = discoverEndpointsWithExtension( + WebEndpointExtension.class, EndpointType.WEB); + verifyThatOperationsHaveDistinctPredicates(endpoints); + return endpoints.stream().map(EndpointInfoDescriptor::getEndpointInfo) + .collect(Collectors.toList()); + } + + private void verifyThatOperationsHaveDistinctPredicates( + Collection> endpointDescriptors) { + List> clashes = new ArrayList<>(); + endpointDescriptors.forEach((descriptor) -> clashes + .addAll(descriptor.findDuplicateOperations().values())); + if (!clashes.isEmpty()) { + StringBuilder message = new StringBuilder(); + message.append(String.format( + "Found multiple web operations with matching request predicates:%n")); + clashes.forEach((clash) -> { + message.append(" ").append(clash.get(0).getRequestPredicate()) + .append(String.format(":%n")); + clash.forEach((operation) -> message.append(" ") + .append(String.format("%s%n", operation))); + }); + throw new IllegalStateException(message.toString()); + } + } + + private static final class WebEndpointOperationFactory + implements EndpointOperationFactory { + + private static final boolean REACTIVE_STREAMS_PRESENT = ClassUtils.isPresent( + "org.reactivestreams.Publisher", + WebEndpointOperationFactory.class.getClassLoader()); + + private final OperationParameterMapper parameterMapper; + + private final Collection consumedMediaTypes; + + private final Collection producedMediaTypes; + + private WebEndpointOperationFactory(OperationParameterMapper parameterMapper, + Collection consumedMediaTypes, + Collection producedMediaTypes) { + this.parameterMapper = parameterMapper; + this.consumedMediaTypes = consumedMediaTypes; + this.producedMediaTypes = producedMediaTypes; + } + + @Override + public WebEndpointOperation createOperation(String endpointId, + AnnotationAttributes operationAttributes, Object target, Method method, + EndpointOperationType type, long timeToLive) { + WebEndpointHttpMethod httpMethod = determineHttpMethod(type); + OperationRequestPredicate requestPredicate = new OperationRequestPredicate( + determinePath(endpointId, method), httpMethod, + determineConsumedMediaTypes(httpMethod, method), + determineProducedMediaTypes(method)); + OperationInvoker invoker = new ReflectiveOperationInvoker( + this.parameterMapper, target, method); + if (timeToLive > 0) { + invoker = new CachingOperationInvoker(invoker, timeToLive); + } + return new WebEndpointOperation(type, invoker, determineBlocking(method), + requestPredicate, determineId(endpointId, method)); + } + + private String determinePath(String endpointId, Method operationMethod) { + StringBuilder path = new StringBuilder(endpointId); + Stream.of(operationMethod.getParameters()) + .filter(( + parameter) -> parameter.getAnnotation(Selector.class) != null) + .map((parameter) -> "/{" + parameter.getName() + "}") + .forEach(path::append); + return path.toString(); + } + + private String determineId(String endpointId, Method operationMethod) { + StringBuilder path = new StringBuilder(endpointId); + Stream.of(operationMethod.getParameters()) + .filter(( + parameter) -> parameter.getAnnotation(Selector.class) != null) + .map((parameter) -> "-" + parameter.getName()).forEach(path::append); + return path.toString(); + } + + private Collection determineConsumedMediaTypes( + WebEndpointHttpMethod httpMethod, Method method) { + if (WebEndpointHttpMethod.POST == httpMethod && consumesRequestBody(method)) { + return this.consumedMediaTypes; + } + return Collections.emptyList(); + } + + private Collection determineProducedMediaTypes(Method method) { + if (Void.class.equals(method.getReturnType()) + || void.class.equals(method.getReturnType())) { + return Collections.emptyList(); + } + if (producesResourceResponseBody(method)) { + return Collections.singletonList("application/octet-stream"); + } + return this.producedMediaTypes; + } + + private boolean producesResourceResponseBody(Method method) { + if (Resource.class.equals(method.getReturnType())) { + return true; + } + if (WebEndpointResponse.class.isAssignableFrom(method.getReturnType())) { + ResolvableType returnType = ResolvableType.forMethodReturnType(method); + if (ResolvableType.forClass(Resource.class) + .isAssignableFrom(returnType.getGeneric(0))) { + return true; + } + } + return false; + } + + private boolean consumesRequestBody(Method method) { + return Stream.of(method.getParameters()).anyMatch( + (parameter) -> parameter.getAnnotation(Selector.class) == null); + } + + private WebEndpointHttpMethod determineHttpMethod( + EndpointOperationType operationType) { + if (operationType == EndpointOperationType.WRITE) { + return WebEndpointHttpMethod.POST; + } + return WebEndpointHttpMethod.GET; + } + + private boolean determineBlocking(Method method) { + return !REACTIVE_STREAMS_PRESENT + || !Publisher.class.isAssignableFrom(method.getReturnType()); + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointExtension.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointExtension.java new file mode 100644 index 0000000000..f89416fb23 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointExtension.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.endpoint.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.boot.endpoint.Endpoint; + +/** + * Identifies a type as being a Web-specific extension of an {@link Endpoint}. + * + * @author Andy Wilkinson + * @author Stephane Nicoll + * @since 2.0.0 + * @see Endpoint + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface WebEndpointExtension { + + /** + * The {@link Endpoint endpoint} class to which this Web extension relates. + * @return the endpoint class + */ + Class endpoint(); + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointHttpMethod.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointHttpMethod.java new file mode 100644 index 0000000000..d43d41b872 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointHttpMethod.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +/** + * An enumeration of HTTP methods supported by web endpoint operations. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public enum WebEndpointHttpMethod { + + /** + * An HTTP GET request. + */ + GET, + + /** + * An HTTP POST request. + */ + POST + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointOperation.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointOperation.java new file mode 100644 index 0000000000..df87213729 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointOperation.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import org.springframework.boot.endpoint.EndpointOperation; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.OperationInvoker; + +/** + * An operation on a web endpoint. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class WebEndpointOperation extends EndpointOperation { + + private final OperationRequestPredicate requestPredicate; + + private final String id; + + /** + * Creates a new {@code WebEndpointOperation} with the given {@code type}. The + * operation can be performed using the given {@code operationInvoker}. The operation + * can handle requests that match the given {@code requestPredicate}. + * @param type the type of the operation + * @param operationInvoker used to perform the operation + * @param blocking whether or not this is a blocking operation + * @param requestPredicate the predicate for requests that can be handled by the + * @param id the id of the operation, unique within its endpoint operation + */ + public WebEndpointOperation(EndpointOperationType type, + OperationInvoker operationInvoker, boolean blocking, + OperationRequestPredicate requestPredicate, String id) { + super(type, operationInvoker, blocking); + this.requestPredicate = requestPredicate; + this.id = id; + } + + /** + * Returns the predicate for requests that can be handled by this operation. + * @return the predicate + */ + public OperationRequestPredicate getRequestPredicate() { + return this.requestPredicate; + } + + /** + * Returns the ID of the operation that uniquely identifies it within its endpoint. + * @return the ID + */ + public String getId() { + return this.id; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointResponse.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointResponse.java new file mode 100644 index 0000000000..ae50b1c14e --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointResponse.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +/** + * A {@code WebEndpointResponse} can be returned by an operation on a + * {@link WebEndpointExtension} to provide additional, web-specific information such as + * the HTTP status code. + * + * @param the type of the response body + * @author Stephane Nicoll + * @author Andy Wilkinson + * @since 2.0.0 + */ +public final class WebEndpointResponse { + + private final T body; + + private final int status; + + /** + * Creates a new {@code WebEndpointResponse} with no body and a 200 (OK) status. + */ + public WebEndpointResponse() { + this(null); + } + + /** + * Creates a new {@code WebEndpointResponse} with no body and the given + * {@code status}. + * @param status the HTTP status + */ + public WebEndpointResponse(int status) { + this(null, status); + } + + /** + * Creates a new {@code WebEndpointResponse} with then given body and a 200 (OK) + * status. + * @param body the body + */ + public WebEndpointResponse(T body) { + this(body, 200); + } + + /** + * Creates a new {@code WebEndpointResponse} with then given body and status. + * @param body the body + * @param status the HTTP status + */ + public WebEndpointResponse(T body, int status) { + this.body = body; + this.status = status; + } + + /** + * Returns the body for the response. + * @return the body + */ + public T getBody() { + return this.body; + } + + /** + * Returns the status for the response. + * @return the status + */ + public int getStatus() { + return this.status; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/JerseyEndpointResourceFactory.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/JerseyEndpointResourceFactory.java new file mode 100644 index 0000000000..eb49a5a58c --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/JerseyEndpointResourceFactory.java @@ -0,0 +1,208 @@ +/* + * Copyright 2012-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.boot.endpoint.web.jersey; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.ws.rs.HttpMethod; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; + +import org.glassfish.jersey.process.Inflector; +import org.glassfish.jersey.server.ContainerRequest; +import org.glassfish.jersey.server.model.Resource; +import org.glassfish.jersey.server.model.Resource.Builder; + +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.ParameterMappingException; +import org.springframework.boot.endpoint.web.EndpointLinksResolver; +import org.springframework.boot.endpoint.web.Link; +import org.springframework.boot.endpoint.web.OperationRequestPredicate; +import org.springframework.boot.endpoint.web.WebEndpointOperation; +import org.springframework.boot.endpoint.web.WebEndpointResponse; +import org.springframework.util.CollectionUtils; + +/** + * A factory for creating Jersey {@link Resource Resources} for web endpoint operations. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class JerseyEndpointResourceFactory { + + private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver(); + + /** + * Creates {@link Resource Resources} for the operations of the given + * {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param webEndpoints the web endpoints + * @return the resources for the operations + */ + public Collection createEndpointResources(String endpointPath, + Collection> webEndpoints) { + List resources = new ArrayList<>(); + webEndpoints.stream() + .flatMap((endpointInfo) -> endpointInfo.getOperations().stream()) + .map((operation) -> createResource(endpointPath, operation)) + .forEach(resources::add); + resources.add(createEndpointLinksResource(endpointPath, webEndpoints)); + return resources; + } + + private Resource createResource(String endpointPath, WebEndpointOperation operation) { + OperationRequestPredicate requestPredicate = operation.getRequestPredicate(); + Builder resourceBuilder = Resource.builder() + .path(endpointPath + "/" + requestPredicate.getPath()); + resourceBuilder.addMethod(requestPredicate.getHttpMethod().name()) + .consumes(toStringArray(requestPredicate.getConsumes())) + .produces(toStringArray(requestPredicate.getProduces())) + .handledBy(new EndpointInvokingInflector(operation.getOperationInvoker(), + !requestPredicate.getConsumes().isEmpty())); + return resourceBuilder.build(); + } + + private String[] toStringArray(Collection collection) { + return collection.toArray(new String[collection.size()]); + } + + private Resource createEndpointLinksResource(String endpointPath, + Collection> webEndpoints) { + Builder resourceBuilder = Resource.builder().path(endpointPath); + resourceBuilder.addMethod("GET").handledBy( + new EndpointLinksInflector(webEndpoints, this.endpointLinksResolver)); + return resourceBuilder.build(); + } + + private static final class EndpointInvokingInflector + implements Inflector { + + private final OperationInvoker operationInvoker; + + private final boolean readBody; + + private EndpointInvokingInflector(OperationInvoker operationInvoker, + boolean readBody) { + this.operationInvoker = operationInvoker; + this.readBody = readBody; + } + + @SuppressWarnings("unchecked") + @Override + public Response apply(ContainerRequestContext data) { + Map arguments = new HashMap<>(); + if (this.readBody) { + Map body = ((ContainerRequest) data) + .readEntity(Map.class); + if (body != null) { + arguments.putAll(body); + } + } + arguments.putAll(extractPathParmeters(data)); + arguments.putAll(extractQueryParmeters(data)); + try { + return convertToJaxRsResponse(this.operationInvoker.invoke(arguments), + data.getRequest().getMethod()); + } + catch (ParameterMappingException ex) { + return Response.status(Status.BAD_REQUEST).build(); + } + } + + private Map extractPathParmeters( + ContainerRequestContext requestContext) { + return extract(requestContext.getUriInfo().getPathParameters()); + } + + private Map extractQueryParmeters( + ContainerRequestContext requestContext) { + return extract(requestContext.getUriInfo().getQueryParameters()); + } + + private Map extract( + MultivaluedMap multivaluedMap) { + Map result = new HashMap<>(); + multivaluedMap.forEach((name, values) -> { + if (!CollectionUtils.isEmpty(values)) { + result.put(name, values.size() == 1 ? values.get(0) : values); + } + }); + return result; + } + + private Response convertToJaxRsResponse(Object response, String httpMethod) { + if (response == null) { + return Response.status(HttpMethod.GET.equals(httpMethod) + ? Status.NOT_FOUND : Status.NO_CONTENT).build(); + } + try { + if (!(response instanceof WebEndpointResponse)) { + return Response.status(Status.OK).entity(convertIfNecessary(response)) + .build(); + } + WebEndpointResponse webEndpointResponse = (WebEndpointResponse) response; + return Response.status(webEndpointResponse.getStatus()) + .entity(convertIfNecessary(webEndpointResponse.getBody())) + .build(); + } + catch (IOException ex) { + return Response.status(Status.INTERNAL_SERVER_ERROR).build(); + } + } + + private Object convertIfNecessary(Object body) throws IOException { + if (body instanceof org.springframework.core.io.Resource) { + return ((org.springframework.core.io.Resource) body).getInputStream(); + } + return body; + } + + } + + private static final class EndpointLinksInflector + implements Inflector { + + private final Collection> endpoints; + + private final EndpointLinksResolver linksResolver; + + private EndpointLinksInflector( + Collection> endpoints, + EndpointLinksResolver linksResolver) { + this.endpoints = endpoints; + this.linksResolver = linksResolver; + } + + @Override + public Response apply(ContainerRequestContext request) { + Map links = this.linksResolver.resolveLinks(this.endpoints, + request.getUriInfo().getAbsolutePath().toString()); + return Response.ok(Collections.singletonMap("_links", links)).build(); + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/package-info.java new file mode 100644 index 0000000000..65469b9973 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Jersey web endpoint support. + */ +package org.springframework.boot.endpoint.web.jersey; diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java new file mode 100644 index 0000000000..77065dd8c1 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java @@ -0,0 +1,243 @@ +/* + * Copyright 2012-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.boot.endpoint.web.mvc; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.ParameterMappingException; +import org.springframework.boot.endpoint.web.EndpointLinksResolver; +import org.springframework.boot.endpoint.web.Link; +import org.springframework.boot.endpoint.web.OperationRequestPredicate; +import org.springframework.boot.endpoint.web.WebEndpointOperation; +import org.springframework.boot.endpoint.web.WebEndpointResponse; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.accept.PathExtensionContentNegotiationStrategy; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; +import org.springframework.web.servlet.mvc.condition.ConsumesRequestCondition; +import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition; +import org.springframework.web.servlet.mvc.condition.ProducesRequestCondition; +import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping; + +/** + * A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available + * over HTTP using Spring MVC. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class WebEndpointServletHandlerMapping extends RequestMappingInfoHandlerMapping + implements InitializingBean { + + private final Method handle = ReflectionUtils.findMethod(OperationHandler.class, + "handle", HttpServletRequest.class, Map.class); + + private final Method links = ReflectionUtils.findMethod( + WebEndpointServletHandlerMapping.class, "links", HttpServletRequest.class); + + private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver(); + + private final String endpointPath; + + private final Collection> webEndpoints; + + private final CorsConfiguration corsConfiguration; + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param collection the web endpoints operations + */ + public WebEndpointServletHandlerMapping(String endpointPath, + Collection> collection) { + this(endpointPath, collection, null); + } + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param webEndpoints the web endpoints + * @param corsConfiguration the CORS configuraton for the endpoints + */ + public WebEndpointServletHandlerMapping(String endpointPath, + Collection> webEndpoints, + CorsConfiguration corsConfiguration) { + this.endpointPath = (endpointPath.startsWith("/") ? "" : "/") + endpointPath; + this.webEndpoints = webEndpoints; + this.corsConfiguration = corsConfiguration; + setOrder(-100); + } + + @Override + protected void initHandlerMethods() { + this.webEndpoints.stream() + .flatMap((webEndpoint) -> webEndpoint.getOperations().stream()) + .forEach(this::registerMappingForOperation); + registerMapping(new RequestMappingInfo(patternsRequestConditionForPattern(""), + new RequestMethodsRequestCondition(RequestMethod.GET), null, null, null, + null, null), this, this.links); + } + + @Override + protected CorsConfiguration initCorsConfiguration(Object handler, Method method, + RequestMappingInfo mapping) { + return this.corsConfiguration; + } + + private void registerMappingForOperation(WebEndpointOperation operation) { + registerMapping(createRequestMappingInfo(operation), + new OperationHandler(operation.getOperationInvoker()), this.handle); + } + + private RequestMappingInfo createRequestMappingInfo( + WebEndpointOperation operationInfo) { + OperationRequestPredicate requestPredicate = operationInfo.getRequestPredicate(); + return new RequestMappingInfo(null, + patternsRequestConditionForPattern(requestPredicate.getPath()), + new RequestMethodsRequestCondition( + RequestMethod.valueOf(requestPredicate.getHttpMethod().name())), + null, null, + new ConsumesRequestCondition( + toStringArray(requestPredicate.getConsumes())), + new ProducesRequestCondition( + toStringArray(requestPredicate.getProduces())), + null); + } + + private PatternsRequestCondition patternsRequestConditionForPattern(String path) { + return new PatternsRequestCondition( + new String[] { this.endpointPath + + (StringUtils.hasText(path) ? "/" + path : "") }, + null, null, false, false); + } + + private String[] toStringArray(Collection collection) { + return collection.toArray(new String[collection.size()]); + } + + @Override + protected boolean isHandler(Class beanType) { + return false; + } + + @Override + protected RequestMappingInfo getMappingForMethod(Method method, + Class handlerType) { + return null; + } + + @Override + protected void extendInterceptors(List interceptors) { + interceptors.add(new SkipPathExtensionContentNegotiation()); + } + + @ResponseBody + private Map> links(HttpServletRequest request) { + return Collections.singletonMap("_links", this.endpointLinksResolver + .resolveLinks(this.webEndpoints, request.getRequestURL().toString())); + } + + /** + * A handler for an endpoint operation. + */ + final class OperationHandler { + + private final OperationInvoker operationInvoker; + + OperationHandler(OperationInvoker operationInvoker) { + this.operationInvoker = operationInvoker; + } + + @SuppressWarnings("unchecked") + @ResponseBody + public Object handle(HttpServletRequest request, + @RequestBody(required = false) Map body) { + Map arguments = new HashMap<>((Map) request + .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)); + HttpMethod httpMethod = HttpMethod.valueOf(request.getMethod()); + if (body != null && HttpMethod.POST == httpMethod) { + arguments.putAll(body); + } + request.getParameterMap().forEach((name, values) -> arguments.put(name, + values.length == 1 ? values[0] : Arrays.asList(values))); + try { + return handleResult(this.operationInvoker.invoke(arguments), httpMethod); + } + catch (ParameterMappingException ex) { + return new ResponseEntity(HttpStatus.BAD_REQUEST); + } + } + + private Object handleResult(Object result, HttpMethod httpMethod) { + if (result == null) { + return new ResponseEntity<>(httpMethod == HttpMethod.GET + ? HttpStatus.NOT_FOUND : HttpStatus.NO_CONTENT); + } + if (!(result instanceof WebEndpointResponse)) { + return result; + } + WebEndpointResponse response = (WebEndpointResponse) result; + return new ResponseEntity(response.getBody(), + HttpStatus.valueOf(response.getStatus())); + } + + } + + /** + * {@link HandlerInterceptorAdapter} to ensure that + * {@link PathExtensionContentNegotiationStrategy} is skipped for web endpoints. + */ + private static final class SkipPathExtensionContentNegotiation + extends HandlerInterceptorAdapter { + + private static final String SKIP_ATTRIBUTE = PathExtensionContentNegotiationStrategy.class + .getName() + ".SKIP"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + request.setAttribute(SKIP_ATTRIBUTE, Boolean.TRUE); + return true; + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/package-info.java new file mode 100644 index 0000000000..f2874cd1ff --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Spring MVC web endpoint support. + */ +package org.springframework.boot.endpoint.web.mvc; diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/package-info.java new file mode 100644 index 0000000000..8c0c6c9d85 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Web endpoint support. + */ +package org.springframework.boot.endpoint.web; diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/WebEndpointReactiveHandlerMapping.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/WebEndpointReactiveHandlerMapping.java new file mode 100644 index 0000000000..9db49432ad --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/WebEndpointReactiveHandlerMapping.java @@ -0,0 +1,254 @@ +/* + * Copyright 2012-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.boot.endpoint.web.reactive; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.ParameterMappingException; +import org.springframework.boot.endpoint.web.EndpointLinksResolver; +import org.springframework.boot.endpoint.web.Link; +import org.springframework.boot.endpoint.web.OperationRequestPredicate; +import org.springframework.boot.endpoint.web.WebEndpointOperation; +import org.springframework.boot.endpoint.web.WebEndpointResponse; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.result.condition.ConsumesRequestCondition; +import org.springframework.web.reactive.result.condition.PatternsRequestCondition; +import org.springframework.web.reactive.result.condition.ProducesRequestCondition; +import org.springframework.web.reactive.result.condition.RequestMethodsRequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available + * over HTTP using Spring WebFlux. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class WebEndpointReactiveHandlerMapping extends RequestMappingInfoHandlerMapping + implements InitializingBean { + + private static final PathPatternParser pathPatternParser = new PathPatternParser(); + + private final Method handleRead = ReflectionUtils + .findMethod(ReadOperationHandler.class, "handle", ServerWebExchange.class); + + private final Method handleWrite = ReflectionUtils.findMethod( + WriteOperationHandler.class, "handle", ServerWebExchange.class, Map.class); + + private final Method links = ReflectionUtils.findMethod(getClass(), "links", + ServerHttpRequest.class); + + private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver(); + + private final String endpointPath; + + private final Collection> webEndpoints; + + private final CorsConfiguration corsConfiguration; + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param collection the web endpoints + */ + public WebEndpointReactiveHandlerMapping(String endpointPath, + Collection> collection) { + this(endpointPath, collection, null); + } + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param webEndpoints the web endpoints + * @param corsConfiguration the CORS configuraton for the endpoints + */ + public WebEndpointReactiveHandlerMapping(String endpointPath, + Collection> webEndpoints, + CorsConfiguration corsConfiguration) { + this.endpointPath = (endpointPath.startsWith("/") ? "" : "/") + endpointPath; + this.webEndpoints = webEndpoints; + this.corsConfiguration = corsConfiguration; + setOrder(-100); + } + + @Override + protected void initHandlerMethods() { + this.webEndpoints.stream() + .flatMap((webEndpoint) -> webEndpoint.getOperations().stream()) + .forEach(this::registerMappingForOperation); + registerMapping(new RequestMappingInfo( + new PatternsRequestCondition(pathPatternParser.parse(this.endpointPath)), + new RequestMethodsRequestCondition(RequestMethod.GET), null, null, null, + null, null), this, this.links); + } + + @Override + protected CorsConfiguration initCorsConfiguration(Object handler, Method method, + RequestMappingInfo mapping) { + return this.corsConfiguration; + } + + private void registerMappingForOperation(WebEndpointOperation operation) { + EndpointOperationType operationType = operation.getType(); + registerMapping(createRequestMappingInfo(operation), + operationType == EndpointOperationType.WRITE + ? new WriteOperationHandler(operation.getOperationInvoker()) + : new ReadOperationHandler(operation.getOperationInvoker()), + operationType == EndpointOperationType.WRITE ? this.handleWrite + : this.handleRead); + } + + private RequestMappingInfo createRequestMappingInfo( + WebEndpointOperation operationInfo) { + OperationRequestPredicate requestPredicate = operationInfo.getRequestPredicate(); + return new RequestMappingInfo(null, + new PatternsRequestCondition(pathPatternParser + .parse(this.endpointPath + "/" + requestPredicate.getPath())), + new RequestMethodsRequestCondition( + RequestMethod.valueOf(requestPredicate.getHttpMethod().name())), + null, null, + new ConsumesRequestCondition( + toStringArray(requestPredicate.getConsumes())), + new ProducesRequestCondition( + toStringArray(requestPredicate.getProduces())), + null); + } + + private String[] toStringArray(Collection collection) { + return collection.toArray(new String[collection.size()]); + } + + @Override + protected boolean isHandler(Class beanType) { + return false; + } + + @Override + protected RequestMappingInfo getMappingForMethod(Method method, + Class handlerType) { + return null; + } + + @ResponseBody + private Map> links(ServerHttpRequest request) { + return Collections.singletonMap("_links", + this.endpointLinksResolver.resolveLinks(this.webEndpoints, + UriComponentsBuilder.fromUri(request.getURI()).replaceQuery(null) + .toUriString())); + } + + /** + * Base class for handlers for endpoint operations. + */ + abstract class AbstractOperationHandler { + + private final OperationInvoker operationInvoker; + + AbstractOperationHandler(OperationInvoker operationInvoker) { + this.operationInvoker = operationInvoker; + } + + @SuppressWarnings("unchecked") + ResponseEntity doHandle(ServerWebExchange exchange, Map body) { + Map arguments = new HashMap<>((Map) exchange + .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)); + if (body != null) { + arguments.putAll(body); + } + exchange.getRequest().getQueryParams().forEach((name, values) -> arguments + .put(name, values.size() == 1 ? values.get(0) : values)); + try { + return handleResult(this.operationInvoker.invoke(arguments), + exchange.getRequest().getMethod()); + } + catch (ParameterMappingException ex) { + return new ResponseEntity(HttpStatus.BAD_REQUEST); + } + } + + private ResponseEntity handleResult(Object result, HttpMethod httpMethod) { + if (result == null) { + return new ResponseEntity<>(httpMethod == HttpMethod.GET + ? HttpStatus.NOT_FOUND : HttpStatus.NO_CONTENT); + } + if (!(result instanceof WebEndpointResponse)) { + return new ResponseEntity<>(result, HttpStatus.OK); + } + WebEndpointResponse response = (WebEndpointResponse) result; + return new ResponseEntity(response.getBody(), + HttpStatus.valueOf(response.getStatus())); + } + + } + + /** + * A handler for an endpoint write operation. + */ + final class WriteOperationHandler extends AbstractOperationHandler { + + WriteOperationHandler(OperationInvoker operationInvoker) { + super(operationInvoker); + } + + @ResponseBody + public ResponseEntity handle(ServerWebExchange exchange, + @RequestBody(required = false) Map body) { + return doHandle(exchange, body); + } + + } + + /** + * A handler for an endpoint write operation. + */ + final class ReadOperationHandler extends AbstractOperationHandler { + + ReadOperationHandler(OperationInvoker operationInvoker) { + super(operationInvoker); + } + + @ResponseBody + public ResponseEntity handle(ServerWebExchange exchange) { + return doHandle(exchange, null); + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/package-info.java new file mode 100644 index 0000000000..98c77dc6f9 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Reactive web endpoint support. + */ +package org.springframework.boot.endpoint.web.reactive; diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/AbstractWebEndpointIntegrationTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/AbstractWebEndpointIntegrationTests.java new file mode 100644 index 0000000000..0c4fe80aee --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/AbstractWebEndpointIntegrationTests.java @@ -0,0 +1,481 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.junit.Test; + +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.OperationParameterMapper; +import org.springframework.boot.endpoint.ReadOperation; +import org.springframework.boot.endpoint.Selector; +import org.springframework.boot.endpoint.WriteOperation; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Abstract base class for web endpoint integration tests. + * + * @param the type of application context used by the tests + * @author Andy Wilkinson + */ +public abstract class AbstractWebEndpointIntegrationTests { + + private final Class exporterConfiguration; + + protected AbstractWebEndpointIntegrationTests(Class exporterConfiguration) { + this.exporterConfiguration = exporterConfiguration; + } + + @Test + public void readOperation() { + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/test").accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk().expectBody().jsonPath("All") + .isEqualTo(true)); + } + + @Test + public void readOperationWithSelector() { + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/test/one") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("part").isEqualTo("one")); + } + + @Test + public void readOperationWithSelectorContainingADot() { + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/test/foo.bar") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("part").isEqualTo("foo.bar")); + } + + @Test + public void linksToOtherEndpointsAreProvided() { + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("").accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk().expectBody() + .jsonPath("_links.length()").isEqualTo(3) + .jsonPath("_links.self.href").isNotEmpty() + .jsonPath("_links.self.templated").isEqualTo(false) + .jsonPath("_links.test.href").isNotEmpty() + .jsonPath("_links.test.templated").isEqualTo(false) + .jsonPath("_links.test-part.href").isNotEmpty() + .jsonPath("_links.test-part.templated").isEqualTo(true)); + } + + @Test + public void readOperationWithSingleQueryParameters() { + load(QueryEndpointConfiguration.class, + (client) -> client.get().uri("/query?one=1&two=2") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("query").isEqualTo("1 2")); + } + + @Test + public void readOperationWithSingleQueryParametersAndMultipleValues() { + load(QueryEndpointConfiguration.class, + (client) -> client.get().uri("/query?one=1&one=1&two=2") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("query").isEqualTo("1,1 2")); + } + + @Test + public void readOperationWithListQueryParameterAndSingleValue() { + load(QueryWithListEndpointConfiguration.class, + (client) -> client.get().uri("/query?one=1&two=2") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("query").isEqualTo("1 [2]")); + } + + @Test + public void readOperationWithListQueryParameterAndMultipleValues() { + load(QueryWithListEndpointConfiguration.class, + (client) -> client.get().uri("/query?one=1&two=2&two=2") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("query").isEqualTo("1 [2, 2]")); + } + + @Test + public void readOperationWithMappingFailureProducesBadRequestResponse() { + load(QueryEndpointConfiguration.class, + (client) -> client.get().uri("/query?two=two") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isBadRequest()); + } + + @Test + public void writeOperation() { + load(TestEndpointConfiguration.class, (client) -> { + Map body = new HashMap<>(); + body.put("foo", "one"); + body.put("bar", "two"); + client.post().uri("/test").syncBody(body).accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isNoContent().expectBody().isEmpty(); + }); + } + + @Test + public void writeOperationWithVoidResponse() { + load(VoidWriteResponseEndpointConfiguration.class, (context, client) -> { + client.post().uri("/voidwrite").accept(MediaType.APPLICATION_JSON).exchange() + .expectStatus().isNoContent().expectBody().isEmpty(); + verify(context.getBean(EndpointDelegate.class)).write(); + }); + } + + @Test + public void nullIsPassedToTheOperationWhenArgumentIsNotFoundInPostRequestBody() { + load(TestEndpointConfiguration.class, (context, client) -> { + Map body = new HashMap<>(); + body.put("foo", "one"); + client.post().uri("/test").syncBody(body).accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isNoContent().expectBody().isEmpty(); + verify(context.getBean(EndpointDelegate.class)).write("one", null); + }); + } + + @Test + public void nullsArePassedToTheOperationWhenPostRequestHasNoBody() { + load(TestEndpointConfiguration.class, (context, client) -> { + client.post().uri("/test").contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isNoContent().expectBody().isEmpty(); + verify(context.getBean(EndpointDelegate.class)).write(null, null); + }); + } + + @Test + public void nullResponseFromReadOperationResultsInNotFoundResponseStatus() { + load(NullReadResponseEndpointConfiguration.class, + (context, client) -> client.get().uri("/nullread") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isNotFound()); + } + + @Test + public void nullResponseFromWriteOperationResultsInNoContentResponseStatus() { + load(NullWriteResponseEndpointConfiguration.class, + (context, client) -> client.post().uri("/nullwrite") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isNoContent()); + } + + @Test + public void readOperationWithResourceResponse() { + load(ResourceEndpointConfiguration.class, (context, client) -> { + byte[] responseBody = client.get().uri("/resource").exchange().expectStatus() + .isOk().expectHeader().contentType(MediaType.APPLICATION_OCTET_STREAM) + .returnResult(byte[].class).getResponseBodyContent(); + assertThat(responseBody).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); + }); + } + + @Test + public void readOperationWithResourceWebOperationResponse() { + load(ResourceWebEndpointResponseEndpointConfiguration.class, + (context, client) -> { + byte[] responseBody = client.get().uri("/resource").exchange() + .expectStatus().isOk().expectHeader() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .returnResult(byte[].class).getResponseBodyContent(); + assertThat(responseBody).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, + 9); + }); + } + + protected abstract T createApplicationContext(Class... config); + + protected abstract int getPort(T context); + + private void load(Class configuration, + BiConsumer consumer) { + T context = createApplicationContext(configuration, this.exporterConfiguration); + try { + consumer.accept(context, + WebTestClient.bindToServer() + .baseUrl( + "http://localhost:" + getPort(context) + "/endpoints") + .build()); + } + finally { + context.close(); + } + } + + protected void load(Class configuration, Consumer clientConsumer) { + load(configuration, (context, client) -> clientConsumer.accept(client)); + } + + @Configuration + static class BaseConfiguration { + + @Bean + public EndpointDelegate endpointDelegate() { + return mock(EndpointDelegate.class); + } + + @Bean + public WebAnnotationEndpointDiscoverer webEndpointDiscoverer( + ApplicationContext applicationContext) { + OperationParameterMapper parameterMapper = new ConversionServiceOperationParameterMapper( + DefaultConversionService.getSharedInstance()); + return new WebAnnotationEndpointDiscoverer(applicationContext, + parameterMapper, (id) -> new CachingConfiguration(0), + Collections.singletonList("application/json"), + Collections.singletonList("application/json")); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + protected static class TestEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint(EndpointDelegate endpointDelegate) { + return new TestEndpoint(endpointDelegate); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class QueryEndpointConfiguration { + + @Bean + public QueryEndpoint queryEndpoint() { + return new QueryEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class QueryWithListEndpointConfiguration { + + @Bean + public QueryWithListEndpoint queryEndpoint() { + return new QueryWithListEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class VoidWriteResponseEndpointConfiguration { + + @Bean + public VoidWriteResponseEndpoint voidWriteResponseEndpoint( + EndpointDelegate delegate) { + return new VoidWriteResponseEndpoint(delegate); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class NullWriteResponseEndpointConfiguration { + + @Bean + public NullWriteResponseEndpoint nullWriteResponseEndpoint( + EndpointDelegate delegate) { + return new NullWriteResponseEndpoint(delegate); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class NullReadResponseEndpointConfiguration { + + @Bean + public NullReadResponseEndpoint nullResponseEndpoint() { + return new NullReadResponseEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class ResourceEndpointConfiguration { + + @Bean + public ResourceEndpoint resourceEndpoint() { + return new ResourceEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class ResourceWebEndpointResponseEndpointConfiguration { + + @Bean + public ResourceWebEndpointResponseEndpoint resourceEndpoint() { + return new ResourceWebEndpointResponseEndpoint(); + } + + } + + @Endpoint(id = "test") + static class TestEndpoint { + + private final EndpointDelegate endpointDelegate; + + TestEndpoint(EndpointDelegate endpointDelegate) { + this.endpointDelegate = endpointDelegate; + } + + @ReadOperation + public Map readAll() { + return Collections.singletonMap("All", true); + } + + @ReadOperation + public Map readPart(@Selector String part) { + return Collections.singletonMap("part", part); + } + + @WriteOperation + public void write(String foo, String bar) { + this.endpointDelegate.write(foo, bar); + } + + } + + @Endpoint(id = "query") + static class QueryEndpoint { + + @ReadOperation + public Map query(String one, Integer two) { + return Collections.singletonMap("query", one + " " + two); + } + + @ReadOperation + public Map queryWithParameterList(@Selector String list, + String one, List two) { + return Collections.singletonMap("query", list + " " + one + " " + two); + } + + } + + @Endpoint(id = "query") + static class QueryWithListEndpoint { + + @ReadOperation + public Map queryWithParameterList(String one, List two) { + return Collections.singletonMap("query", one + " " + two); + } + + } + + @Endpoint(id = "voidwrite") + static class VoidWriteResponseEndpoint { + + private final EndpointDelegate delegate; + + VoidWriteResponseEndpoint(EndpointDelegate delegate) { + this.delegate = delegate; + } + + @WriteOperation + public void write() { + this.delegate.write(); + } + + } + + @Endpoint(id = "nullwrite") + static class NullWriteResponseEndpoint { + + private final EndpointDelegate delegate; + + NullWriteResponseEndpoint(EndpointDelegate delegate) { + this.delegate = delegate; + } + + @WriteOperation + public Object write() { + this.delegate.write(); + return null; + } + + } + + @Endpoint(id = "nullread") + static class NullReadResponseEndpoint { + + @ReadOperation + public String readReturningNull() { + return null; + } + + } + + @Endpoint(id = "resource") + static class ResourceEndpoint { + + @ReadOperation + public Resource read() { + return new ByteArrayResource(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + } + + } + + @Endpoint(id = "resource") + static class ResourceWebEndpointResponseEndpoint { + + @ReadOperation + public WebEndpointResponse read() { + return new WebEndpointResponse( + new ByteArrayResource(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }), + 200); + } + + } + + public interface EndpointDelegate { + + void write(); + + void write(String foo, String bar); + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/EndpointLinksResolverTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/EndpointLinksResolverTests.java new file mode 100644 index 0000000000..efd3f6bb10 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/EndpointLinksResolverTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import org.assertj.core.api.Condition; +import org.junit.Test; + +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link EndpointLinksResolver}. + * + * @author Andy Wilkinson + */ +public class EndpointLinksResolverTests { + + private final EndpointLinksResolver linksResolver = new EndpointLinksResolver(); + + @Test + public void linkResolutionWithTrailingSlashStripsSlashOnSelfLink() { + Map links = this.linksResolver.resolveLinks(Collections.emptyList(), + "https://api.example.com/application/"); + assertThat(links).hasSize(1); + assertThat(links).hasEntrySatisfying("self", + linkWithHref("https://api.example.com/application")); + } + + @Test + public void linkResolutionWithoutTrailingSlash() { + Map links = this.linksResolver.resolveLinks(Collections.emptyList(), + "https://api.example.com/application"); + assertThat(links).hasSize(1); + assertThat(links).hasEntrySatisfying("self", + linkWithHref("https://api.example.com/application")); + } + + @Test + public void resolvedLinksContainsALinkForEachEndpointOperation() { + Map links = this.linksResolver + .resolveLinks( + Arrays.asList(new EndpointInfo<>("alpha", true, + Arrays.asList(operationWithPath("/alpha", "alpha"), + operationWithPath("/alpha/{name}", + "alpha-name")))), + "https://api.example.com/application"); + assertThat(links).hasSize(3); + assertThat(links).hasEntrySatisfying("self", + linkWithHref("https://api.example.com/application")); + assertThat(links).hasEntrySatisfying("alpha", + linkWithHref("https://api.example.com/application/alpha")); + assertThat(links).hasEntrySatisfying("alpha-name", + linkWithHref("https://api.example.com/application/alpha/{name}")); + } + + private WebEndpointOperation operationWithPath(String path, String id) { + return new WebEndpointOperation(EndpointOperationType.READ, null, false, + new OperationRequestPredicate(path, WebEndpointHttpMethod.GET, + Collections.emptyList(), Collections.emptyList()), + id); + } + + private Condition linkWithHref(String href) { + return new Condition<>((link) -> href.equals(link.getHref()), + "Link with href '%s'", href); + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/OperationRequestPredicateTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/OperationRequestPredicateTests.java new file mode 100644 index 0000000000..177c41a23f --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/OperationRequestPredicateTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import java.util.Collections; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OperationRequestPredicate}. + * + * @author Andy Wilkinson + */ +public class OperationRequestPredicateTests { + + @Test + public void predicatesWithIdenticalPathsAreEqual() { + assertThat(predicateWithPath("/path")).isEqualTo(predicateWithPath("/path")); + } + + @Test + public void predicatesWithDifferentPathsAreNotEqual() { + assertThat(predicateWithPath("/one")).isNotEqualTo(predicateWithPath("/two")); + } + + @Test + public void predicatesWithIdenticalPathsWithVariablesAreEqual() { + assertThat(predicateWithPath("/path/{foo}")) + .isEqualTo(predicateWithPath("/path/{foo}")); + } + + @Test + public void predicatesWhereOneHasAPathAndTheOtherHasAVariableAreNotEqual() { + assertThat(predicateWithPath("/path/{foo}")) + .isNotEqualTo(predicateWithPath("/path/foo")); + } + + @Test + public void predicatesWithSinglePathVariablesInTheSamplePlaceAreEqual() { + assertThat(predicateWithPath("/path/{foo1}")) + .isEqualTo(predicateWithPath("/path/{foo2}")); + } + + @Test + public void predicatesWithMultiplePathVariablesInTheSamplePlaceAreEqual() { + assertThat(predicateWithPath("/path/{foo1}/more/{bar1}")) + .isEqualTo(predicateWithPath("/path/{foo2}/more/{bar2}")); + } + + private OperationRequestPredicate predicateWithPath(String path) { + return new OperationRequestPredicate(path, WebEndpointHttpMethod.GET, + Collections.emptyList(), Collections.emptyList()); + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscovererTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscovererTests.java new file mode 100644 index 0000000000..2af16b2be7 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscovererTests.java @@ -0,0 +1,628 @@ +/* + * Copyright 2012-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.boot.endpoint.web; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.assertj.core.api.Condition; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.CachingOperationInvoker; +import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointType; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.ReadOperation; +import org.springframework.boot.endpoint.Selector; +import org.springframework.boot.endpoint.WriteOperation; +import org.springframework.boot.endpoint.web.AbstractWebEndpointIntegrationTests.BaseConfiguration; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link WebAnnotationEndpointDiscoverer}. + * + * @author Andy Wilkinson + * @author Stephane Nicoll + */ +public class WebAnnotationEndpointDiscovererTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void discoveryWorksWhenThereAreNoEndpoints() { + load(EmptyConfiguration.class, + (discoverer) -> assertThat(discoverer.discoverEndpoints()).isEmpty()); + } + + @Test + public void webExtensionMustHaveEndpoint() { + load(TestWebEndpointExtensionConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Invalid extension"); + this.thrown.expectMessage(TestWebEndpointExtension.class.getName()); + this.thrown.expectMessage("no endpoint found"); + this.thrown.expectMessage(TestEndpoint.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void onlyWebEndpointsAreDiscovered() { + load(MultipleEndpointsConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + }); + } + + @Test + public void oneOperationIsDiscoveredWhenExtensionOverridesOperation() { + load(OverriddenOperationWebEndpointExtensionConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + EndpointInfo endpoint = endpoints.get("test"); + assertThat(requestPredicates(endpoint)).has( + requestPredicates(path("test").httpMethod(WebEndpointHttpMethod.GET) + .consumes().produces("application/json"))); + }); + } + + @Test + public void twoOperationsAreDiscoveredWhenExtensionAddsOperation() { + load(AdditionalOperationWebEndpointConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + EndpointInfo endpoint = endpoints.get("test"); + assertThat(requestPredicates(endpoint)).has(requestPredicates( + path("test").httpMethod(WebEndpointHttpMethod.GET).consumes() + .produces("application/json"), + path("test/{id}").httpMethod(WebEndpointHttpMethod.GET).consumes() + .produces("application/json"))); + }); + } + + @Test + public void predicateForWriteOperationThatReturnsVoidHasNoProducedMediaTypes() { + load(VoidWriteOperationEndpointConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("voidwrite"); + EndpointInfo endpoint = endpoints.get("voidwrite"); + assertThat(requestPredicates(endpoint)).has(requestPredicates( + path("voidwrite").httpMethod(WebEndpointHttpMethod.POST).produces() + .consumes("application/json"))); + }); + } + + @Test + public void discoveryFailsWhenTwoExtensionsHaveTheSameEndpointType() { + load(ClashingWebEndpointConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found two extensions for the same endpoint"); + this.thrown.expectMessage(TestEndpoint.class.getName()); + this.thrown.expectMessage(TestWebEndpointExtension.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenTwoStandardEndpointsHaveTheSameId() { + load(ClashingStandardEndpointConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found two endpoints with the id 'test': "); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenEndpointHasClashingOperations() { + load(ClashingOperationsEndpointConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage( + "Found multiple web operations with matching request predicates:"); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenExtensionIsNotCompatibleWithTheEndpointType() { + load(InvalidWebExtensionConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Invalid extension"); + this.thrown.expectMessage(NonWebWebEndpointExtension.class.getName()); + this.thrown.expectMessage(NonWebEndpoint.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void twoOperationsOnSameEndpointClashWhenSelectorsHaveDifferentNames() { + load(ClashingSelectorsWebEndpointExtensionConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage( + "Found multiple web operations with matching request predicates:"); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void endpointMainReadOperationIsCachedWithMatchingId() { + load((id) -> new CachingConfiguration(500), TestEndpointConfiguration.class, + (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + EndpointInfo endpoint = endpoints.get("test"); + assertThat(endpoint.getOperations()).hasSize(1); + OperationInvoker operationInvoker = endpoint.getOperations() + .iterator().next().getOperationInvoker(); + assertThat(operationInvoker) + .isInstanceOf(CachingOperationInvoker.class); + assertThat( + ((CachingOperationInvoker) operationInvoker).getTimeToLive()) + .isEqualTo(500); + }); + } + + @Test + public void operationsThatReturnResourceProduceApplicationOctetStream() { + load(ResourceEndpointConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("resource"); + EndpointInfo endpoint = endpoints.get("resource"); + assertThat(requestPredicates(endpoint)).has(requestPredicates( + path("resource").httpMethod(WebEndpointHttpMethod.GET).consumes() + .produces("application/octet-stream"))); + }); + } + + private void load(Class configuration, + Consumer consumer) { + this.load((id) -> null, configuration, consumer); + } + + private void load(Function cachingConfigurationFactory, + Class configuration, Consumer consumer) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + configuration); + try { + consumer.accept(new WebAnnotationEndpointDiscoverer(context, + new ConversionServiceOperationParameterMapper( + DefaultConversionService.getSharedInstance()), + cachingConfigurationFactory, + Collections.singletonList("application/json"), + Collections.singletonList("application/json"))); + } + finally { + context.close(); + } + } + + private Map> mapEndpoints( + Collection> endpoints) { + Map> endpointById = new HashMap<>(); + endpoints.forEach((endpoint) -> endpointById.put(endpoint.getId(), endpoint)); + return endpointById; + } + + private List requestPredicates( + EndpointInfo endpoint) { + return endpoint.getOperations().stream() + .map(WebEndpointOperation::getRequestPredicate) + .collect(Collectors.toList()); + } + + private Condition> requestPredicates( + RequestPredicateMatcher... matchers) { + return new Condition<>((predicates) -> { + if (predicates.size() != matchers.length) { + return false; + } + Map matchCounts = new HashMap<>(); + for (OperationRequestPredicate predicate : predicates) { + matchCounts.put(predicate, Stream.of(matchers) + .filter(matcher -> matcher.matches(predicate)).count()); + } + return matchCounts.values().stream().noneMatch(count -> count != 1); + }, Arrays.toString(matchers)); + } + + private RequestPredicateMatcher path(String path) { + return new RequestPredicateMatcher(path); + } + + @Configuration + static class EmptyConfiguration { + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class TestWebEndpointExtension { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getOne(@Selector String id) { + return null; + } + + @WriteOperation + public void update(String foo, String bar) { + + } + + public void someOtherMethod() { + + } + + } + + @Endpoint(id = "test") + static class TestEndpoint { + + @ReadOperation + public Object getAll() { + return null; + } + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class OverriddenOperationWebEndpointExtension { + + @ReadOperation + public Object getAll() { + return null; + } + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class AdditionalOperationWebEndpointExtension { + + @ReadOperation + public Object getOne(@Selector String id) { + return null; + } + + } + + @Endpoint(id = "test") + static class ClashingOperationsEndpoint { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getAgain() { + return null; + } + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class ClashingOperationsWebEndpointExtension { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getAgain() { + return null; + } + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class ClashingSelectorsWebEndpointExtension { + + @ReadOperation + public Object readOne(@Selector String oneA, @Selector String oneB) { + return null; + } + + @ReadOperation + public Object readTwo(@Selector String twoA, @Selector String twoB) { + return null; + } + + } + + @Endpoint(id = "nonweb", types = EndpointType.JMX) + static class NonWebEndpoint { + + @ReadOperation + public Object getData() { + return null; + } + + } + + @WebEndpointExtension(endpoint = NonWebEndpoint.class) + static class NonWebWebEndpointExtension { + + @ReadOperation + public Object getSomething(@Selector String name) { + return null; + } + + } + + @Endpoint(id = "voidwrite") + static class VoidWriteOperationEndpoint { + + @WriteOperation + public void write(String foo, String bar) { + + } + + } + + @Endpoint(id = "resource") + static class ResourceEndpoint { + + @ReadOperation + public Resource read() { + return new ByteArrayResource(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + } + + } + + @Configuration + static class MultipleEndpointsConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public NonWebEndpoint nonWebEndpoint() { + return new NonWebEndpoint(); + } + + } + + @Configuration + static class TestWebEndpointExtensionConfiguration { + + @Bean + public TestWebEndpointExtension endpointExtension() { + return new TestWebEndpointExtension(); + } + + } + + @Configuration + static class ClashingOperationsEndpointConfiguration { + + @Bean + public ClashingOperationsEndpoint clashingOperationsEndpoint() { + return new ClashingOperationsEndpoint(); + } + + } + + @Configuration + static class ClashingOperationsWebEndpointExtensionConfiguration { + + @Bean + public ClashingOperationsWebEndpointExtension clashingOperationsExtension() { + return new ClashingOperationsWebEndpointExtension(); + } + + } + + @Configuration + @Import(TestEndpointConfiguration.class) + static class OverriddenOperationWebEndpointExtensionConfiguration { + + @Bean + public OverriddenOperationWebEndpointExtension overriddenOperationExtension() { + return new OverriddenOperationWebEndpointExtension(); + } + + } + + @Configuration + @Import(TestEndpointConfiguration.class) + static class AdditionalOperationWebEndpointConfiguration { + + @Bean + public AdditionalOperationWebEndpointExtension additionalOperationExtension() { + return new AdditionalOperationWebEndpointExtension(); + } + + } + + @Configuration + static class TestEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + } + + @Configuration + static class ClashingWebEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public TestWebEndpointExtension testExtensionOne() { + return new TestWebEndpointExtension(); + } + + @Bean + public TestWebEndpointExtension testExtensionTwo() { + return new TestWebEndpointExtension(); + } + + } + + @Configuration + static class ClashingStandardEndpointConfiguration { + + @Bean + public TestEndpoint testEndpointTwo() { + return new TestEndpoint(); + } + + @Bean + public TestEndpoint testEndpointOne() { + return new TestEndpoint(); + } + + } + + @Configuration + static class ClashingSelectorsWebEndpointExtensionConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public ClashingSelectorsWebEndpointExtension clashingSelectorsExtension() { + return new ClashingSelectorsWebEndpointExtension(); + } + + } + + @Configuration + static class InvalidWebExtensionConfiguration { + + @Bean + public NonWebEndpoint nonWebEndpoint() { + return new NonWebEndpoint(); + } + + @Bean + public NonWebWebEndpointExtension nonWebWebEndpointExtension() { + return new NonWebWebEndpointExtension(); + } + + } + + @Configuration + static class VoidWriteOperationEndpointConfiguration { + + @Bean + public VoidWriteOperationEndpoint voidWriteOperationEndpoint() { + return new VoidWriteOperationEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class ResourceEndpointConfiguration { + + @Bean + public ResourceEndpoint resourceEndpoint() { + return new ResourceEndpoint(); + } + + } + + private static final class RequestPredicateMatcher { + + private final String path; + + private List produces; + + private List consumes; + + private WebEndpointHttpMethod httpMethod; + + private RequestPredicateMatcher(String path) { + this.path = path; + } + + public RequestPredicateMatcher produces(String... mediaTypes) { + this.produces = Arrays.asList(mediaTypes); + return this; + } + + public RequestPredicateMatcher consumes(String... mediaTypes) { + this.consumes = Arrays.asList(mediaTypes); + return this; + } + + private RequestPredicateMatcher httpMethod(WebEndpointHttpMethod httpMethod) { + this.httpMethod = httpMethod; + return this; + } + + private boolean matches(OperationRequestPredicate predicate) { + return (this.path == null || this.path.equals(predicate.getPath())) + && (this.httpMethod == null + || this.httpMethod == predicate.getHttpMethod()) + && (this.produces == null || this.produces + .equals(new ArrayList<>(predicate.getProduces()))) + && (this.consumes == null || this.consumes + .equals(new ArrayList<>(predicate.getConsumes()))); + } + + @Override + public String toString() { + return "Request predicate with path = '" + this.path + "', httpMethod = '" + + this.httpMethod + "', produces = '" + this.produces + "'"; + } + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java new file mode 100644 index 0000000000..c184103a40 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2012-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.boot.endpoint.web.jersey; + +import java.util.Collection; +import java.util.HashSet; + +import javax.ws.rs.ext.ContextResolver; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.server.model.Resource; +import org.glassfish.jersey.servlet.ServletContainer; + +import org.springframework.boot.endpoint.web.AbstractWebEndpointIntegrationTests; +import org.springframework.boot.endpoint.web.WebAnnotationEndpointDiscoverer; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Integration tests for web endpoints exposed using Jersey. + * + * @author Andy Wilkinson + */ +public class JerseyWebEndpointIntegrationTests extends + AbstractWebEndpointIntegrationTests { + + public JerseyWebEndpointIntegrationTests() { + super(JerseyConfiguration.class); + } + + @Override + protected AnnotationConfigServletWebServerApplicationContext createApplicationContext( + Class... config) { + return new AnnotationConfigServletWebServerApplicationContext(config); + } + + @Override + protected int getPort(AnnotationConfigServletWebServerApplicationContext context) { + return context.getWebServer().getPort(); + } + + @Configuration + static class JerseyConfiguration { + + @Bean + public TomcatServletWebServerFactory tomcat() { + return new TomcatServletWebServerFactory(0); + } + + @Bean + public ServletRegistrationBean servletContainer( + ResourceConfig resourceConfig) { + return new ServletRegistrationBean( + new ServletContainer(resourceConfig), "/*"); + } + + @Bean + public ResourceConfig resourceConfig( + WebAnnotationEndpointDiscoverer endpointDiscoverer) { + ResourceConfig resourceConfig = new ResourceConfig(); + Collection resources = new JerseyEndpointResourceFactory() + .createEndpointResources("endpoints", + endpointDiscoverer.discoverEndpoints()); + resourceConfig.registerResources(new HashSet(resources)); + resourceConfig.register(JacksonFeature.class); + resourceConfig.register(new ObjectMapperContextResolver(new ObjectMapper()), + ContextResolver.class); + return resourceConfig; + } + + } + + private static final class ObjectMapperContextResolver + implements ContextResolver { + + private final ObjectMapper objectMapper; + + private ObjectMapperContextResolver(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public ObjectMapper getContext(Class type) { + return this.objectMapper; + } + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/mvc/MvcWebEndpointIntegrationTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/mvc/MvcWebEndpointIntegrationTests.java new file mode 100644 index 0000000000..1f9c77f6c0 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/mvc/MvcWebEndpointIntegrationTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-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.boot.endpoint.web.mvc; + +import java.util.Arrays; + +import org.junit.Test; + +import org.springframework.boot.endpoint.web.AbstractWebEndpointIntegrationTests; +import org.springframework.boot.endpoint.web.WebAnnotationEndpointDiscoverer; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * Integration tests for web endpoints exposed using Spring MVC. + * + * @author Andy Wilkinson + */ +public class MvcWebEndpointIntegrationTests extends + AbstractWebEndpointIntegrationTests { + + public MvcWebEndpointIntegrationTests() { + super(WebMvcConfiguration.class); + } + + @Test + public void responseToOptionsRequestIncludesCorsHeaders() { + load(TestEndpointConfiguration.class, + (client) -> client.options().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .header("Access-Control-Request-Method", "POST") + .header("Origin", "http://example.com").exchange().expectStatus() + .isOk().expectHeader() + .valueEquals("Access-Control-Allow-Origin", "http://example.com") + .expectHeader() + .valueEquals("Access-Control-Allow-Methods", "GET,POST")); + } + + @Override + protected AnnotationConfigServletWebServerApplicationContext createApplicationContext( + Class... config) { + return new AnnotationConfigServletWebServerApplicationContext(config); + } + + @Override + protected int getPort(AnnotationConfigServletWebServerApplicationContext context) { + return context.getWebServer().getPort(); + } + + @Configuration + @EnableWebMvc + static class WebMvcConfiguration { + + @Bean + public TomcatServletWebServerFactory tomcat() { + return new TomcatServletWebServerFactory(0); + } + + @Bean + public DispatcherServlet dispatcherServlet() { + return new DispatcherServlet(); + } + + @Bean + public WebEndpointServletHandlerMapping webEndpointHandlerMapping( + WebAnnotationEndpointDiscoverer webEndpointDiscoverer) { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowedOrigins(Arrays.asList("http://example.com")); + corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST")); + return new WebEndpointServletHandlerMapping("/endpoints", + webEndpointDiscoverer.discoverEndpoints(), corsConfiguration); + } + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/reactive/ReactiveWebEndpointIntegrationTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/reactive/ReactiveWebEndpointIntegrationTests.java new file mode 100644 index 0000000000..2335671dae --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/reactive/ReactiveWebEndpointIntegrationTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2012-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.boot.endpoint.web.reactive; + +import java.util.Arrays; + +import org.junit.Test; + +import org.springframework.boot.endpoint.web.AbstractWebEndpointIntegrationTests; +import org.springframework.boot.endpoint.web.WebAnnotationEndpointDiscoverer; +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; +import org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext; +import org.springframework.boot.web.reactive.context.ReactiveWebServerInitializedEvent; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +/** + * Integration tests for web endpoints exposed using WebFlux. + * + * @author Andy Wilkinson + */ +public class ReactiveWebEndpointIntegrationTests + extends AbstractWebEndpointIntegrationTests { + + public ReactiveWebEndpointIntegrationTests() { + super(ReactiveConfiguration.class); + } + + @Test + public void responseToOptionsRequestIncludesCorsHeaders() { + load(TestEndpointConfiguration.class, + (client) -> client.options().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .header("Access-Control-Request-Method", "POST") + .header("Origin", "http://example.com").exchange().expectStatus() + .isOk().expectHeader() + .valueEquals("Access-Control-Allow-Origin", "http://example.com") + .expectHeader() + .valueEquals("Access-Control-Allow-Methods", "GET,POST")); + } + + @Override + protected ReactiveWebServerApplicationContext createApplicationContext( + Class... config) { + return new ReactiveWebServerApplicationContext(config); + } + + @Override + protected int getPort(ReactiveWebServerApplicationContext context) { + return context.getBean(ReactiveConfiguration.class).port; + } + + @Configuration + @EnableWebFlux + static class ReactiveConfiguration { + + private int port; + + @Bean + public NettyReactiveWebServerFactory netty() { + return new NettyReactiveWebServerFactory(0); + } + + @Bean + public HttpHandler httpHandler(ApplicationContext applicationContext) { + return WebHttpHandlerBuilder.applicationContext(applicationContext).build(); + } + + @Bean + public WebEndpointReactiveHandlerMapping webEndpointHandlerMapping( + WebAnnotationEndpointDiscoverer endpointDiscoverer) { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowedOrigins(Arrays.asList("http://example.com")); + corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST")); + return new WebEndpointReactiveHandlerMapping("endpoints", + endpointDiscoverer.discoverEndpoints(), corsConfiguration); + } + + @Bean + public ApplicationListener serverInitializedListener() { + return (event) -> this.port = event.getWebServer().getPort(); + } + + } + +}