Commit c81a0223 authored by Phillip Webb's avatar Phillip Webb

Refine 'Allow operations to produce different output'

Refine the new `Producible` support so that it can also be used with
`@ReadOperation`, `@WriteOperation` and `@DeleteOperation` annotations.

This update allows the same enum to be used both as an argument and as
an indicator of the media-types that an operation may produce.

Closes gh-25738
parent 1ec49cee
......@@ -21,7 +21,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker;
......@@ -58,11 +57,11 @@ public class InvocationContext {
* @param arguments the arguments available to the operation. Never {@code null}
* @since 2.2.0
* @deprecated since 2.5.0 in favor of
* {@link #InvocationContext(SecurityContext, Map, List)}
* {@link #InvocationContext(SecurityContext, Map, OperationArgumentResolver[])}
*/
@Deprecated
public InvocationContext(ApiVersion apiVersion, SecurityContext securityContext, Map<String, Object> arguments) {
this(securityContext, arguments, Arrays.asList(new FixedValueArgumentResolver<>(ApiVersion.class, apiVersion)));
this(securityContext, arguments, OperationArgumentResolver.of(ApiVersion.class, () -> apiVersion));
}
/**
......@@ -74,17 +73,17 @@ public class InvocationContext {
* the operation.
*/
public InvocationContext(SecurityContext securityContext, Map<String, Object> arguments,
List<OperationArgumentResolver> argumentResolvers) {
OperationArgumentResolver... argumentResolvers) {
Assert.notNull(securityContext, "SecurityContext must not be null");
Assert.notNull(arguments, "Arguments must not be null");
this.arguments = arguments;
this.argumentResolvers = new ArrayList<>();
if (argumentResolvers != null) {
this.argumentResolvers.addAll(argumentResolvers);
this.argumentResolvers.addAll(Arrays.asList(argumentResolvers));
}
this.argumentResolvers.add(new FixedValueArgumentResolver<>(SecurityContext.class, securityContext));
this.argumentResolvers.add(new SuppliedValueArgumentResolver<>(Principal.class, securityContext::getPrincipal));
this.argumentResolvers.add(new FixedValueArgumentResolver<>(ApiVersion.class, ApiVersion.LATEST));
this.argumentResolvers.add(OperationArgumentResolver.of(SecurityContext.class, () -> securityContext));
this.argumentResolvers.add(OperationArgumentResolver.of(Principal.class, securityContext::getPrincipal));
this.argumentResolvers.add(OperationArgumentResolver.of(ApiVersion.class, () -> ApiVersion.LATEST));
}
/**
......@@ -154,52 +153,4 @@ public class InvocationContext {
return false;
}
private static final class FixedValueArgumentResolver<T> implements OperationArgumentResolver {
private final Class<T> argumentType;
private final T value;
private FixedValueArgumentResolver(Class<T> argumentType, T value) {
this.argumentType = argumentType;
this.value = value;
}
@SuppressWarnings("unchecked")
@Override
public <U> U resolve(Class<U> type) {
return (U) this.value;
}
@Override
public boolean canResolve(Class<?> type) {
return this.argumentType.equals(type);
}
}
private static final class SuppliedValueArgumentResolver<T> implements OperationArgumentResolver {
private final Class<T> argumentType;
private final Supplier<T> value;
private SuppliedValueArgumentResolver(Class<T> argumentType, Supplier<T> value) {
this.argumentType = argumentType;
this.value = value;
}
@SuppressWarnings("unchecked")
@Override
public <U> U resolve(Class<U> type) {
return (U) this.value.get();
}
@Override
public boolean canResolve(Class<?> type) {
return this.argumentType.equals(type);
}
}
}
......@@ -16,6 +16,10 @@
package org.springframework.boot.actuate.endpoint;
import java.util.function.Supplier;
import org.springframework.util.Assert;
/**
* Resolver for an argument of an {@link Operation}.
*
......@@ -24,6 +28,14 @@ package org.springframework.boot.actuate.endpoint;
*/
public interface OperationArgumentResolver {
/**
* Return whether an argument of the given {@code type} can be resolved.
* @param type argument type
* @return {@code true} if an argument of the required type can be resolved, otherwise
* {@code false}
*/
boolean canResolve(Class<?> type);
/**
* Resolves an argument of the given {@code type}.
* @param <T> required type of the argument
......@@ -33,11 +45,30 @@ public interface OperationArgumentResolver {
<T> T resolve(Class<T> type);
/**
* Return whether an argument of the given {@code type} can be resolved.
* @param type argument type
* @return {@code true} if an argument of the required type can be resolved, otherwise
* {@code false}
* Factory method that creates an {@link OperationArgumentResolver} for a specific
* type using a {@link Supplier}.
* @param <T> the resolvable type
* @param type the resolvable type
* @param supplier the value supplier
* @return an {@link OperationArgumentResolver} instance
*/
boolean canResolve(Class<?> type);
static <T> OperationArgumentResolver of(Class<T> type, Supplier<? extends T> supplier) {
Assert.notNull(type, "Type must not be null");
Assert.notNull(supplier, "Supplier must not be null");
return new OperationArgumentResolver() {
@Override
public boolean canResolve(Class<?> actualType) {
return actualType.equals(type);
}
@Override
@SuppressWarnings("unchecked")
public <R> R resolve(Class<R> argumentType) {
return (R) supplier.get();
}
};
}
}
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 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.
......@@ -40,4 +40,11 @@ public @interface DeleteOperation {
*/
String[] produces() default {};
/**
* The media types of the result of the operation.
* @return the media types
*/
@SuppressWarnings("rawtypes")
Class<? extends Producible> producesFrom() default Producible.class;
}
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 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.
......@@ -17,6 +17,7 @@
package org.springframework.boot.actuate.endpoint.annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
......@@ -40,8 +41,32 @@ public class DiscoveredOperationMethod extends OperationMethod {
AnnotationAttributes annotationAttributes) {
super(method, operationType);
Assert.notNull(annotationAttributes, "AnnotationAttributes must not be null");
String[] produces = annotationAttributes.getStringArray("produces");
this.producesMediaTypes = Collections.unmodifiableList(Arrays.asList(produces));
List<String> producesMediaTypes = new ArrayList<>();
producesMediaTypes.addAll(Arrays.asList(annotationAttributes.getStringArray("produces")));
producesMediaTypes.addAll(getProducesFromProducable(annotationAttributes));
this.producesMediaTypes = Collections.unmodifiableList(producesMediaTypes);
}
private <E extends Enum<E> & Producible<E>> List<String> getProducesFromProducable(
AnnotationAttributes annotationAttributes) {
Class<?> type = getProducesFrom(annotationAttributes);
if (type == Producible.class) {
return Collections.emptyList();
}
List<String> produces = new ArrayList<>();
for (Object value : type.getEnumConstants()) {
produces.add(((Producible<?>) value).getProducedMimeType().toString());
}
return produces;
}
private Class<?> getProducesFrom(AnnotationAttributes annotationAttributes) {
try {
return annotationAttributes.getClass("producesFrom");
}
catch (IllegalArgumentException ex) {
return Producible.class;
}
}
public List<String> getProducesMediaTypes() {
......
......@@ -14,15 +14,23 @@
* limitations under the License.
*/
package org.springframework.boot.actuate.endpoint.http;
package org.springframework.boot.actuate.endpoint.annotation;
import org.springframework.util.MimeType;
/**
* Interface to be implemented by an {@link Enum} that can be injected into an operation
* on a web endpoint. The value of the {@code Producible} enum is resolved using the
* {@code Accept} header of the request. When multiple values are equally acceptable, the
* value with the highest {@link Enum#ordinal() ordinal} is used.
* Interface that can be implemented by any {@link Enum} that represents a finite set of
* producible mime-types.
* <p>
* Can be used with {@link ReadOperation @ReadOperation},
* {@link WriteOperation @ReadOperation} and {@link DeleteOperation @ReadOperation}
* annotations to quickly define a list of {@code produces} values.
* <p>
* {@link Producible} types can also be injected into operations when the underlying
* technology supports content negotiation. For example, with web based endpoints, the
* value of the {@code Producible} enum is resolved using the {@code Accept} header of the
* request. When multiple values are equally acceptable, the value with the highest
* {@link Enum#ordinal() ordinal} is used.
*
* @param <E> enum type that implements this interface
* @author Andy Wilkinson
......@@ -34,6 +42,6 @@ public interface Producible<E extends Enum<E> & Producible<E>> {
* Mime type that can be produced.
* @return the producible mime type
*/
MimeType getMimeType();
MimeType getProducedMimeType();
}
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 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.
......@@ -39,4 +39,11 @@ public @interface ReadOperation {
*/
String[] produces() default {};
/**
* The media types of the result of the operation.
* @return the media types
*/
@SuppressWarnings("rawtypes")
Class<? extends Producible> producesFrom() default Producible.class;
}
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 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.
......@@ -39,4 +39,11 @@ public @interface WriteOperation {
*/
String[] produces() default {};
/**
* The media types of the result of the operation.
* @return the media types
*/
@SuppressWarnings("rawtypes")
Class<? extends Producible> producesFrom() default Producible.class;
}
......@@ -19,6 +19,7 @@ package org.springframework.boot.actuate.endpoint.http;
import java.util.List;
import java.util.Map;
import org.springframework.boot.actuate.endpoint.annotation.Producible;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
......@@ -49,12 +50,26 @@ public enum ApiVersion implements Producible<ApiVersion> {
*/
public static final ApiVersion LATEST = ApiVersion.V3;
private final MimeType mimeType;
ApiVersion(String mimeType) {
this.mimeType = MimeTypeUtils.parseMimeType(mimeType);
}
@Override
public MimeType getProducedMimeType() {
return this.mimeType;
}
/**
* Return the {@link ApiVersion} to use based on the HTTP request headers. The version
* will be deduced based on the {@code Accept} header.
* @param headers the HTTP headers
* @return the API version to use
* @deprecated since 2.5.0 in favor of direct injection with resolution via the
* {@link ProducibleOperationArgumentResolver}.
*/
@Deprecated
public static ApiVersion fromHttpHeaders(Map<String, List<String>> headers) {
ApiVersion version = null;
List<String> accepts = headers.get("Accept");
......@@ -88,15 +103,4 @@ public enum ApiVersion implements Producible<ApiVersion> {
return (candidateOrdinal > existingOrdinal) ? candidate : existing;
}
private final MimeType mimeType;
ApiVersion(String mimeType) {
this.mimeType = MimeTypeUtils.parseMimeType(mimeType);
}
@Override
public MimeType getMimeType() {
return this.mimeType;
}
}
......@@ -22,6 +22,7 @@ import java.util.List;
import java.util.Map;
import org.springframework.boot.actuate.endpoint.OperationArgumentResolver;
import org.springframework.boot.actuate.endpoint.annotation.Producible;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
......@@ -34,10 +35,10 @@ import org.springframework.util.MimeTypeUtils;
*/
public class ProducibleOperationArgumentResolver implements OperationArgumentResolver {
private final Map<String, List<String>> httpHeaders;
private final Map<String, List<String>> headers;
public ProducibleOperationArgumentResolver(Map<String, List<String>> httpHeaders) {
this.httpHeaders = httpHeaders;
public ProducibleOperationArgumentResolver(Map<String, List<String>> headers) {
this.headers = headers;
}
@Override
......@@ -52,7 +53,7 @@ public class ProducibleOperationArgumentResolver implements OperationArgumentRes
}
private Enum<? extends Producible<?>> resolveProducible(Class<Enum<? extends Producible<?>>> type) {
List<String> accepts = this.httpHeaders.get("Accept");
List<String> accepts = this.headers.get("Accept");
List<Enum<? extends Producible<?>>> values = Arrays.asList(type.getEnumConstants());
Collections.reverse(values);
if (CollectionUtils.isEmpty(accepts)) {
......@@ -69,15 +70,15 @@ public class ProducibleOperationArgumentResolver implements OperationArgumentRes
private static Enum<? extends Producible<?>> mostRecent(Enum<? extends Producible<?>> existing,
Enum<? extends Producible<?>> candidate) {
int existingOrdinal = (existing != null) ? ((Enum<?>) existing).ordinal() : -1;
int candidateOrdinal = (candidate != null) ? ((Enum<?>) candidate).ordinal() : -1;
int existingOrdinal = (existing != null) ? existing.ordinal() : -1;
int candidateOrdinal = (candidate != null) ? candidate.ordinal() : -1;
return (candidateOrdinal > existingOrdinal) ? candidate : existing;
}
private static Enum<? extends Producible<?>> forType(List<Enum<? extends Producible<?>>> candidates,
MimeType mimeType) {
for (Enum<? extends Producible<?>> candidate : candidates) {
if (mimeType.isCompatibleWith(((Producible<?>) candidate).getMimeType())) {
if (mimeType.isCompatibleWith(((Producible<?>) candidate).getProducedMimeType())) {
return candidate;
}
}
......
......@@ -16,6 +16,7 @@
package org.springframework.boot.actuate.endpoint.web;
import org.springframework.boot.actuate.endpoint.annotation.Producible;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.util.MimeType;
......@@ -98,6 +99,17 @@ public final class WebEndpointResponse<T> {
this(body, STATUS_OK);
}
/**
* Creates a new {@code WebEndpointResponse} with the given body and content type and
* a 200 (OK) status.
* @param body the body
* @param producible the producible providing the content type
* @since 2.5.0
*/
public WebEndpointResponse(T body, Producible<?> producible) {
this(body, STATUS_OK, producible.getProducedMimeType());
}
/**
* Creates a new {@code WebEndpointResponse} with the given body and content type and
* a 200 (OK) status.
......
......@@ -21,7 +21,6 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
......@@ -154,7 +153,7 @@ public class JerseyEndpointResourceFactory {
try {
JerseySecurityContext securityContext = new JerseySecurityContext(data.getSecurityContext());
InvocationContext invocationContext = new InvocationContext(securityContext, arguments,
Arrays.asList(new ProducibleOperationArgumentResolver(data.getHeaders())));
new ProducibleOperationArgumentResolver(data.getHeaders()));
Object response = this.operation.invoke(invocationContext);
return convertToJaxRsResponse(response, data.getRequest().getMethod());
}
......
......@@ -19,7 +19,6 @@ package org.springframework.boot.actuate.endpoint.web.reactive;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
......@@ -308,7 +307,7 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
}
return this.securityContextSupplier.get()
.map((securityContext) -> new InvocationContext(securityContext, arguments,
Arrays.asList(new ProducibleOperationArgumentResolver(exchange.getRequest().getHeaders()))))
new ProducibleOperationArgumentResolver(exchange.getRequest().getHeaders())))
.flatMap((invocationContext) -> handleResult((Publisher<?>) this.invoker.invoke(invocationContext),
exchange.getRequest().getMethod()));
}
......
......@@ -287,7 +287,7 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
try {
ServletSecurityContext securityContext = new ServletSecurityContext(request);
InvocationContext invocationContext = new InvocationContext(securityContext, arguments,
Arrays.asList(new ProducibleOperationArgumentResolver(headers)));
new ProducibleOperationArgumentResolver(headers));
return handleResult(this.operation.invoke(invocationContext), HttpMethod.resolve(request.getMethod()));
}
catch (InvalidEndpointRequestException ex) {
......
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 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.
......@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.endpoint.OperationType;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.util.MimeType;
import org.springframework.util.ReflectionUtils;
import static org.assertj.core.api.Assertions.assertThat;
......@@ -48,12 +49,35 @@ class DiscoveredOperationMethodTests {
AnnotationAttributes annotationAttributes = new AnnotationAttributes();
String[] produces = new String[] { "application/json" };
annotationAttributes.put("produces", produces);
annotationAttributes.put("producesFrom", Producible.class);
DiscoveredOperationMethod discovered = new DiscoveredOperationMethod(method, OperationType.READ,
annotationAttributes);
assertThat(discovered.getProducesMediaTypes()).containsExactly("application/json");
}
@Test
void getProducesMediaTypesWhenProducesFromShouldReturnMediaTypes() {
Method method = ReflectionUtils.findMethod(getClass(), "example");
AnnotationAttributes annotationAttributes = new AnnotationAttributes();
annotationAttributes.put("produces", new String[0]);
annotationAttributes.put("producesFrom", ExampleProducible.class);
DiscoveredOperationMethod discovered = new DiscoveredOperationMethod(method, OperationType.READ,
annotationAttributes);
assertThat(discovered.getProducesMediaTypes()).containsExactly("one/*", "two/*", "three/*");
}
void example() {
}
enum ExampleProducible implements Producible<ExampleProducible> {
ONE, TWO, THREE;
@Override
public MimeType getProducedMimeType() {
return new MimeType(toString().toLowerCase());
}
}
}
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 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.
......@@ -34,6 +34,7 @@ import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.invoke.OperationParameters;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.invoke.reflect.OperationMethod;
import org.springframework.util.MimeType;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
......@@ -121,6 +122,14 @@ class DiscoveredOperationsFactoryTests {
assertThat(advisor.getParameters()).isEmpty();
}
@Test
void createOperationShouldApplyProducesFrom() {
TestOperation operation = getFirst(
this.factory.createOperations(EndpointId.of("test"), new ExampleWithProducesFrom()));
DiscoveredOperationMethod method = (DiscoveredOperationMethod) operation.getOperationMethod();
assertThat(method.getProducesMediaTypes()).containsExactly("one/*", "two/*", "three/*");
}
private <T> T getFirst(Iterable<T> iterable) {
return iterable.iterator().next();
}
......@@ -175,6 +184,15 @@ class DiscoveredOperationsFactoryTests {
}
static class ExampleWithProducesFrom {
@ReadOperation(producesFrom = ExampleProducible.class)
String read() {
return "read";
}
}
static class TestDiscoveredOperationsFactory extends DiscoveredOperationsFactory<TestOperation> {
TestDiscoveredOperationsFactory(ParameterValueMapper parameterValueMapper,
......@@ -229,4 +247,15 @@ class DiscoveredOperationsFactoryTests {
}
enum ExampleProducible implements Producible<ExampleProducible> {
ONE, TWO, THREE;
@Override
public MimeType getProducedMimeType() {
return new MimeType(toString().toLowerCase());
}
}
}
......@@ -39,24 +39,28 @@ class ApiVersionTests {
}
@Test
@Deprecated
void fromHttpHeadersWhenEmptyReturnsLatest() {
ApiVersion version = ApiVersion.fromHttpHeaders(Collections.emptyMap());
assertThat(version).isEqualTo(ApiVersion.V3);
}
@Test
@Deprecated
void fromHttpHeadersWhenHasSingleV2HeaderReturnsV2() {
ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader(ActuatorMediaType.V2_JSON));
assertThat(version).isEqualTo(ApiVersion.V2);
}
@Test
@Deprecated
void fromHttpHeadersWhenHasSingleV3HeaderReturnsV3() {
ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader(ActuatorMediaType.V3_JSON));
assertThat(version).isEqualTo(ApiVersion.V3);
}
@Test
@Deprecated
void fromHttpHeadersWhenHasV2AndV3HeaderReturnsV3() {
ApiVersion version = ApiVersion
.fromHttpHeaders(acceptHeader(ActuatorMediaType.V2_JSON, ActuatorMediaType.V3_JSON));
......@@ -64,6 +68,7 @@ class ApiVersionTests {
}
@Test
@Deprecated
void fromHttpHeadersWhenHasV2AndV3AsOneHeaderReturnsV3() {
ApiVersion version = ApiVersion
.fromHttpHeaders(acceptHeader(ActuatorMediaType.V2_JSON + "," + ActuatorMediaType.V3_JSON));
......@@ -71,18 +76,21 @@ class ApiVersionTests {
}
@Test
@Deprecated
void fromHttpHeadersWhenHasSingleHeaderWithoutJsonReturnsHeader() {
ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader("application/vnd.spring-boot.actuator.v2"));
assertThat(version).isEqualTo(ApiVersion.V2);
}
@Test
@Deprecated
void fromHttpHeadersWhenHasUnknownVersionReturnsLatest() {
ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader("application/vnd.spring-boot.actuator.v200"));
assertThat(version).isEqualTo(ApiVersion.V3);
}
@Test
@Deprecated
void fromHttpHeadersWhenAcceptsEverythingReturnsLatest() {
ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader("*/*"));
assertThat(version).isEqualTo(ApiVersion.V3);
......
......@@ -18,7 +18,6 @@ package org.springframework.boot.actuate.endpoint.invoker.cache;
import java.security.Principal;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
......@@ -203,9 +202,9 @@ class CachingOperationInvokerTests {
Object expectedV2 = new Object();
Object expectedV3 = new Object();
InvocationContext contextV2 = new InvocationContext(mock(SecurityContext.class), Collections.emptyMap(),
Arrays.asList(new ApiVersionArgumentResolver(ApiVersion.V2)));
new ApiVersionArgumentResolver(ApiVersion.V2));
InvocationContext contextV3 = new InvocationContext(mock(SecurityContext.class), Collections.emptyMap(),
Arrays.asList(new ApiVersionArgumentResolver(ApiVersion.V3)));
new ApiVersionArgumentResolver(ApiVersion.V3));
given(target.invoke(contextV2)).willReturn(expectedV2);
given(target.invoke(contextV3)).willReturn(expectedV3);
CachingOperationInvoker invoker = new CachingOperationInvoker(target, CACHE_TTL);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment