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(); + } + + } + +}