quarkusio / quarkus

Quarkus: Supersonic Subatomic Java.
https://quarkus.io
Apache License 2.0
13.79k stars 2.68k forks source link

Hibernate Validator doesn't support validation of output values, wrapped in Uni #28421

Open fedinskiy opened 2 years ago

fedinskiy commented 2 years ago

Describe the bug

I have two Resteasy endpoints, both annotated with @Size for output values. One of them returns String, another returns Uni. When I make them return invalid values, the String endpoint sends correct validation message, but the Uni endpoint fails with No validator could be found for constraint.

Expected behavior

Output of reactive endpoints should be validated in the same way as output of non-reactive endpoints.

Actual behavior

An error:

Request failed: javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'javax.validation.constraints.Size' validating type 'io.smallrye.mutiny.Uni<java.lang.String>'. Check configuration for 'validateReactiveEcho.<return value>'
        at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.getExceptionForNullValidator(ConstraintTree.java:116)
        at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.getInitializedConstraintValidator(ConstraintTree.java:162)
        at org.hibernate.validator.internal.engine.constraintvalidation.SimpleConstraintTree.validateConstraints(SimpleConstraintTree.java:54)
        at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateConstraints(ConstraintTree.java:75)
        at org.hibernate.validator.internal.metadata.core.MetaConstraint.doValidateConstraint(MetaConstraint.java:130)
        at org.hibernate.validator.internal.metadata.core.MetaConstraint.validateConstraint(MetaConstraint.java:123)
        at org.hibernate.validator.internal.engine.ValidatorImpl.validateMetaConstraint(ValidatorImpl.java:555)
        at org.hibernate.validator.internal.engine.ValidatorImpl.validateMetaConstraints(ValidatorImpl.java:537)
        at org.hibernate.validator.internal.engine.ValidatorImpl.validateReturnValueForSingleGroup(ValidatorImpl.java:1141)
        at org.hibernate.validator.internal.engine.ValidatorImpl.validateReturnValueForGroup(ValidatorImpl.java:1111)
        at org.hibernate.validator.internal.engine.ValidatorImpl.validateReturnValueInContext(ValidatorImpl.java:1033)
        at org.hibernate.validator.internal.engine.ValidatorImpl.validateReturnValue(ValidatorImpl.java:309)
        at org.hibernate.validator.internal.engine.ValidatorImpl.validateReturnValue(ValidatorImpl.java:259)
        at io.quarkus.hibernate.validator.runtime.interceptor.AbstractMethodValidationInterceptor.validateMethodInvocation(AbstractMethodValidationInterceptor.java:73)
        at io.quarkus.hibernate.validator.runtime.jaxrs.ResteasyReactiveEndPointValidationInterceptor.validateMethodInvocation(ResteasyReactiveEndPointValidationInterceptor.java:21)
        at io.quarkus.hibernate.validator.runtime.jaxrs.ResteasyReactiveEndPointValidationInterceptor_Bean.intercept(Unknown Source)
        at io.quarkus.arc.impl.InterceptorInvocation.invoke(InterceptorInvocation.java:41)
        at io.quarkus.arc.impl.AroundInvokeInvocationContext.perform(AroundInvokeInvocationContext.java:40)
        at io.quarkus.arc.impl.InvocationContexts.performAroundInvoke(InvocationContexts.java:32)
        at com.redhat.qe.Resource_Subclass.validateReactiveEcho(Unknown Source)
        at com.redhat.qe.Resource$quarkusrestinvoker$validateReactiveEcho_947c4ad3e4cb945eb49440b464bcc9d5fc38ea68.invoke(Unknown Source)
        at org.jboss.resteasy.reactive.server.handlers.InvocationHandler.handle(InvocationHandler.java:29)
        at io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext.invokeHandler(QuarkusResteasyReactiveRequestContext.java:115)
        at org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:140)
        at org.jboss.resteasy.reactive.server.handlers.RestInitialHandler.beginProcessing(RestInitialHandler.java:49)
        at org.jboss.resteasy.reactive.server.vertx.ResteasyReactiveVertxHandler.handle(ResteasyReactiveVertxHandler.java:17)
        at org.jboss.resteasy.reactive.server.vertx.ResteasyReactiveVertxHandler.handle(ResteasyReactiveVertxHandler.java:7)
        at io.vertx.ext.web.impl.RouteState.handleContext(RouteState.java:1284)
        at io.vertx.ext.web.impl.RoutingContextImplBase.iterateNext(RoutingContextImplBase.java:173)
        at io.vertx.ext.web.impl.RoutingContextImpl.next(RoutingContextImpl.java:140)
        at io.quarkus.vertx.http.runtime.VertxHttpRecorder$6.handle(VertxHttpRecorder.java:430)
        at io.quarkus.vertx.http.runtime.VertxHttpRecorder$6.handle(VertxHttpRecorder.java:408)
        at io.vertx.ext.web.impl.RouteState.handleContext(RouteState.java:1284)
        at io.vertx.ext.web.impl.RoutingContextImplBase.iterateNext(RoutingContextImplBase.java:173)
        at io.vertx.ext.web.impl.RoutingContextImpl.next(RoutingContextImpl.java:140)
        at io.vertx.ext.web.impl.RouterImpl.handle(RouterImpl.java:68)
        at io.vertx.ext.web.impl.RouterImpl.handle(RouterImpl.java:37)
        at io.quarkus.vertx.http.runtime.VertxHttpRecorder$15.handle(VertxHttpRecorder.java:606)
        at io.quarkus.vertx.http.runtime.VertxHttpRecorder$15.handle(VertxHttpRecorder.java:589)
        at io.quarkus.vertx.http.runtime.VertxHttpRecorder$1.handle(VertxHttpRecorder.java:185)
        at io.quarkus.vertx.http.runtime.VertxHttpRecorder$1.handle(VertxHttpRecorder.java:160)
        at io.vertx.core.http.impl.Http1xServerRequestHandler.handle(Http1xServerRequestHandler.java:67)
        at io.vertx.core.http.impl.Http1xServerRequestHandler.handle(Http1xServerRequestHandler.java:30)
        at io.vertx.core.impl.EventLoopContext.emit(EventLoopContext.java:55)
        at io.vertx.core.impl.DuplicatedContext.emit(DuplicatedContext.java:158)
        at io.vertx.core.http.impl.Http1xServerConnection.handleMessage(Http1xServerConnection.java:145)
        at io.vertx.core.net.impl.ConnectionBase.read(ConnectionBase.java:157)
        at io.vertx.core.net.impl.VertxHandler.channelRead(VertxHandler.java:153)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.channel.ChannelInboundHandlerAdapter.channelRead(ChannelInboundHandlerAdapter.java:93)
        at io.netty.handler.codec.http.websocketx.extensions.WebSocketServerExtensionHandler.channelRead(WebSocketServerExtensionHandler.java:99)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.vertx.core.http.impl.Http1xUpgradeToH2CHandler.channelRead(Http1xUpgradeToH2CHandler.java:116)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.handler.timeout.IdleStateHandler.channelRead(IdleStateHandler.java:286)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:327)
        at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:299)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.vertx.core.http.impl.Http1xOrH2CHandler.end(Http1xOrH2CHandler.java:61)
        at io.vertx.core.http.impl.Http1xOrH2CHandler.channelRead(Http1xOrH2CHandler.java:38)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.handler.timeout.IdleStateHandler.channelRead(IdleStateHandler.java:286)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919)
        at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166)
        at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:722)
        at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:658)
        at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:584)
        at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:496)
        at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997)
        at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        at java.base/java.lang.Thread.run(Thread.java:829)

How to Reproduce?

  1. Clone the reproducer git clone git@github.com:fedinskiy/reproducer.git -b reproducer/output-validation
  2. cd reproducer
  3. Run the test, which checks the reactive endpoint mvn clean verify -Dtest=GreetingResourceTest#reactive
  4. For a comparison, run the test, which checks the classic endpoint mvn clean verify -Dtest=GreetingResourceTest#classic

Output of uname -a or ver

5.19.11-200.fc36.x86_64

Output of java -version

11.0.16 temurin

GraalVM version (if different from Java)

No response

Quarkus version or git rev

2.13.0.Final

Build tool (ie. output of mvnw --version or gradlew --version)

Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)

Additional information

Last year similar issue was found in reactive-routes[1] and it was decided[2], that Resteasy is a way to go for that use case.

[1] https://github.com/quarkusio/quarkus/issues/15168 [2] https://github.com/quarkusio/quarkus/issues/15168#issuecomment-922661864

quarkus-bot[bot] commented 2 years ago

/cc @FroMage, @geoand, @stuartwdouglas

geoand commented 2 years ago

Do we validate output values not wrapped in a Uni (cc @gsmet)?

gsmet commented 2 years ago

I don't know if you do but you should :). That's how the RESTEasy Classic integration is working and I think you mimicked this behavior in RESTEasy Reactive, at least that's what the OP is saying AFAICS.

As for Uni, I don't know if they got some love. I'm pretty sure they will require some special treatment. I didn't do anything on that front myself, not sure if anyone did something about it.

geoand commented 2 years ago

I just wanted to make sure it's expected behavior, thanks

geoand commented 2 years ago

This is not specific to RESTEasy Reactive - it's a general issue with using Hibernate Validator with a Uni response type, so we'll need to figure out how to deal with it.

geoand commented 2 years ago

@gsmet @yrodiere how can I invoke the validator with using a different return type than what the method has?

What I essentially want to do is something like: https://github.com/quarkusio/quarkus/compare/main...geoand:%2328421?expand=1

yrodiere commented 2 years ago

So first, that's not just a problem with Validator and Uni: that's a problem with reactive code and Validator not being designed to deal with reactive code (since the spec it implements, Bean Validation, only addresses imperative/blocking use cases). We'll have the same problem with Multi, CompletableFuture, etc.

If we start with Uni... Calling validateReturnValue will be problematic as Hibernate Validator will generate its metadata based on the Uni<T> return type, and we're passing a T as the value to validate.

One obvious solution would be to register a ValueExtractor for Uni, to have Hibernate Validator understand what Uni is and how to extract a value from it, in a blocking fashion:

public class UniValueExtractor<T> implements ContainerExtractor<Uni<T>, T> {
    public UniValueExtractor() {
    }

    public String toString() {
        return "uni";
    }

    public <T1, C2> void extract(Uni<T> uni, ValueProcessor<T1, ? super T, C2> perValueProcessor, T1 target, C2 context, ContainerExtractionContext extractionContext) {
        if (uni != null) {
            T value = uni.await().indifinitely();
            perValueProcessor.process(target, value, context, extractionContext);
        }
    }

    public boolean multiValued() {
        return false;
    }
}

Then, to avoid blocking when validating RestEasy responses, we'd use your code to validate in a callback, but re-create a (completed) Uni before passing the value to Validator:

            return uni.onItem().invoke(new Consumer<Object>() {
                @Override
                public void accept(Object o) {
                    var uniValueViolations = executableValidator.validateReturnValue(ctx.getTarget(), ctx.getMethod(), Uni.of(o));
                    if (!uniValueViolations.isEmpty()) {
                        throw new ConstraintViolationException(getMessage(ctx.getMethod(), ctx.getParameters(),
                                uniValueViolations), uniValueViolations);
                    }
                }
            });

That way, Validator handles a Uni in the blocking fashion it knows... but does not block.

Admittedly, that's hackish and ugly. But that should work, and would not block, so I guess it checks all the boxes.

We would however have to be extra sure that this blocking-but-not-quite hack is not invoked in a context where we didn't wrap it all in a callback, because that would potentially result in blocking the event loop (people won't like that). I'm thinking in particular of validation of method parameters of Uni type... We probably want to delay validating those as well? Be aware that we would (probably?) need to replace the Uni passed to the method with one that also has a Hibernate Validator callback (onItem(... -> validate)) Finally... I really don't know how we would handle nested fields in classes annotated with @Valid. Imagine a field like @Min(0) Uni<Integer> foo; how do we handle that in a non-blocking fashion? I don't think we can without significant changes to Hibernate Validator.

Other solutions would be to add built-in support for reactive code in Validator (hard) or reimplementing part of Validator's support for validateReturnValue in Quarkus (even more hackish/ugly than the above, and doesn't address method parameters/nested fields). So I guess the above would be our... least bad solution? Though with major caveats.

I'll let @gsmet comment on that though, he will probably have better insight.

yrodiere commented 2 years ago

Oh, an alternative would be to generate some abstract method with the same signature as the one we're validating, except the return type is T instead of Uni<T>. Then we would be able to call validateReturnValue passing that method as an argument. Still a hack, though.

fedinskiy commented 9 months ago

@gsmet @yrodiere this issue lingers for more, that a year now and it seems, that the discussion suddenly stopped. Was there any progress?

yrodiere commented 9 months ago

@fedinskiy There was not. I explained the challenges and offered solutions above, but I have more pressing matters to attend to, and suspect @geoand and @gsmet even more so. So, unless @geoand made some progress, I think this issue is basically up for grabs.

geoand commented 9 months ago

Needless to say it's fallen completely out of my queue 😞