quarkusio / quarkus-quickstarts

Quarkus quickstart code
https://quarkus.io
Apache License 2.0
1.97k stars 1.47k forks source link

[Quarkus][v3.13.2] REST CLIENT - quarkus-rest-client does not pass POST requests body #1440

Closed emakunin closed 2 months ago

emakunin commented 3 months ago

Hi folks,

I'm trying to migrate to Quarkus v3.13.2 (tried also v3.11 with the same effect) and as per documentation I'm migrating my resteasy clients to quarkus-rest-client

          * If your application uses a client and exposes REST endpoints,
            please use Quarkus REST for the server part.
          * Please note that the quarkus-resteasy-client extension may not be used with Quarkus REST,
            use quarkus-rest-client instead.

          See https://quarkus.io/guides/rest-client

However it does not pass POST payload. Current quarkus LTS (3.8.1) worked fine with resteasy client.

The problem is that post body value is always null. I checked in debugger and in rest logs. They contain server response with an error. due to missing fields that I do pass as I can see in the debugger. The object serializatioin seems to be just ignoring the input class object (see below)

....
2024-08-13 11:21:43,453 DEBUG [org.jbo.res.rea.cli.log.DefaultClientLogger] (vert.x-eventloop-thread-2) Request: POST https://sellingpartnerapi-eu.amazon.com/inbound/fba/2024-03-20/inboundPlans Headers[Accept=application/json Authorization=*** Credential=***/aws4_request, SignedHeaders=accept;content-type;host;method;uri;x-amz-access-token;x-amz-date;x-amz-security-token, Signature=**** Content-Type=application/json Host=sellingpartnerapi-eu.amazon.com method=POST uri=https://sellingpartnerapi-eu.amazon.com/inbound/fba/2024-03-20/inboundPlans User-Agent=Quarkus REST Client x-amz-access-token=**** **content-length=0], Empty body**
...
2024-08-13 11:21:43,520 DEBUG [org.jbo.res.rea.cli.log.DefaultClientLogger] (vert.x-eventloop-thread-2) Response: POST ****/fba/2024-03-20/inboundPlans, Status[400 ], Headers[Server=Server Date=Tue, 13 Aug 2024 09:21:43 GMT Content-Type=application/json Content-Length=1456 Connection=keep-alive x-amz-rid=*** x-amzn-RateLimit-Limit=2.0 x-amzn-***** x-amz-apigw-id=**** X-Amzn-Trace-Id=Root=*** Vary=Content-Type,Accept-Encoding,User-Agent Strict-Transport-Security=max-age=47474747; includeSubDomains; preload], Body:
{
  "errors": [

My setup

Quarkus 3.13.2

// Rest interface:
@Path("/inbound/fba/2024-03-20")
@RegisterRestClient
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface FbaInboundV2024Api {
... 
    @POST
    @Path("inboundPlans")
    CreateInboundPlanResponse createInboundPlan(CreateInboundPlanRequest body);
...
}

// payload
@ApiModel(description = "The `createInboundPlan` request.")
@jakarta.annotation.Generated(value = "io.swagger.codegen.languages.JavaClientCodegen", date = "2024-06-12T12:45:38.427+02:00")
@RegisterForReflection
public class CreateInboundPlanRequest {
  @JsonProperty("destinationMarketplaces")
  private List<String> destinationMarketplaces = new ArrayList<>();

  @JsonProperty("items")
  private List<ItemInput> items = new ArrayList<>();

  @JsonProperty("name")
  private String name = null;
....
}

// Producer code
@ApplicationScoped
public class ApiProducer {
  ...
    @Produces
    @ApplicationScoped
    @Named("FbaInboundV2024ApiEu")
    public FbaInboundV2024Api fbaInboundV2024ApiEu(@Named("builderEu") QuarkusRestClientBuilder builderEu) {
        return builderEu.build(FbaInboundV2024Api.class);
    }

    @Produces
    @ApplicationScoped
    @Named("FbaInboundV2024ApiNa")
    public FbaInboundV2024Api fbaInboundV2024ApiNa(@Named("builderNa") QuarkusRestClientBuilder builderNa) {
        return builderNa.build(FbaInboundV2024Api.class);
    }
....
    private static QuarkusRestClientBuilder defaultBuilder(String uri) {
        return QuarkusRestClientBuilder.newBuilder()
                .baseUri(URI.create(uri))
                .queryParamStyle(QueryParamStyle.COMMA_SEPARATED)
                .register(SpApiExceptionMapper.class);
    }
}

I have below dependencies in runtime package (plus correspoinding ones with *-deployment suffix in deployment lib)

        <dependency>
            <!-- Generates rest API handler lambda -->
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-amazon-lambda-rest</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-rest-client</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-rest-client-jackson</artifactId>
        </dependency>

Also I added a build step to add my client to the index as otherwise quarkus cannot start saying `REST client interface: interface ....FbaInboundV2024Api was not indexed at build time

    @BuildStep
    AdditionalIndexedClassesBuildItem addAdditionalClasses() {
        return new AdditionalIndexedClassesBuildItem(
                FbaInboundV2024Api.class.getName();
    }

Probably worths a separate issue, but in case it matters: I noticed that if I add beans.xml file to my estension then I get producers conflict even though I provide 2 Named ones, quarkus ApplicationImpl tries to init every single rest client (I have many for each api segment) with @Default qualifier and complains that there are 2 named beans. Probably it's an incorrect initialization.

emakunin commented 3 months ago

The issue seems to be related to this and this part of ClientWriterInterceptorContextImpl used in the client. Temporary buffer replaces a link to the output stream. However other interceptors can call setOutputStream as well to capture the output. They'll do some actions (e.g sigh it) and then reset the output stream from theirs own buffer.

Consider interception like this:

 @Override
    public void aroundWriteTo(WriterInterceptorContext context) throws IOException, WebApplicationException {
        OutputStream originalStream = context.getOutputStream();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        context.setOutputStream(baos);
        try {
            //wait the serialization of the body
            context.proceed();
        } finally {
            // when body is serialized get it
            byte[] body = baos.toByteArray();

           // do something with the body content here (E.g add signature, hash-sum, etc)

            // write request to originalStream
            baos.writeTo(originalStream);
            baos.close();
            context.setOutputStream(originalStream);
      }

ClientWriterInterceptorContextImpl::baos will always be empty or what can be even warse can contain "half baked" data. Since inside the interceptor the reference can be changed.

The code is there for 4 years so not sure whether it's desired/documented. IMHO it's better to fix. For the time being I'll try to modify my interceptors. However the right fix should be in the context (we should not be able to set streams) or in the writer (it should always read output stream)

emakunin commented 3 months ago

UPD:

If someone struggles with similar issue. The fix is easy. Just use below in your interceptor.

ByteArrayOutputStream baos = new ByteArrayOutputStream();
        TeeOutputStream teeStream = new TeeOutputStream(context.getOutputStream(), baos);
        context.setOutputStream(teeStream);

Probably the same can be applied to the ClientWriterInterceptorContextImpl. However there will be a qustion suring setOutputStream as well it's easy to get a recursion there if we just wrap the stream. Not ready to provide an elegant solution straight away.