quarkus icon indicating copy to clipboard operation
quarkus copied to clipboard

Resteasy Reactive: ContextResolver<ObjectMapper> not used

Open westarne opened this issue 3 years ago • 26 comments

Describe the bug

When creating a RestClient with a ContextResolver<ObjectMapper> registered, this ContextResolver is never used and thus the wrong ObjectMapper (via CDI) is used. Other implementation approaches would be fine as well, but nothing seems to get this behaviour working. Multiple different ObjectMappers in the application do not seem to be supported.

Expected behavior

The ObjectMapper returned by a class that implements ContextResolver<ObjectMapper> used via RestClientBuilder#register(Object) is used in the given RestClient and only there. Multiple RestClients can use multiple different ObjectMappers.

Actual behavior

The ObjectMapper of the registered ContextResolver is not used at all. Instead an application scoped ObjectMapper bean is used in all RestClients.

How to Reproduce?

Run the following test class. The test should pass if matching the expected behaviour.

@QuarkusTest
class MyClientTest {

    MyClient clientAllowsUnknown;
    MyClient clientDisallowsUnknown;

    WireMockServer wireMockServer = getWireMockServer();

    @BeforeEach
    void setUp() throws MalformedURLException {
        wireMockServer.resetAll();

        clientAllowsUnknown = RestClientBuilder.newBuilder()
            .baseUrl(new URL(wireMockServer.baseUrl()))
            .register(ClientObjectMapperUnknown.class)
            .build(MyClient.class);

        clientDisallowsUnknown = RestClientBuilder.newBuilder()
            .baseUrl(new URL(wireMockServer.baseUrl()))
            .register(ClientObjectMapperNoUnknown.class)
            .build(MyClient.class);
    }

    @Test
    void something_withAdditionalIgnoredProperties() {
        var json = "{ \"value\": \"someValue\", \"secondValue\": \"toBeIgnored\" }";
        wireMockServer.stubFor(
            WireMock.get(WireMock.urlMatching("/something"))
                .willReturn(okJson(json)));

        var result = clientAllowsUnknown.something().await().indefinitely();

        // FAIL_ON_UNKNOWN_PROPERTIES disabled
        assertThatCode(() -> new ClientObjectMapperUnknown().getContext(ObjectMapper.class).readValue(json, Something.class))
            .doesNotThrowAnyException();
        assertThat(result).isEqualTo(Something.builder().withValue("someValue").build());

        // FAIL_ON_UNKNOWN_PROPERTIES enabled
        assertThatThrownBy(() -> new ClientObjectMapperNoUnknown().getContext(ObjectMapper.class).readValue(json, Something.class))
            .isInstanceOf(JsonProcessingException.class);
        assertThatThrownBy(() -> clientDisallowsUnknown.something().await().indefinitely())
            .isInstanceOf(JsonProcessingException.class);
    }

    @Path("/something")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public interface MyClient {
        @GET
        Uni<Something> something();
    }

    @Value
    @Builder(toBuilder = true, setterPrefix = "with")
    @Jacksonized
    public static class Something {
        String value;
    }

    public static class ClientObjectMapperUnknown implements ContextResolver<ObjectMapper> {
        @Override
        public ObjectMapper getContext(Class<?> type) {
            return new ObjectMapper()
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
        }
    }

    public static class ClientObjectMapperNoUnknown implements ContextResolver<ObjectMapper> {
        @Override
        public ObjectMapper getContext(Class<?> type) {
            return new ObjectMapper()
                .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .enable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
        }
    }

    public static WireMockServer getWireMockServer() {
        var wireMockServer = new WireMockServer(options().port(getAvailableTcpPort(20000, 22000)));
        wireMockServer.start();
        return wireMockServer;
    }

    public static int getAvailableTcpPort(int min, int max) {
        var ports = IntStream.range(min, max).boxed().collect(Collectors.toList());
        Collections.shuffle(ports); // shuffle to get a random order and reduce the probability a port is already in use

        for (var port : ports) {
            try (ServerSocket serverSocket = new ServerSocket(port)) {
                return serverSocket.getLocalPort();
            } catch (IOException e) {
                // try next
            }
        }

        throw new IllegalStateException(MessageFormat.format("Could not find a free TCP port in range {0}:{1}.", min, max));
    }
}

With

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-rest-client-reactive-jackson</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-resteasy-reactive</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.22</version>
        </dependency>

Output of uname -a or ver

Darwin M042112251A.local 20.6.0 Darwin Kernel Version 20.6.0: Wed Nov 10 22:23:07 PST 2021; root:xnu-7195.141.14~1/RELEASE_X86_64 x86_64

Output of java -version

openjdk version "11.0.14.1" 2022-02-08 OpenJDK Runtime Environment Temurin-11.0.14.1+1 (build 11.0.14.1+1) OpenJDK 64-Bit Server VM Temurin-11.0.14.1+1 (build 11.0.14.1+1, mixed mode)

GraalVM version (if different from Java)

not used

Quarkus version or git rev

2.8.0.Final, 2.9.2.Final, 2.10.0.CR1

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

Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)

Additional information

No response

westarne avatar Jun 15 '22 10:06 westarne

/cc @FroMage, @geoand, @stuartwdouglas

quarkus-bot[bot] avatar Jun 15 '22 10:06 quarkus-bot[bot]

@westarne have you tried to provide your custom ObjectMapper instance by following the instructions in https://quarkus.io/guides/rest-json#json ? Something like this should work:

import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.jackson.ObjectMapperCustomizer;

import javax.enterprise.inject.Instance;
import javax.inject.Singleton;

public class CustomObjectMapper {

    // Replaces the CDI producer for ObjectMapper built into Quarkus
    @Singleton
    ObjectMapper objectMapper(Instance<ObjectMapperCustomizer> customizers) {
        ObjectMapper mapper = myObjectMapper(); // Custom `ObjectMapper`

        // Apply all ObjectMapperCustomizer beans (incl. Quarkus)
        for (ObjectMapperCustomizer customizer : customizers) {
            customizer.customize(mapper);
        }

        return mapper;
    }
}

Sgitario avatar Jun 30 '22 05:06 Sgitario

@westarne have you tried to provide your custom ObjectMapper instance by following the instructions in https://quarkus.io/guides/rest-json#json ? Something like this should work:

import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.jackson.ObjectMapperCustomizer;

import javax.enterprise.inject.Instance;
import javax.inject.Singleton;

public class CustomObjectMapper {

    // Replaces the CDI producer for ObjectMapper built into Quarkus
    @Singleton
    ObjectMapper objectMapper(Instance<ObjectMapperCustomizer> customizers) {
        ObjectMapper mapper = myObjectMapper(); // Custom `ObjectMapper`

        // Apply all ObjectMapperCustomizer beans (incl. Quarkus)
        for (ObjectMapperCustomizer customizer : customizers) {
            customizer.customize(mapper);
        }

        return mapper;
    }
}

Yes, that's working for one object mapper, but not for multiple object mappers with different configurations for each client

westarne avatar Jun 30 '22 06:06 westarne

Yes, that was still the same behavior for me as far as I remember.

But I wanted to use the same client with different object mappers (for different backends). So I did this by extending the base client and added the @RegisterProvider there.

So something like @RegisterProvier(SpecificObjectMapperProvider.class) public interface SpecificRestClient extends RestClient

westarne avatar Jul 15 '22 19:07 westarne

I have a similar issue. I tried to migrate fom resteasy to resteasy-reactive and from rest-client to rest-client-reactive. Every Client has it's own ObjectMapper which is registered via @RegisterProvider(....class). In rest-client this works, in rest-client-reactive it's not working. If a breakpoint is set in the getContext() method you see that it's called only when rest-client is used.

hamburml avatar Jul 18 '22 19:07 hamburml

Does someone have a ready made sample I can use to see the problem?

geoand avatar Jul 26 '22 12:07 geoand

@geoand You can use this https://github.com/hamburml/context-resolver

When quarkus-resteasy-jackson and quarkus-rest-client-jackson is used the GreetingRestClientMapper works (getContext is called). When using quarkus-resteasy-reactive-jackson, quarkus-resteasy-reactive and quarkus-rest-client-reactive-jackson instead of the older counterparts getContext is not called anymore.

hamburml avatar Jul 26 '22 17:07 hamburml

To be honest, I very much dislike the use of ContextResolver to solve such kind of issues... I would much prefer us do something like https://github.com/quarkusio/quarkus/pull/23995

geoand avatar Jul 27 '22 07:07 geoand

To be honest, I very much dislike the use of ContextResolver to solve such kind of issues... I would much prefer us do something like #23995

As far as I understand that issue, it will still not allow distinct ObjectMappers for different clients, right? And that is exactly what we need. Some object mapper features are just not compatible and cannot be used for multiple different clients. So a separation of server and client is also required (at least to have the possibility), but for systems with a lot of different clients it just does not work with one object mapper for all of these. So what we currently do is have several services for several clients just to be able to combine the info then in another service, which is really a big overhead for such a use case in my opinion.

Obviously it shouldn't be enforced to have several OMs, just the possibility to set a specific OM instance (maybe even in the RestClientBuilder) would be important for us.

westarne avatar Jul 27 '22 08:07 westarne

Yeah, I understand that and it's completely reasonable. The PR mentioned above does not exactly do that, but it could lay the groundwork for making things easier when it comes to addressing this use case.

I personally think ContextResolver is a very dated API that most people don't know about anyway so we should strive to come up with something more modern and usable.

geoand avatar Jul 27 '22 08:07 geoand

We could for example use CDI qualifiers on the class, or even utilize a static (or default) method on the interface that would yield an object mapper.

My point is that there are a lot of ways of making this better than relying on the antiquated and arcane ContextResolver API

geoand avatar Jul 27 '22 08:07 geoand

A CDI qualifier or an annotation could make sense. In any case, it's a very valid use case given you will get all sorts of JSON formats coming from various apps your clients are targeting.

Now I don't know how hard supporting ContextResolver would be. If not, maybe we should implement it anyway to simplify migrations but only document the more modern approach we want people to have. My concern is that not using the appropriate ObjectMapper might break your app in a very subtle way that you only find out later (and could potentially lead to data loss if you are persisting what comes from the client). Now, if it's hard and complex, it's probably not worth it.

gsmet avatar Jul 27 '22 09:07 gsmet

My concern is that not using the appropriate ObjectMapper might break your app in a very subtle way that you only find out later (and could potentially lead to data loss if you are persisting what comes from the client)

Completely agreed, this should be addressed. My point is that I see no reason to resolve it using ContextResolver.

geoand avatar Jul 27 '22 09:07 geoand

Thanks for your answers. Looks like we will stop our migration to reactive dependencies till this is configurable. I am not so sure but I think I read somewhere that "resteasy" and "resteasy-reactive" (e.g. "rest-client" and "rest-client-reactive") is compatible to each other which is not the case. Maybe if I find the docs again I send them to you.

hamburml avatar Jul 27 '22 09:07 hamburml

They are compatible, but obviously this is a case where things fall short. It will be addressed however (for Quarkus 2.12 or 2.13)

geoand avatar Jul 27 '22 09:07 geoand

My point is that I see no reason to resolve it using ContextResolver.

You're missing my point then :) - it might not be enough to justify the work but better make sure you have it. My concern is that people migrating from the REST Client to the Reactive one won't notice until too late that their ContextResolver won't actually be taken into account. It's not as if the failure would always be big enough to notice it. For instance, you could just lose a field that you used to persist or something similar. And realize a few days later that the data is missing. That's my biggest concern about us not supporting the "old" way.

gsmet avatar Jul 27 '22 10:07 gsmet

I looked into this a little more and making the ContextResolver thing work for the client is not going to pretty at all... Due to how the APIs are constructed, there is really no integration point at which a MessageBodyReader or MessageBodyWriter can access the JAX-RS Configuration (which is where the ContextResolver is stored for each client). We also can't really rely on the @Context annotation because that would result in obtaining the ContextResolver classes configured for the server (stuff like this is a constant source of bugs in RESTEasy Classic).

So the only way I see to get this functionality is to provide our own ClientMessageBodyReader and ClientMessageBodyWriter interfaces that have access to the necessary information in the reader / writer methods. That would suffice for us to then change the our Jackson related readers / writers to lookup the matching ContextResolver classes.

This is an invasive change so I won't be doing it without the agreement of @michalszynkiewicz and @Sgitario

geoand avatar Jul 27 '22 11:07 geoand

I also must say that without our api gateway which validates the request body against a scheme I probably would have missed that ContextResolver doesn't work. For me it is 100% important that it works like before (or there is an even better way how it's done). Thanks for your work @geoand

hamburml avatar Jul 27 '22 17:07 hamburml

Understood, thanks.

geoand avatar Jul 28 '22 05:07 geoand

https://github.com/quarkusio/quarkus/pull/26988 takes care of the server part.

For the client part, this comment still applies.

geoand avatar Jul 28 '22 09:07 geoand

@michalszynkiewicz and @Sgitario Could you please give a statement to this issue? Thanks!

hamburml avatar Aug 05 '22 10:08 hamburml

They are both on PTO IIRC

geoand avatar Aug 05 '22 10:08 geoand

I looked into this issue before my PTO and shared the same thoughts as @geoand . The most straightforward approach is to provide a custom message reader / writer that checks for custom context resolvers objects. However, this is not a general solution which is ok (it would only work for the jackson extension), and we also need to take into account the performance pitfall of checking for the custom context resolvers.

Sgitario avatar Aug 08 '22 06:08 Sgitario

and we also need to take into account the performance pitfall of checking for the custom context resolvers.

We can alleviate this in the same way I did it for the server

geoand avatar Aug 08 '22 13:08 geoand

and we also need to take into account the performance pitfall of checking for the custom context resolvers.

We can alleviate this in the same way I did it for the server

Sure. I will do the changes on the client side.

Sgitario avatar Aug 09 '22 05:08 Sgitario

Thanks!

geoand avatar Aug 09 '22 05:08 geoand

Maybe I don't understand the comments correctly, but to me it reads like ContextResolver<ObjectMapper> should work in resteasy-reactive like in resteasy classic. I am currently switching our dependencies to resteasy-reactive and have the same problem as described in this issue. The provider is not called (Quarkus 2.16.3.Final).

timonz-de avatar Feb 24 '23 11:02 timonz-de

@timonz-de You are right, it does not work. I just tried it. Maybe you should stick to the non-reactive dependencies. @gsmet @Sgitario You could take a look please? I used https://github.com/hamburml/context-resolver, used the reactive dependencies and updates to new quarkus version and tried it. GreetingRestClientMapper::getContext is never called

hamburml avatar Feb 25 '23 13:02 hamburml

The CDI support was partially fixed by https://github.com/quarkusio/quarkus/commit/b60b23def77cbd4b31896259239ea46784abee25 but, I've just spotted another issue that will be fixed in https://github.com/quarkusio/quarkus/pull/31422.

Sgitario avatar Feb 26 '23 12:02 Sgitario

Sorry, just saw your edit. I answered to the mail :) Thx for fixing so fast.

hamburml avatar Feb 26 '23 19:02 hamburml