diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/pom.xml b/spring-boot-project/spring-boot-actuator-autoconfigure/pom.xml
index 4a977c5d43..ab8b9bb38f 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/pom.xml
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/pom.xml
@@ -348,6 +348,16 @@
logback-classic
test
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+ com.squareup.okhttp3
+ mockwebserver
+ test
+
com.jayway.jsonpath
json-path
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AccessLevel.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AccessLevel.java
index 3f0d16a336..a7e5efbb4f 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AccessLevel.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AccessLevel.java
@@ -19,15 +19,13 @@ package org.springframework.boot.actuate.autoconfigure.cloudfoundry;
import java.util.Arrays;
import java.util.List;
-import javax.servlet.http.HttpServletRequest;
-
/**
* The specific access level granted to the cloud foundry user that's calling the
* endpoints.
*
* @author Madhura Bhave
*/
-enum AccessLevel {
+public enum AccessLevel {
/**
* Restricted access to a limited set of endpoints.
@@ -39,7 +37,7 @@ enum AccessLevel {
*/
FULL;
- private static final String REQUEST_ATTRIBUTE = "cloudFoundryAccessLevel";
+ public static final String REQUEST_ATTRIBUTE = "cloudFoundryAccessLevel";
private final List endpointPaths;
@@ -56,12 +54,4 @@ enum AccessLevel {
return this.endpointPaths.isEmpty() || this.endpointPaths.contains(endpointPath);
}
- public void put(HttpServletRequest request) {
- request.setAttribute(REQUEST_ATTRIBUTE, this);
- }
-
- public static AccessLevel get(HttpServletRequest request) {
- return (AccessLevel) request.getAttribute(REQUEST_ATTRIBUTE);
- }
-
}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryAuthorizationException.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryAuthorizationException.java
index c2ee514a69..5e419ee38f 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryAuthorizationException.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryAuthorizationException.java
@@ -23,15 +23,15 @@ import org.springframework.http.HttpStatus;
*
* @author Madhura Bhave
*/
-class CloudFoundryAuthorizationException extends RuntimeException {
+public class CloudFoundryAuthorizationException extends RuntimeException {
private final Reason reason;
- CloudFoundryAuthorizationException(Reason reason, String message) {
+ public CloudFoundryAuthorizationException(Reason reason, String message) {
this(reason, message, null);
}
- CloudFoundryAuthorizationException(Reason reason, String message, Throwable cause) {
+ public CloudFoundryAuthorizationException(Reason reason, String message, Throwable cause) {
super(message);
this.reason = reason;
}
@@ -55,7 +55,7 @@ class CloudFoundryAuthorizationException extends RuntimeException {
/**
* Reasons why the exception can be thrown.
*/
- enum Reason {
+ public enum Reason {
ACCESS_DENIED(HttpStatus.FORBIDDEN),
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SecurityResponse.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SecurityResponse.java
new file mode 100644
index 0000000000..d484be2d62
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SecurityResponse.java
@@ -0,0 +1,53 @@
+/*
+ * 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.actuate.autoconfigure.cloudfoundry;
+
+import org.springframework.http.HttpStatus;
+
+/**
+ * Response from the Cloud Foundry security interceptors.
+ *
+ * @author Madhura Bhave
+ */
+public class SecurityResponse {
+
+ private final HttpStatus status;
+
+ private final String message;
+
+ public SecurityResponse(HttpStatus status) {
+ this(status, null);
+ }
+
+ public SecurityResponse(HttpStatus status, String message) {
+ this.status = status;
+ this.message = message;
+ }
+
+ public HttpStatus getStatus() {
+ return this.status;
+ }
+
+ public String getMessage() {
+ return this.message;
+ }
+
+ public static SecurityResponse success() {
+ return new SecurityResponse(HttpStatus.OK);
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/Token.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/Token.java
index cc6b52961c..11d21b1ab2 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/Token.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/Token.java
@@ -20,6 +20,7 @@ import java.nio.charset.Charset;
import java.util.List;
import java.util.Map;
+import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.json.JsonParserFactory;
import org.springframework.util.Base64Utils;
import org.springframework.util.StringUtils;
@@ -29,7 +30,7 @@ import org.springframework.util.StringUtils;
*
* @author Madhura Bhave
*/
-class Token {
+public class Token {
private static final Charset UTF_8 = Charset.forName("UTF-8");
@@ -41,13 +42,13 @@ class Token {
private final Map claims;
- Token(String encoded) {
+ public Token(String encoded) {
this.encoded = encoded;
int firstPeriod = encoded.indexOf('.');
int lastPeriod = encoded.lastIndexOf('.');
if (firstPeriod <= 0 || lastPeriod <= firstPeriod) {
throw new CloudFoundryAuthorizationException(
- CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
+ Reason.INVALID_TOKEN,
"JWT must have header, body and signature");
}
this.header = parseJson(encoded.substring(0, firstPeriod));
@@ -55,7 +56,7 @@ class Token {
this.signature = encoded.substring(lastPeriod + 1);
if (!StringUtils.hasLength(this.signature)) {
throw new CloudFoundryAuthorizationException(
- CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
+ Reason.INVALID_TOKEN,
"Token must have non-empty crypto segment");
}
}
@@ -67,7 +68,7 @@ class Token {
}
catch (RuntimeException ex) {
throw new CloudFoundryAuthorizationException(
- CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
+ Reason.INVALID_TOKEN,
"Token could not be parsed", ex);
}
}
@@ -106,12 +107,12 @@ class Token {
Object value = map.get(key);
if (value == null) {
throw new CloudFoundryAuthorizationException(
- CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
+ Reason.INVALID_TOKEN,
"Unable to get value from key " + key);
}
if (!type.isInstance(value)) {
throw new CloudFoundryAuthorizationException(
- CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
+ Reason.INVALID_TOKEN,
"Unexpected value type from key " + key + " value " + value);
}
return (T) value;
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java
new file mode 100644
index 0000000000..17189f8f39
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java
@@ -0,0 +1,233 @@
+/*
+ * 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.actuate.autoconfigure.cloudfoundry.reactive;
+
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.reactivestreams.Publisher;
+import reactor.core.publisher.Mono;
+
+import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel;
+import org.springframework.boot.actuate.endpoint.EndpointInfo;
+import org.springframework.boot.actuate.endpoint.OperationInvoker;
+import org.springframework.boot.actuate.endpoint.OperationType;
+import org.springframework.boot.actuate.endpoint.ParameterMappingException;
+import org.springframework.boot.actuate.endpoint.ParametersMissingException;
+import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
+import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
+import org.springframework.boot.actuate.endpoint.web.Link;
+import org.springframework.boot.actuate.endpoint.web.WebEndpointOperation;
+import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
+import org.springframework.boot.actuate.endpoint.web.reactive.AbstractWebFluxEndpointHandlerMapping;
+import org.springframework.boot.endpoint.web.EndpointMapping;
+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.ResponseBody;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.reactive.HandlerMapping;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;
+
+/**
+ * A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available on
+ * Cloud Foundry specific URLs over HTTP using Spring WebFlux.
+ *
+ * @author Madhura Bhave
+ */
+public class CloudFoundryWebFluxEndpointHandlerMapping extends AbstractWebFluxEndpointHandlerMapping {
+
+ 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",
+ ServerWebExchange.class);
+
+ private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver();
+
+ private final ReactiveCloudFoundrySecurityInterceptor securityInterceptor;
+
+ @Override
+ protected Method getLinks() {
+ return this.links;
+ }
+
+ @Override
+ protected void registerMappingForOperation(WebEndpointOperation operation) {
+ OperationType operationType = operation.getType();
+ OperationInvoker operationInvoker = operation.getInvoker();
+ if (operation.isBlocking()) {
+ operationInvoker = new ElasticSchedulerOperationInvoker(operationInvoker);
+ }
+ registerMapping(createRequestMappingInfo(operation),
+ operationType == OperationType.WRITE
+ ? new WriteOperationHandler(operationInvoker, operation.getId())
+ : new ReadOperationHandler(operationInvoker, operation.getId()),
+ operationType == OperationType.WRITE ? this.handleWrite
+ : this.handleRead);
+ }
+
+ @ResponseBody
+ private Publisher> links(ServerWebExchange exchange) {
+ ServerHttpRequest request = exchange.getRequest();
+ return this.securityInterceptor
+ .preHandle(exchange, "")
+ .map(securityResponse -> {
+ if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
+ return new ResponseEntity<>(securityResponse.getStatus());
+ }
+ AccessLevel accessLevel = exchange.getAttribute(AccessLevel.REQUEST_ATTRIBUTE);
+ Map links = this.endpointLinksResolver.resolveLinks(getEndpoints(),
+ request.getURI().toString());
+ return new ResponseEntity<>(Collections.singletonMap("_links",
+ getAccessibleLinks(accessLevel, links)), HttpStatus.OK);
+ });
+ }
+
+ private Map getAccessibleLinks(AccessLevel accessLevel, Map links) {
+ if (accessLevel == null) {
+ return new LinkedHashMap<>();
+ }
+ return links.entrySet().stream()
+ .filter((e) -> e.getKey().equals("self")
+ || accessLevel.isAccessAllowed(e.getKey()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ /**
+ * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the
+ * operations of the given {@code webEndpoints}.
+ * @param endpointMapping the base mapping for all endpoints
+ * @param webEndpoints the web endpoints
+ * @param endpointMediaTypes media types consumed and produced by the endpoints
+ * @param corsConfiguration the CORS configuration for the endpoints
+ * @param securityInterceptor the Security Interceptor
+ */
+ public CloudFoundryWebFluxEndpointHandlerMapping(EndpointMapping endpointMapping,
+ Collection> webEndpoints,
+ EndpointMediaTypes endpointMediaTypes, CorsConfiguration corsConfiguration,
+ ReactiveCloudFoundrySecurityInterceptor securityInterceptor) {
+ super(endpointMapping, webEndpoints, endpointMediaTypes, corsConfiguration);
+ this.securityInterceptor = securityInterceptor;
+ }
+
+ /**
+ * Base class for handlers for endpoint operations.
+ */
+ abstract class AbstractOperationHandler {
+
+ private final OperationInvoker operationInvoker;
+
+ private final String endpointId;
+
+ private final ReactiveCloudFoundrySecurityInterceptor securityInterceptor;
+
+ AbstractOperationHandler(OperationInvoker operationInvoker, String endpointId, ReactiveCloudFoundrySecurityInterceptor securityInterceptor) {
+ this.operationInvoker = operationInvoker;
+ this.endpointId = endpointId;
+ this.securityInterceptor = securityInterceptor;
+ }
+
+ @SuppressWarnings({ "unchecked" })
+ Publisher> doHandle(ServerWebExchange exchange,
+ Map body) {
+ return this.securityInterceptor
+ .preHandle(exchange, this.endpointId)
+ .flatMap(securityResponse -> {
+ if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
+ return Mono.just(new ResponseEntity<>(securityResponse.getStatus()));
+ }
+ Map arguments = new HashMap<>(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));
+ return handleResult((Publisher>) this.operationInvoker.invoke(arguments),
+ exchange.getRequest().getMethod());
+ });
+ }
+
+ private Mono> handleResult(Publisher> result,
+ HttpMethod httpMethod) {
+ return Mono.from(result).map(this::toResponseEntity)
+ .onErrorReturn(ParametersMissingException.class,
+ new ResponseEntity<>(HttpStatus.BAD_REQUEST))
+ .onErrorReturn(ParameterMappingException.class,
+ new ResponseEntity<>(HttpStatus.BAD_REQUEST))
+ .defaultIfEmpty(new ResponseEntity<>(httpMethod == HttpMethod.GET
+ ? HttpStatus.NOT_FOUND : HttpStatus.NO_CONTENT));
+ }
+
+ private ResponseEntity