Add support for delete operations

This commit adds a `@DeleteOperation` annotation that can be used to
indicate that an endpoint's operation is meant to delete a resource.

Such operation is mapped to a DELETE http method.

Closes gh-10023
This commit is contained in:
Stephane Nicoll 2017-08-24 15:06:05 +02:00
parent 26b93e9454
commit 4c7088981f
10 changed files with 215 additions and 16 deletions

View File

@ -217,9 +217,15 @@ public abstract class AnnotationEndpointDiscoverer<T extends Operation, K>
private T createOperationIfPossible(String endpointId, String beanName,
Method method) {
T readOperation = createReadOperationIfPossible(endpointId, beanName, method);
return (readOperation != null ? readOperation
: createWriteOperationIfPossible(endpointId, beanName, method));
T operation = createReadOperationIfPossible(endpointId, beanName, method);
if (operation != null) {
return operation;
}
operation = createWriteOperationIfPossible(endpointId, beanName, method);
if (operation != null) {
return operation;
}
return createDeleteOperationIfPossible(endpointId, beanName, method);
}
private T createReadOperationIfPossible(String endpointId, String beanName,
@ -234,6 +240,12 @@ public abstract class AnnotationEndpointDiscoverer<T extends Operation, K>
WriteOperation.class, OperationType.WRITE);
}
private T createDeleteOperationIfPossible(String endpointId, String beanName,
Method method) {
return createOperationIfPossible(endpointId, beanName, method,
DeleteOperation.class, OperationType.DELETE);
}
private T createOperationIfPossible(String endpointId, String beanName, Method method,
Class<? extends Annotation> operationAnnotation,
OperationType operationType) {

View File

@ -0,0 +1,36 @@
/*
* 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;
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;
/**
* Identifies a method on an {@link Endpoint} as being a delete operation.
*
* @author Stephane Nicoll
* @since 2.0.0
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DeleteOperation {
}

View File

@ -32,6 +32,11 @@ public enum OperationType {
/**
* A write operation.
*/
WRITE
WRITE,
/**
* A delete operation.
*/
DELETE
}

View File

@ -103,7 +103,7 @@ class EndpointMBeanInfoAssembler {
if (type == OperationType.READ) {
return MBeanOperationInfo.INFO;
}
if (type == OperationType.WRITE) {
if (type == OperationType.WRITE || type == OperationType.DELETE) {
return MBeanOperationInfo.ACTION;
}
return MBeanOperationInfo.UNKNOWN;

View File

@ -205,6 +205,9 @@ public class WebAnnotationEndpointDiscoverer extends
if (operationType == OperationType.WRITE) {
return WebEndpointHttpMethod.POST;
}
if (operationType == OperationType.DELETE) {
return WebEndpointHttpMethod.DELETE;
}
return WebEndpointHttpMethod.GET;
}

View File

@ -32,6 +32,11 @@ public enum WebEndpointHttpMethod {
/**
* An HTTP POST request.
*/
POST
POST,
/**
* An HTTP DELETE request.
*/
DELETE
}

View File

@ -64,12 +64,14 @@ public class AnnotationEndpointDiscovererTests {
assertThat(endpoints).containsOnlyKeys("test");
Map<Method, TestEndpointOperation> operations = mapOperations(
endpoints.get("test"));
assertThat(operations).hasSize(3);
assertThat(operations).hasSize(4);
assertThat(operations).containsKeys(
ReflectionUtils.findMethod(TestEndpoint.class, "getAll"),
ReflectionUtils.findMethod(TestEndpoint.class, "getOne",
String.class),
ReflectionUtils.findMethod(TestEndpoint.class, "update", String.class,
String.class),
ReflectionUtils.findMethod(TestEndpoint.class, "deleteOne",
String.class));
});
}
@ -82,13 +84,15 @@ public class AnnotationEndpointDiscovererTests {
assertThat(endpoints).containsOnlyKeys("test");
Map<Method, TestEndpointOperation> operations = mapOperations(
endpoints.get("test"));
assertThat(operations).hasSize(4);
assertThat(operations).hasSize(5);
assertThat(operations).containsKeys(
ReflectionUtils.findMethod(TestEndpoint.class, "getAll"),
ReflectionUtils.findMethod(TestEndpoint.class, "getOne",
String.class),
ReflectionUtils.findMethod(TestEndpoint.class, "update", String.class,
String.class),
ReflectionUtils.findMethod(TestEndpoint.class, "deleteOne",
String.class),
ReflectionUtils.findMethod(TestEndpointSubclass.class,
"updateWithMoreArguments", String.class, String.class,
String.class));
@ -114,7 +118,7 @@ public class AnnotationEndpointDiscovererTests {
assertThat(endpoints).containsOnlyKeys("test");
Map<Method, TestEndpointOperation> operations = mapOperations(
endpoints.get("test"));
assertThat(operations).hasSize(3);
assertThat(operations).hasSize(4);
operations.values()
.forEach(operation -> assertThat(operation.getInvoker())
.isNotInstanceOf(CachingOperationInvoker.class));
@ -133,7 +137,7 @@ public class AnnotationEndpointDiscovererTests {
assertThat(endpoints).containsOnlyKeys("test");
Map<Method, TestEndpointOperation> operations = mapOperations(
endpoints.get("test"));
assertThat(operations).hasSize(3);
assertThat(operations).hasSize(4);
operations.values()
.forEach(operation -> assertThat(operation.getInvoker())
.isNotInstanceOf(CachingOperationInvoker.class));
@ -232,6 +236,11 @@ public class AnnotationEndpointDiscovererTests {
}
@DeleteOperation
public void deleteOne(@Selector String id) {
}
public void someOtherMethod() {
}

View File

@ -43,6 +43,7 @@ import reactor.core.publisher.Mono;
import org.springframework.boot.endpoint.CachingConfiguration;
import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper;
import org.springframework.boot.endpoint.DeleteOperation;
import org.springframework.boot.endpoint.Endpoint;
import org.springframework.boot.endpoint.ReadOperation;
import org.springframework.boot.endpoint.WriteOperation;
@ -109,6 +110,15 @@ public class EndpointMBeanTests {
new Object[] { "one" }, new String[] { String.class.getName() });
assertThat(updatedOneResponse).isEqualTo("1");
// deleteOne
Object deleteResponse = this.server.invoke(objectName, "deleteOne",
new Object[] { "one" }, new String[] { String.class.getName() });
assertThat(oneResponse).isEqualTo("ONE");
// getOne validation after delete
updatedOneResponse = this.server.invoke(objectName, "getOne",
new Object[] { "one" }, new String[] { String.class.getName() });
assertThat(updatedOneResponse).isNull();
}
catch (Exception ex) {
throw new AssertionError("Failed to invoke method on FooEndpoint", ex);
@ -123,7 +133,8 @@ public class EndpointMBeanTests {
try {
MBeanInfo mBeanInfo = this.server.getMBeanInfo(objectName);
Map<String, MBeanOperationInfo> operations = mapOperations(mBeanInfo);
assertThat(operations).containsOnlyKeys("getAll", "getOne", "update");
assertThat(operations).containsOnlyKeys("getAll", "getOne", "update",
"deleteOne");
assertOperation(operations.get("getAll"), String.class,
MBeanOperationInfo.INFO, new Class<?>[0]);
assertOperation(operations.get("getOne"), String.class,
@ -131,6 +142,8 @@ public class EndpointMBeanTests {
assertOperation(operations.get("update"), Void.TYPE,
MBeanOperationInfo.ACTION,
new Class<?>[] { String.class, String.class });
assertOperation(operations.get("deleteOne"), Void.TYPE,
MBeanOperationInfo.ACTION, new Class<?>[] { String.class });
}
catch (Exception ex) {
throw new AssertionError("Failed to retrieve MBeanInfo of FooEndpoint",
@ -305,6 +318,11 @@ public class EndpointMBeanTests {
this.all.put(name, new Foo(value));
}
@DeleteOperation
public void deleteOne(FooName name) {
this.all.remove(name);
}
}
@Endpoint(id = "reactive")

View File

@ -30,6 +30,7 @@ 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.DeleteOperation;
import org.springframework.boot.endpoint.Endpoint;
import org.springframework.boot.endpoint.EndpointExposure;
import org.springframework.boot.endpoint.EndpointInfo;
@ -71,7 +72,7 @@ public class JmxAnnotationEndpointDiscovererTests {
Map<String, JmxEndpointOperation> operationByName = mapOperations(
endpoints.get("test").getOperations());
assertThat(operationByName).containsOnlyKeys("getAll", "getSomething",
"update");
"update", "deleteSomething");
JmxEndpointOperation getAll = operationByName.get("getAll");
assertThat(getAll.getDescription())
.isEqualTo("Invoke getAll for endpoint test");
@ -92,6 +93,12 @@ public class JmxAnnotationEndpointDiscovererTests {
assertThat(update.getParameters()).hasSize(2);
hasDefaultParameter(update, 0, String.class);
hasDefaultParameter(update, 1, String.class);
JmxEndpointOperation deleteSomething = operationByName.get("deleteSomething");
assertThat(deleteSomething.getDescription())
.isEqualTo("Invoke deleteSomething for endpoint test");
assertThat(deleteSomething.getOutputType()).isEqualTo(Void.TYPE);
assertThat(deleteSomething.getParameters()).hasSize(1);
hasDefaultParameter(deleteSomething, 0, String.class);
});
}
@ -136,7 +143,7 @@ public class JmxAnnotationEndpointDiscovererTests {
Map<String, JmxEndpointOperation> operationByName = mapOperations(
endpoints.get("test").getOperations());
assertThat(operationByName).containsOnlyKeys("getAll", "getSomething",
"update", "getAnother");
"update", "deleteSomething", "getAnother");
JmxEndpointOperation getAnother = operationByName.get("getAnother");
assertThat(getAnother.getDescription()).isEqualTo("Get another thing");
assertThat(getAnother.getOutputType()).isEqualTo(Object.class);
@ -153,7 +160,7 @@ public class JmxAnnotationEndpointDiscovererTests {
Map<String, JmxEndpointOperation> operationByName = mapOperations(
endpoints.get("test").getOperations());
assertThat(operationByName).containsOnlyKeys("getAll", "getSomething",
"update");
"update", "deleteSomething");
JmxEndpointOperation getAll = operationByName.get("getAll");
assertThat(getAll.getInvoker()).isInstanceOf(CachingOperationInvoker.class);
assertThat(((CachingOperationInvoker) getAll.getInvoker()).getTimeToLive())
@ -171,7 +178,7 @@ public class JmxAnnotationEndpointDiscovererTests {
Map<String, JmxEndpointOperation> operationByName = mapOperations(
endpoints.get("test").getOperations());
assertThat(operationByName).containsOnlyKeys("getAll", "getSomething",
"update", "getAnother");
"update", "deleteSomething", "getAnother");
JmxEndpointOperation getAll = operationByName.get("getAll");
assertThat(getAll.getInvoker())
.isInstanceOf(CachingOperationInvoker.class);
@ -239,7 +246,8 @@ public class JmxAnnotationEndpointDiscovererTests {
private void assertJmxTestEndpoint(EndpointInfo<JmxEndpointOperation> endpoint) {
Map<String, JmxEndpointOperation> operationByName = mapOperations(
endpoint.getOperations());
assertThat(operationByName).containsOnlyKeys("getAll", "getSomething", "update");
assertThat(operationByName).containsOnlyKeys("getAll", "getSomething", "update",
"deleteSomething");
JmxEndpointOperation getAll = operationByName.get("getAll");
assertThat(getAll.getDescription()).isEqualTo("Get all the things");
assertThat(getAll.getOutputType()).isEqualTo(Object.class);
@ -257,6 +265,13 @@ public class JmxAnnotationEndpointDiscovererTests {
assertThat(update.getParameters()).hasSize(2);
hasDocumentedParameter(update, 0, "foo", String.class, "Foo identifier");
hasDocumentedParameter(update, 1, "bar", String.class, "Bar value");
JmxEndpointOperation deleteSomething = operationByName.get("deleteSomething");
assertThat(deleteSomething.getDescription())
.isEqualTo("Delete something based on a timeUnit");
assertThat(deleteSomething.getOutputType()).isEqualTo(Void.TYPE);
assertThat(deleteSomething.getParameters()).hasSize(1);
hasDocumentedParameter(deleteSomething, 0, "unitMs", Long.class,
"Number of milliseconds");
}
private void hasDefaultParameter(JmxEndpointOperation operation, int index,
@ -329,6 +344,11 @@ public class JmxAnnotationEndpointDiscovererTests {
}
@DeleteOperation
public void deleteSomething(TimeUnit timeUnit) {
}
}
@Endpoint(id = "jmx", exposure = EndpointExposure.JMX)
@ -367,6 +387,14 @@ public class JmxAnnotationEndpointDiscovererTests {
}
@DeleteOperation
@ManagedOperation(description = "Delete something based on a timeUnit")
@ManagedOperationParameters({
@ManagedOperationParameter(name = "unitMs", description = "Number of milliseconds") })
public void deleteSomething(Long timeUnit) {
}
}
@JmxEndpointExtension(endpoint = TestEndpoint.class)

View File

@ -27,6 +27,7 @@ import org.junit.Test;
import org.springframework.boot.endpoint.CachingConfiguration;
import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper;
import org.springframework.boot.endpoint.DeleteOperation;
import org.springframework.boot.endpoint.Endpoint;
import org.springframework.boot.endpoint.OperationParameterMapper;
import org.springframework.boot.endpoint.ReadOperation;
@ -159,6 +160,23 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
});
}
@Test
public void deleteOperation() {
load(TestEndpointConfiguration.class,
(client) -> client.delete().uri("/test/one")
.accept(MediaType.APPLICATION_JSON).exchange().expectStatus()
.isOk().expectBody().jsonPath("part").isEqualTo("one"));
}
@Test
public void deleteOperationWithVoidResponse() {
load(VoidDeleteResponseEndpointConfiguration.class, (context, client) -> {
client.delete().uri("/voiddelete").accept(MediaType.APPLICATION_JSON).exchange()
.expectStatus().isNoContent().expectBody().isEmpty();
verify(context.getBean(EndpointDelegate.class)).delete();
});
}
@Test
public void nullIsPassedToTheOperationWhenArgumentIsNotFoundInPostRequestBody() {
load(TestEndpointConfiguration.class, (context, client) -> {
@ -188,6 +206,14 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
.isNotFound());
}
@Test
public void nullResponseFromDeleteOperationResultsInNoContentResponseStatus() {
load(NullDeleteResponseEndpointConfiguration.class,
(context, client) -> client.delete().uri("/nulldelete")
.accept(MediaType.APPLICATION_JSON).exchange().expectStatus()
.isNoContent());
}
@Test
public void nullResponseFromWriteOperationResultsInNoContentResponseStatus() {
load(NullWriteResponseEndpointConfiguration.class,
@ -308,6 +334,19 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
}
@Configuration
@Import(BaseConfiguration.class)
static class VoidDeleteResponseEndpointConfiguration {
@Bean
public VoidDeleteResponseEndpoint voidDeleteResponseEndpoint(
EndpointDelegate delegate) {
return new VoidDeleteResponseEndpoint(delegate);
}
}
@Configuration
@Import(BaseConfiguration.class)
static class NullWriteResponseEndpointConfiguration {
@ -331,6 +370,17 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
}
@Configuration
@Import(BaseConfiguration.class)
static class NullDeleteResponseEndpointConfiguration {
@Bean
public NullDeleteResponseEndpoint nullDeleteResponseEndpoint() {
return new NullDeleteResponseEndpoint();
}
}
@Configuration
@Import(BaseConfiguration.class)
static class ResourceEndpointConfiguration {
@ -377,6 +427,11 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
this.endpointDelegate.write(foo, bar);
}
@DeleteOperation
public Map<String, Object> deletePart(@Selector String part) {
return Collections.singletonMap("part", part);
}
}
@Endpoint(id = "query")
@ -421,6 +476,22 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
}
@Endpoint(id = "voiddelete")
static class VoidDeleteResponseEndpoint {
private final EndpointDelegate delegate;
VoidDeleteResponseEndpoint(EndpointDelegate delegate) {
this.delegate = delegate;
}
@DeleteOperation
public void delete() {
this.delegate.delete();
}
}
@Endpoint(id = "nullwrite")
static class NullWriteResponseEndpoint {
@ -448,6 +519,16 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
}
@Endpoint(id = "nulldelete")
static class NullDeleteResponseEndpoint {
@DeleteOperation
public String deleteReturningNull() {
return null;
}
}
@Endpoint(id = "resource")
static class ResourceEndpoint {
@ -476,6 +557,8 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
void write(String foo, String bar);
void delete();
}
}