Skip to content

Commit 6c8f54f

Browse files
feat(api): add custom validation framework (#1756)
* feat(api): add custom validation framework * added documentation * updated docs * Apply suggestions from code review Co-authored-by: Florian Rusch (ZF Friedrichshafen AG) <[email protected]> * renamed to InterceptorFunctionRegistry Co-authored-by: Florian Rusch (ZF Friedrichshafen AG) <[email protected]>
1 parent b76a91b commit 6c8f54f

File tree

17 files changed

+945
-7
lines changed

17 files changed

+945
-7
lines changed

docs/developer/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ As a developer, I do not want to ...
3434
- [Serialization Context](./decision-records/2022-07-04-type-manager/README.md)
3535
- [State machine](state-machine.md)
3636
- [Style guide](_helper/styleguide.md)
37+
- [Custom request validation](custom_validation.md)
3738
- [Testing](testing.md)
3839

3940
> All implementations have to follow existing design principles and architectural patterns that are provided as

docs/developer/custom_validation.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Custom validation framework
2+
3+
The validation framework hooks into the normal Jetty/Jersey request dispatch mechanism and is designed to allow users to
4+
intercept the request chain to perform additional validation tasks. In its current form it is intended for intercepting
5+
REST requests. Users can elect any validation framework they desire, such as `jakarta.validation` or
6+
the [Apache Commons Validator](https://commons.apache.org/proper/commons-validator/), or they can implement one
7+
themselves.
8+
9+
## When to use it
10+
11+
This feature is intended for use cases where the standard DTO validation, that ships with EDC's APIs is not sufficient.
12+
Please check out the [OpenAPI spec](../../resources/openapi/openapi.yaml) to find out more about the object schema.
13+
14+
EDC features various data types that do not have a strict schema but are *extensible*, for example `Asset`/`AssetDto`,
15+
or a `DataRequest`/`DataRequestDto`. This was done by design, to allow for maximum flexibility and openness. However,
16+
users may still want to put a more rigid schema on top of those data types, for example a use case may require an
17+
`Asset` to always have a `owner` property or may require a `contentType` to be present. The standard EDC validation
18+
scheme has no way of enforcing that, so this is where _custom validation_ enters the playing field.
19+
20+
## Building blocks
21+
22+
There are two important components necessary for custom validation:
23+
24+
- the `InterceptorFunction`: a function that accepts the intercepted method's parameters as argument (as `Object[]`),
25+
and returns a `Result<Void>` to indicate the validation success. It **must not** throw an exception, or dispatch to
26+
the target resource is not guaranteed.
27+
- the `ValidationFunctionRegistry`: all `InterceptorFunctions` must be registered there, using one of three registration
28+
methods (see below).
29+
30+
Custom validation works by supplying an `InterceptorFunction` to the `ValidationFunctionRegistry` in one of the
31+
following ways:
32+
33+
1. bound to a resource-method: here, we register the `InterceptorFunction` to any of a controller's methods. That means,
34+
we need compile-time access to the controller class, because we use reflection to obtain the `Method`:
35+
```java
36+
var method = YourController.class.getDeclaredMethods("theMethod", /*parameter types*/)
37+
var yourFunction = objects -> Result.success(); // you validation logic goes here
38+
registry.addFunction(method, yourFunction);
39+
```
40+
Consequently `yourFunction` will get invoked before `YourController#theMethod` is invoked by the request dispatcher.
41+
Note that there is currently no way to bind an `InterceptorFunction` directly to an HTTP endpoint.
42+
43+
2. bound to an argument type: the interceptor function gets bound to all resource methods that have a particular type in
44+
their signature:
45+
```java
46+
var yourFunction = objects -> Result.success(); // your validation logic goes here
47+
registry.addFunction(YourObjectDto.class, yourFunction);
48+
```
49+
The above function would therefore get invoked in all controllers on the classpath, that have a `YourObjectDto`
50+
in their signature, e.g. `public void createObject(YourObjectDto dto)` and `public boolean deleteObject
51+
(YourObjectDto dto)` would both get intercepted, even if they are defined in different controller classes.
52+
*This is the recommended way in the situation described above - adding additional schema restrictions on extensible
53+
types*
54+
55+
3. globally, for all resource methods: this is intended for interceptor functions that should get invoked on *all*
56+
resource methods. *This is generally not recommended and should only be used in very specific situations such as
57+
logging*
58+
59+
Please check
60+
out [this test](../../extensions/http/jersey/src/test/java/org/eclipse/dataspaceconnector/extension/jersey/validation/integrationtest/ValidationIntegrationTest.java)
61+
for a comprehensive example how validation can be enabled. All functions are registered during the extension's
62+
initialization phase.
63+
64+
## Limitations and caveats
65+
66+
- `InterceptorFunction` objects **must not** throw exceptions
67+
- all function registration must happen during the `initialize` phase of the extension lifecycle.
68+
- interceptor functions **should not** perform time-consuming tasks, such as invoking other backend systems, so as not
69+
to cause timeouts in the request chain
70+
- for method-based interception compile-time access to the resource is required. This might not be suitable for a lot of
71+
situations.
72+
- returning a `Result.failure(...)` will result in an `HTTP 400 BAD REQUEST` status code. This is the only supported
73+
status code at this time. Note that the failure message will be part of the HTTP response body.
74+
- binding methods directly to paths ("endpoints") is not supported.

docs/developer/decision-records/2022-07-27-custom-dto-validation/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,28 +53,28 @@ public interface CustomValidationRegistry {
5353
* Registers a validation function for a particular type (e.g. a DTO). The validation function gets applied to
5454
* all resource methods that have a T object in their signature
5555
* @param type The class of the object for which to register the function
56-
* @param validationFunction A function that evaluates the object and returns a Result
56+
* @param interceptorFunction A function that evaluates the object and returns a Result
5757
*/
58-
<T> void registerForType(Class<T> type, Function<T, Result> validationFunction);
58+
<T> void registerForType(Class<T> type, Function<T, Result> interceptorFunction);
5959

6060
/**
6161
* Registers a validation function for all resource methods. Conditional evaluation must be done in the
6262
* evaluation function itself
63-
* @param validationFunction Receives the list of arguments of the resource method, returns a Result
63+
* @param interceptorFunction Receives the list of arguments of the resource method, returns a Result
6464
*/
65-
void register(Function<Object[], Result> validationFunction);
65+
void register(Function<Object[], Result> interceptorFunction);
6666

6767
/**
6868
* Registers a validation function for a particular resource method (= Controller method). The validation
6969
* function only gets applied to that particular method.
7070
* @param method The {@link java.lang.reflect.Method} (of a controller) for which to register the function
71-
* @param validationFunction Receives the list of arguments of the resource method, returns a Result
71+
* @param interceptorFunction Receives the list of arguments of the resource method, returns a Result
7272
*/
73-
void registerForMethod(Method method, Function<Object[], Result> validationFunction);
73+
void registerForMethod(Method method, Function<Object[], Result> interceptorFunction);
7474
}
7575
```
7676

77-
If the `validationFunction` returns a failed `Result`, the `InvocationHandler` will throw an
77+
If the `interceptorFunction` returns a failed `Result`, the `InvocationHandler` will throw an
7878
`InvalidRequestException`, resulting in an HTTP 400 error code. As a side note is important to wrap that exception in
7979
an `InvocationTargetException`, so that it gets picked up by the method dispatcher.
8080

extensions/http/jersey/src/main/java/org/eclipse/dataspaceconnector/extension/jersey/JerseyExtension.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,24 @@
1414

1515
package org.eclipse.dataspaceconnector.extension.jersey;
1616

17+
import org.eclipse.dataspaceconnector.extension.jersey.validation.ResourceInterceptorBinder;
18+
import org.eclipse.dataspaceconnector.extension.jersey.validation.ResourceInterceptorProvider;
1719
import org.eclipse.dataspaceconnector.extension.jetty.JettyService;
1820
import org.eclipse.dataspaceconnector.spi.WebService;
1921
import org.eclipse.dataspaceconnector.spi.system.Inject;
22+
import org.eclipse.dataspaceconnector.spi.system.Provider;
2023
import org.eclipse.dataspaceconnector.spi.system.Provides;
2124
import org.eclipse.dataspaceconnector.spi.system.ServiceExtension;
2225
import org.eclipse.dataspaceconnector.spi.system.ServiceExtensionContext;
26+
import org.eclipse.dataspaceconnector.spi.validation.InterceptorFunctionRegistry;
2327

2428
@Provides(WebService.class)
2529
public class JerseyExtension implements ServiceExtension {
2630
private JerseyRestService jerseyRestService;
2731

2832
@Inject
2933
private JettyService jettyService;
34+
private ResourceInterceptorProvider provider;
3035

3136
@Override
3237
public String name() {
@@ -42,11 +47,19 @@ public void initialize(ServiceExtensionContext context) {
4247

4348
jerseyRestService = new JerseyRestService(jettyService, typeManager, configuration, monitor);
4449

50+
provider = new ResourceInterceptorProvider();
51+
jerseyRestService.registerInstance(() -> new ResourceInterceptorBinder(provider));
52+
4553
context.registerService(WebService.class, jerseyRestService);
4654
}
4755

4856
@Override
4957
public void start() {
5058
jerseyRestService.start();
5159
}
60+
61+
@Provider
62+
public InterceptorFunctionRegistry createInterceptorFunctionRegistry() {
63+
return provider;
64+
}
5265
}

extensions/http/jersey/src/main/java/org/eclipse/dataspaceconnector/extension/jersey/JerseyRestService.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.util.HashMap;
3333
import java.util.List;
3434
import java.util.Map;
35+
import java.util.function.Supplier;
3536

3637
import static java.util.stream.Collectors.toSet;
3738
import static org.glassfish.jersey.server.ServerProperties.WADL_FEATURE_DISABLE;
@@ -45,6 +46,7 @@ public class JerseyRestService implements WebService {
4546

4647
private final Map<String, List<Object>> controllers = new HashMap<>();
4748
private final JerseyConfiguration configuration;
49+
private final List<Supplier<Object>> additionalInstances = new ArrayList<>();
4850

4951
public JerseyRestService(JettyService jettyService, TypeManager typeManager, JerseyConfiguration configuration, Monitor monitor) {
5052
this.jettyService = jettyService;
@@ -65,6 +67,10 @@ public void registerResource(String contextAlias, Object resource) {
6567
.add(resource);
6668
}
6769

70+
void registerInstance(Supplier<Object> instance) {
71+
additionalInstances.add(instance);
72+
}
73+
6874
public void start() {
6975
try {
7076
controllers.forEach(this::registerContext);
@@ -89,6 +95,9 @@ private void registerContext(String contextAlias, List<Object> controllers) {
8995
resourceConfig.registerInstances(new ValidationExceptionMapper());
9096
resourceConfig.registerInstances(new UnexpectedExceptionMapper(monitor));
9197

98+
additionalInstances.forEach(supplier -> resourceConfig.registerInstances(supplier.get()));
99+
100+
92101
if (configuration.isCorsEnabled()) {
93102
resourceConfig.register(new CorsFilter(configuration));
94103
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright (c) 2022 Microsoft Corporation
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Microsoft Corporation - initial API and implementation
12+
*
13+
*/
14+
15+
package org.eclipse.dataspaceconnector.extension.jersey.validation;
16+
17+
import org.eclipse.dataspaceconnector.spi.exception.InvalidRequestException;
18+
import org.eclipse.dataspaceconnector.spi.result.Result;
19+
import org.eclipse.dataspaceconnector.spi.validation.InterceptorFunction;
20+
21+
import java.lang.reflect.InvocationHandler;
22+
import java.lang.reflect.InvocationTargetException;
23+
import java.lang.reflect.Method;
24+
import java.util.List;
25+
26+
/**
27+
* This {@link InvocationHandler} acts as interceptor whenever methods get called on a proxy object, and invokes
28+
* a list of {@link InterceptorFunction} objects before calling the actual proxy.
29+
* <p>
30+
* Note that any exception thrown by a {@link InterceptorFunction} may get swallowed or cause the proxy not to be invoked at all.
31+
*/
32+
class ResourceInterceptor implements InvocationHandler {
33+
34+
private final List<InterceptorFunction> interceptorFunctions;
35+
36+
ResourceInterceptor(List<InterceptorFunction> functions) {
37+
interceptorFunctions = List.copyOf(functions);
38+
}
39+
40+
@Override
41+
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
42+
var results = interceptorFunctions.stream()
43+
.map(f -> f.apply(args))
44+
.reduce(Result::merge);
45+
46+
if (results.isPresent()) {
47+
if (results.get().failed()) {
48+
throwException(results.get());
49+
}
50+
}
51+
52+
return method.invoke(proxy, args);
53+
}
54+
55+
private void throwException(Result<Void> result) throws InvocationTargetException {
56+
var cause = new InvalidRequestException(result.getFailureMessages());
57+
58+
// must be wrapped in an InvocationTargetException, so that the message dispatcher picks it up and forwards
59+
// it to the exception mapper(s)
60+
throw new InvocationTargetException(cause);
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2022 Microsoft Corporation
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Microsoft Corporation - initial API and implementation
12+
*
13+
*/
14+
15+
package org.eclipse.dataspaceconnector.extension.jersey.validation;
16+
17+
import org.glassfish.jersey.internal.inject.AbstractBinder;
18+
import org.glassfish.jersey.server.spi.internal.ResourceMethodInvocationHandlerProvider;
19+
20+
/**
21+
* Binds a concrete instance of a {@link ResourceInterceptorProvider} to a {@link ResourceMethodInvocationHandlerProvider}, thus enabling
22+
* the method interceptor mechanism.
23+
*/
24+
public class ResourceInterceptorBinder extends AbstractBinder {
25+
26+
private final ResourceInterceptorProvider provider;
27+
28+
public ResourceInterceptorBinder(ResourceInterceptorProvider provider) {
29+
this.provider = provider;
30+
}
31+
32+
@Override
33+
protected void configure() {
34+
bindFactory(() -> provider).to(ResourceMethodInvocationHandlerProvider.class);
35+
}
36+
}

0 commit comments

Comments
 (0)