Add support for making endpoints accessible via HTTP

This commit adds support for exposing endpoint operations over HTTP.
Jersey, Spring MVC, and WebFlux are all supported but the programming
model remains web framework agnostic. When using WebFlux, blocking
operations are automatically performed on a separate thread using
Reactor's scheduler support. Support for web-specific extensions is
provided via a new `@WebEndpointExtension` annotation.

Closes gh-7970
Closes gh-9946
Closes gh-9947
pull/9938/merge
Andy Wilkinson 7 years ago
parent 4592e071db
commit 9687a5041e

@ -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

@ -29,6 +29,22 @@
</subpackage>
</subpackage>
<!-- Endpoint infrastructure -->
<subpackage name="endpoint">
<disallow pkg="org.springframework.http" />
<disallow pkg="org.springframework.web" />
<subpackage name="web">
<allow pkg="org.springframework.http" />
<allow pkg="org.springframework.web" />
<subpackage name="mvc">
<disallow pkg="org.springframework.web.reactive" />
</subpackage>
<subpackage name="reactive">
<disallow pkg="org.springframework.web.servlet" />
</subpackage>
</subpackage>
</subpackage>
<!-- Logging -->
<subpackage name="logging">
<disallow pkg="org.springframework.context" />

@ -159,6 +159,11 @@
<artifactId>jetty-webapp</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-server</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-library</artifactId>
@ -271,6 +276,11 @@
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
@ -316,6 +326,16 @@
<artifactId>jaybird-jdk18</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.containers</groupId>
<artifactId>jersey-container-servlet-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>

@ -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<String, Link> resolveLinks(
Collection<EndpointInfo<WebEndpointOperation>> webEndpoints,
String requestUrl) {
String normalizedUrl = normalizeRequestUrl(requestUrl);
Map<String, Link> links = new LinkedHashMap<String, Link>();
links.put("self", new Link(normalizedUrl));
for (EndpointInfo<WebEndpointOperation> 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));
}
}

@ -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
* <a href="https://tools.ietf.org/html/draft-kelly-json-hal-08">HAL</a>-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();
}
}

@ -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<String> consumes;
private final Collection<String> 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<String> consumes, Collection<String> 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<String> getConsumes() {
return Collections.unmodifiableCollection(this.consumes);
}
/**
* Returns the media types that the operation produces.
* @return the produced media types
*/
public Collection<String> 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;
}
}

@ -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<WebEndpointOperation, OperationRequestPredicate> {
/**
* 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<String, CachingConfiguration> cachingConfigurationFactory,
Collection<String> consumedMediaTypes,
Collection<String> producedMediaTypes) {
super(applicationContext,
new WebEndpointOperationFactory(operationParameterMapper,
consumedMediaTypes, producedMediaTypes),
WebEndpointOperation::getRequestPredicate, cachingConfigurationFactory);
}
@Override
public Collection<EndpointInfo<WebEndpointOperation>> discoverEndpoints() {
Collection<EndpointInfoDescriptor<WebEndpointOperation, OperationRequestPredicate>> endpoints = discoverEndpointsWithExtension(
WebEndpointExtension.class, EndpointType.WEB);
verifyThatOperationsHaveDistinctPredicates(endpoints);
return endpoints.stream().map(EndpointInfoDescriptor::getEndpointInfo)
.collect(Collectors.toList());
}
private void verifyThatOperationsHaveDistinctPredicates(
Collection<EndpointInfoDescriptor<WebEndpointOperation, OperationRequestPredicate>> endpointDescriptors) {
List<List<WebEndpointOperation>> 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<WebEndpointOperation> {
private static final boolean REACTIVE_STREAMS_PRESENT = ClassUtils.isPresent(
"org.reactivestreams.Publisher",
WebEndpointOperationFactory.class.getClassLoader());
private final OperationParameterMapper parameterMapper;
private final Collection<String> consumedMediaTypes;
private final Collection<String> producedMediaTypes;
private WebEndpointOperationFactory(OperationParameterMapper parameterMapper,
Collection<String> consumedMediaTypes,
Collection<String> 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<String> determineConsumedMediaTypes(
WebEndpointHttpMethod httpMethod, Method method) {
if (WebEndpointHttpMethod.POST == httpMethod && consumesRequestBody(method)) {
return this.consumedMediaTypes;
}
return Collections.emptyList();
}
private Collection<String> 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());
}
}
}

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

@ -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
}

@ -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;
}
}

@ -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 <T> the type of the response body
* @author Stephane Nicoll
* @author Andy Wilkinson
* @since 2.0.0
*/
public final class WebEndpointResponse<T> {
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;
}
}

@ -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<Resource> createEndpointResources(String endpointPath,
Collection<EndpointInfo<WebEndpointOperation>> webEndpoints) {
List<Resource> 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<String> collection) {
return collection.toArray(new String[collection.size()]);
}
private Resource createEndpointLinksResource(String endpointPath,
Collection<EndpointInfo<WebEndpointOperation>> 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<ContainerRequestContext, Object> {
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<String, Object> arguments = new HashMap<>();
if (this.readBody) {
Map<String, Object> 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<String, Object> extractPathParmeters(
ContainerRequestContext requestContext) {
return extract(requestContext.getUriInfo().getPathParameters());
}
private Map<String, Object> extractQueryParmeters(
ContainerRequestContext requestContext) {
return extract(requestContext.getUriInfo().getQueryParameters());
}
private Map<String, Object> extract(
MultivaluedMap<String, String> multivaluedMap) {
Map<String, Object> 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<ContainerRequestContext, Response> {
private final Collection<EndpointInfo<WebEndpointOperation>> endpoints;
private final EndpointLinksResolver linksResolver;
private EndpointLinksInflector(
Collection<EndpointInfo<WebEndpointOperation>> endpoints,
EndpointLinksResolver linksResolver) {
this.endpoints = endpoints;
this.linksResolver = linksResolver;
}
@Override
public Response apply(ContainerRequestContext request) {
Map<String, Link> links = this.linksResolver.resolveLinks(this.endpoints,
request.getUriInfo().getAbsolutePath().toString());
return Response.ok(Collections.singletonMap("_links", links)).build();
}
}
}

@ -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;

@ -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<EndpointInfo<WebEndpointOperation>> 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<EndpointInfo<WebEndpointOperation>> 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<EndpointInfo<WebEndpointOperation>> 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<String> 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<Object> interceptors) {
interceptors.add(new SkipPathExtensionContentNegotiation());
}
@ResponseBody
private Map<String, Map<String, Link>> 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<String, String> body) {
Map<String, Object> arguments = new HashMap<>((Map<String, String>) 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<Void>(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<Object>(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;
}
}
}

@ -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;

@ -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;

@ -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<EndpointInfo<WebEndpointOperation>> 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<EndpointInfo<WebEndpointOperation>> 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<EndpointInfo<WebEndpointOperation>> 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<String> 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<String, Map<String, Link>> 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<String, String> body) {
Map<String, Object> arguments = new HashMap<>((Map<String, String>) 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<Void>(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<Object>(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<String, String> 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);
}
}
}

@ -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;

@ -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 <T> the type of application context used by the tests
* @author Andy Wilkinson
*/
public abstract class AbstractWebEndpointIntegrationTests<T extends ConfigurableApplicationContext> {
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<String, Object> 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<String, Object> 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<ApplicationContext, WebTestClient> 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<WebTestClient> 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<String, Object> readAll() {
return Collections.singletonMap("All", true);
}
@ReadOperation
public Map<String, Object> 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<String, String> query(String one, Integer two) {
return Collections.singletonMap("query", one + " " + two);
}
@ReadOperation
public Map<String, String> queryWithParameterList(@Selector String list,
String one, List<String> two) {
return Collections.singletonMap("query", list + " " + one + " " + two);
}
}
@Endpoint(id = "query")
static class QueryWithListEndpoint {
@ReadOperation
public Map<String, String> queryWithParameterList(String one, List<String> 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<Resource> read() {
return new WebEndpointResponse<Resource>(
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);
}
}

@ -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<String, Link> 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<String, Link> 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<String, Link> 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<Link> linkWithHref(String href) {
return new Condition<>((link) -> href.equals(link.getHref()),
"Link with href '%s'", href);
}
}

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

@ -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<String, EndpointInfo<WebEndpointOperation>> endpoints = mapEndpoints(
discoverer.discoverEndpoints());
assertThat(endpoints).containsOnlyKeys("test");
});
}
@Test
public void oneOperationIsDiscoveredWhenExtensionOverridesOperation() {
load(OverriddenOperationWebEndpointExtensionConfiguration.class, (discoverer) -> {
Map<String, EndpointInfo<WebEndpointOperation>> endpoints = mapEndpoints(
discoverer.discoverEndpoints());
assertThat(endpoints).containsOnlyKeys("test");
EndpointInfo<WebEndpointOperation> 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<String, EndpointInfo<WebEndpointOperation>> endpoints = mapEndpoints(
discoverer.discoverEndpoints());
assertThat(endpoints).containsOnlyKeys("test");
EndpointInfo<WebEndpointOperation> 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<String, EndpointInfo<WebEndpointOperation>> endpoints = mapEndpoints(
discoverer.discoverEndpoints());
assertThat(endpoints).containsOnlyKeys("voidwrite");
EndpointInfo<WebEndpointOperation> 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<String, EndpointInfo<WebEndpointOperation>> endpoints = mapEndpoints(
discoverer.discoverEndpoints());
assertThat(endpoints).containsOnlyKeys("test");
EndpointInfo<WebEndpointOperation> 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<String, EndpointInfo<WebEndpointOperation>> endpoints = mapEndpoints(
discoverer.discoverEndpoints());
assertThat(endpoints).containsOnlyKeys("resource");
EndpointInfo<WebEndpointOperation> 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<WebAnnotationEndpointDiscoverer> consumer) {
this.load((id) -> null, configuration, consumer);
}
private void load(Function<String, CachingConfiguration> cachingConfigurationFactory,
Class<?> configuration, Consumer<WebAnnotationEndpointDiscoverer> 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<String, EndpointInfo<WebEndpointOperation>> mapEndpoints(
Collection<EndpointInfo<WebEndpointOperation>> endpoints) {
Map<String, EndpointInfo<WebEndpointOperation>> endpointById = new HashMap<>();
endpoints.forEach((endpoint) -> endpointById.put(endpoint.getId(), endpoint));
return endpointById;
}
private List<OperationRequestPredicate> requestPredicates(
EndpointInfo<WebEndpointOperation> endpoint) {
return endpoint.getOperations().stream()
.map(WebEndpointOperation::getRequestPredicate)
.collect(Collectors.toList());
}
private Condition<List<? extends OperationRequestPredicate>> requestPredicates(
RequestPredicateMatcher... matchers) {
return new Condition<>((predicates) -> {
if (predicates.size() != matchers.length) {
return false;
}
Map<OperationRequestPredicate, Long> 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<String> produces;
private List<String> 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 + "'";
}
}
}

@ -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<AnnotationConfigServletWebServerApplicationContext> {
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> servletContainer(
ResourceConfig resourceConfig) {
return new ServletRegistrationBean<ServletContainer>(
new ServletContainer(resourceConfig), "/*");
}
@Bean
public ResourceConfig resourceConfig(
WebAnnotationEndpointDiscoverer endpointDiscoverer) {
ResourceConfig resourceConfig = new ResourceConfig();
Collection<Resource> resources = new JerseyEndpointResourceFactory()
.createEndpointResources("endpoints",
endpointDiscoverer.discoverEndpoints());
resourceConfig.registerResources(new HashSet<Resource>(resources));
resourceConfig.register(JacksonFeature.class);
resourceConfig.register(new ObjectMapperContextResolver(new ObjectMapper()),
ContextResolver.class);
return resourceConfig;
}
}
private static final class ObjectMapperContextResolver
implements ContextResolver<ObjectMapper> {
private final ObjectMapper objectMapper;
private ObjectMapperContextResolver(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public ObjectMapper getContext(Class<?> type) {
return this.objectMapper;
}
}
}

@ -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<AnnotationConfigServletWebServerApplicationContext> {
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);
}
}
}

@ -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<ReactiveWebServerApplicationContext> {
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<ReactiveWebServerInitializedEvent> serverInitializedListener() {
return (event) -> this.port = event.getWebServer().getPort();
}
}
}
Loading…
Cancel
Save