Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide a way to automatically deserialize non-OK JSON response using WebClient and RestClient #4412

Closed
wants to merge 16 commits into from

Conversation

my4-dev
Copy link
Contributor

@my4-dev my4-dev commented Sep 4, 2022

Motivation:

Please refer to #4382

Modifications:

  • Add HttpStatusPredicate and HttpStatusClassPredicate class to deserialize non-OK JSON response
  • Add some methods to specify what type of response is allowed

Result:

// WebClient
CompletableFuture<ResponseEntity<MyObject>> response =
    client.prepare()
          .get("/v1/items/1")
          .asJson(MyObject.class, HttpStatus.INTERNAL_SERVER_ERROR)
          .execute();

// RestClient
CompletableFuture<ResponseEntity<MyObject>> response = 
    client.get("/v1/items/1")
          .execute(MyObject.class, HttpStatus.INTERNAL_SERVER_ERROR)

@CLAassistant
Copy link

CLAassistant commented Sep 4, 2022

CLA assistant check
All committers have signed the CLA.

@my4-dev my4-dev changed the title Issue4382 Provide a way to automatically deserialize non-OK JSON response using WebClient and RestClient Sep 4, 2022
@my4-dev
Copy link
Contributor Author

my4-dev commented Sep 7, 2022

I have read the CLA document and already signed it.

@codecov
Copy link

codecov bot commented Sep 8, 2022

Codecov Report

❗ No coverage uploaded for pull request base (main@a88ca59). Click here to learn what that means.
Patch has no changes to coverable lines.

❗ Current head ff299ba differs from pull request most recent head 25f27f9. Consider uploading reports for the commit 25f27f9 to get more accurate results

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4412   +/-   ##
=======================================
  Coverage        ?   73.97%           
  Complexity      ?    18210           
=======================================
  Files           ?     1538           
  Lines           ?    67592           
  Branches        ?     8523           
=======================================
  Hits            ?    50000           
  Misses          ?    13524           
  Partials        ?     4068           

☔ View full report in Codecov by Sentry.
📢 Do you have feedback about the report comment? Let us know in this issue.

@@ -105,6 +105,22 @@ static <T> FutureResponseAs<ResponseEntity<T>> json(Class<? extends T> clazz) {
return aggregateAndConvert(AggregatedResponseAs.json(clazz));
}

@UnstableApi
static <T> FutureResponseAs<ResponseEntity<T>> json(Class<? extends T> clazz,
HttpStatusPredicate predicate) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Global comment:
We cannot expose HttpStatusPredicate and HttpStatusClassPredicate to the public API because it's package private classes.
Instead, could you add method variants that take HttpStatus, HttpStatusClass and Predicate<HttpStatus>, please?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, I was careless.
I'll address your comments quickly.
Thank you for your comments!

@my4-dev
Copy link
Contributor Author

my4-dev commented Sep 10, 2022

I addressed your comments, @minwoox .
Please review again.

@my4-dev my4-dev requested review from minwoox and removed request for trustin, ikhoon and jrhee17 September 10, 2022 09:14
@my4-dev
Copy link
Contributor Author

my4-dev commented Sep 10, 2022

I'm sorry, I accidentally push re-request review btn.

@jrhee17
Copy link
Contributor

jrhee17 commented Sep 14, 2022

Can you check the CLA message and ensure that the email for the commits are registered with github?
Screen Shot 2022-09-14 at 11 28 36 PM

@@ -41,26 +44,73 @@ static <T> ResponseAs<AggregatedHttpResponse, ResponseEntity<T>> json(Class<? ex
return response -> newJsonResponseEntity(response, bytes -> JacksonUtil.readValue(bytes, clazz));
}

static <T> ResponseAs<AggregatedHttpResponse, ResponseEntity<T>> json(Class<? extends T> clazz,
Predicate<HttpStatus> predicate) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Global comment: Should we add ? super for the type parameter of Predicate?

Suggested change
Predicate<HttpStatus> predicate) {
Predicate<? super HttpStatus> predicate) {

@@ -41,26 +44,73 @@ static <T> ResponseAs<AggregatedHttpResponse, ResponseEntity<T>> json(Class<? ex
return response -> newJsonResponseEntity(response, bytes -> JacksonUtil.readValue(bytes, clazz));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also use HttpStatusClassPredicate for this?

private static final HttpStatusClassPredicate SUCCESS_PREDICATE = ...;
...

return json(clazz, SUCCESS_PREDICATE);

public <T> FutureTransformingRequestPreparation<ResponseEntity<T>> asJson(Class<? extends T> clazz,
HttpStatusClass httpStatusClass) {
requireNonNull(httpStatusClass, "httpStatusClass");
return asJson(clazz, new HttpStatusClassPredicate(httpStatusClass));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpStatusClass is an enum so we can statistically create HttpStatusClassPredicates.
How about adding a static factory method for HttpStatusClassPredicate?

// Returns a pre-populated object.
HttpStatusClassPredicate.of(httpStatusClass)

We may apply a similar approach to HttpStatusPrediate

@my4-dev
Copy link
Contributor Author

my4-dev commented Sep 24, 2022

I have addressed your comment @ikhoon .

new HttpStatusClassPredicates(HttpStatusClass.UNKNOWN);

static HttpStatusClassPredicates of(HttpStatusClass httpStatusClass) {
if (httpStatusClass == HttpStatusClass.INFORMATIONAL) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Should we use switch statement?

AggregatedHttpResponse response) {
return new InvalidHttpResponseException(
response, "status: " + response.status() +
" (expect: the success class (2xx). response: " + response, null);
" is not expected by predicate method. response: " + response, null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add predicate to the error message. Some users might want to override toString() for debugging purposes instead of using lambda expressions.

Suggested change
" is not expected by predicate method. response: " + response, null);
" is not expected by predicate method. response: " + response ", predicate: " + predicate, null);

private static final Map<HttpStatus, HttpStatusPredicate> httpStatusPredicateMap;

static {
httpStatusPredicateMap = new HashMap<>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We prefer to use immutable collections if possible. Should we use Guavas ImmutableMap.Builderto createhttpStatusPredicateMap`?

}

static HttpStatusPredicate of(HttpStatus httpStatus) {
return httpStatusPredicateMap.get(httpStatus);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A custom HttpStatus can have a status code larger than 1000. Could we create a new HttpStatusPredicate if httpStatusPredicateMap.get(httpStatus) returns null?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, it's my mistake.
I'm going to revise this as static factory method returns a new HttpStatusPredicate object when a status code is larger than 1000.

/**
* Deserializes the JSON response content into the specified non-container type
* using the default {@link ObjectMapper}.
* {@link HttpStatus} type argument specify what type of response is allowed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
* {@link HttpStatus} type argument specify what type of response is allowed.
* {@link HttpStatus} type argument specifies what type of response is allowed.

/**
* Deserializes the JSON response content into the specified non-container type
* using the default {@link ObjectMapper}.
* {@link HttpStatusClass} type argument specify what type of response is allowed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* {@link HttpStatusClass} type argument specify what type of response is allowed.
* {@link HttpStatusClass} type argument specifies what type of response is allowed.

/**
* Deserializes the JSON content of the {@link HttpResponse} into the specified non-container type using
* the default {@link ObjectMapper}.
* {@link HttpStatus} type argument specify what type of response is allowed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about?

Suggested change
* {@link HttpStatus} type argument specify what type of response is allowed.
* Note that if the specified {@link HttpStatus} is different from the {@link HttpStatus}
* of the response, an {@link InvalidHttpResponseException} is raised.

class HttpStatusClassPredicatesTest {

@Test
public void httpStatusClassIsEqualToTestArgument() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Remove public from the methods?

class HttpStatusPredicateTest {

@Test
public void httpStatusIsEqualToTestArgument() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Remove public from the methods?

Comment on lines 33 to 34
private static final HttpStatusClassPredicates SUCCESS_PREDICATE
= HttpStatusClassPredicates.of(HttpStatusClass.SUCCESS);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Should we place = at the end of the first line?

Suggested change
private static final HttpStatusClassPredicates SUCCESS_PREDICATE
= HttpStatusClassPredicates.of(HttpStatusClass.SUCCESS);
private static final HttpStatusClassPredicates SUCCESS_PREDICATE =
HttpStatusClassPredicates.of(HttpStatusClass.SUCCESS);

@minwoox minwoox added this to the 1.21.0 milestone Sep 29, 2022
@my4-dev
Copy link
Contributor Author

my4-dev commented Oct 2, 2022

I have addressed your all comments @ikhoon again.

@my4-dev
Copy link
Contributor Author

my4-dev commented Jan 26, 2023

Hi, @ikhoon!
I have added some test cases and satisfied coverages which is required to exceed.
So, the workflow pipeline will be passed.

@my4-dev
Copy link
Contributor Author

my4-dev commented Jan 27, 2023

I'm sorry, @jrhee17.
I have revised the build error.

@ikhoon
Copy link
Contributor

ikhoon commented Jan 30, 2023

Sorry for the late review. 😅
I was told that @minwoox has a different idea of the API design on this PR.
He is busy dealing with some LINE internal work. Thanks for your patience.

@minwoox
Copy link
Member

minwoox commented Feb 3, 2023

Sorry about the delay. 😅
@jrhee17 has a better idea so he will propose it in a couple of days.
Thanks a lot for your patience. 🙇

@ikhoon ikhoon modified the milestones: 1.22.0, 1.23.0 Feb 5, 2023
@my4-dev
Copy link
Contributor Author

my4-dev commented Mar 8, 2023

Hi, @jrhee17 !
I want to hear about a better idea of the API design you have!

@ikhoon ikhoon modified the milestones: 1.23.0, 1.24.0 Mar 15, 2023
@jrhee17
Copy link
Contributor

jrhee17 commented Apr 12, 2023

Hi @my4-dev , really sorry about the late comment 😅

I think one of the concerns that was pointed out is that it is difficult to specify multiple mappings.

For instance, it is possible that users want to apply a mapping in the following way:

  • Status 400: ClientErrorMessage.class
  • Status 500: ServerErrorMessage.class
  • Status 200: NormalMessage.class

With the current setup, it would be difficult to satisfy this kind of requirement.


I would like to propose that we introduce a conditional at the ResponseAs level. (I'm using andThen here, but there may be a better naming)

ResponseAs.java

<V> ConditionalResponseAs<T, R, V> andThen(ResponseAs<R, V> responseAs, Predicate<R> predicate)

which would mean a ResponseAs is applied only if the accompanying predicate is satisifed.

Then we may chain conditions like the following:

WebClient.of().prepare()
     .as(ResponseAs.blocking()
                   .andThen(res -> "Unexpected server error", res -> res.status().isServerError())
                   .andThen(res -> "missing header", res -> !res.headers().contains("x-header"))
                   .orElse(AggregatedHttpObject::contentUtf8))
     .execute();

We can do something similar for the blocking counterpart, and introduce a BlockingResponseAs as well.
BlockingResponseAs.java

<V> BlockingConditionalResponseAs<V> andThenJson(Class<? extends V> clazz, Predicate<AggregatedHttpResponse> predicate)

and apply json specific conditionals as well

WebClient.of()
         .prepare()
         .as(ResponseAs.blocking()
                       .<MyResponse>andThenJson(MyError.class, res -> res.status().isError())
                       .andThenJson(EmptyMessage.class, res -> res.status().isInformational())
                       .orElseJson(MyMessage.class))
         .execute();

Let me know if this doesn't make sense and sorry about the change in direction 😅

@jrhee17
Copy link
Contributor

jrhee17 commented Apr 13, 2023

@ikhoon also gave the good idea that we may want to accept the predicate first when designing the API

<V> ConditionalResponseAs<T, R, V> andThen(Predicate<R> predicate, ResponseAs<R, V> responseAs)

@my4-dev
Copy link
Contributor Author

my4-dev commented Apr 15, 2023

Hi @jrhee17 , thank you for your suggestion! It gives me a new perspective.
andThen method which is used like the following seems to be more versatile than asJson.

WebClient.of().prepare()
     .as(ResponseAs.blocking()
                   .andThen(res -> "Unexpected server error", res -> res.status().isServerError())
                   .andThen(res -> "missing header", res -> !res.headers().contains("x-header"))
                   .orElse(AggregatedHttpObject::contentUtf8))
     .execute();

But I think it would be difficult to specify a lambda function converting res.content into user defined clazz as a first argument of addThen.
In this issue, I think it would be better to extend the method intended to convert response contents to user defined clazz like asJson to use predicate.

On the other hand, I believe that the following points you made should be addressed.
I will try to figure out how to implement this.

I think one of the concerns that was pointed out is that it is difficult to specify multiple mappings.

For instance, it is possible that users want to apply a mapping in the following way:

Status 400: ClientErrorMessage.class
Status 500: ServerErrorMessage.class
Status 200: NormalMessage.class

If you have an opinion on the above, I would love to hear it👍

@jrhee17
Copy link
Contributor

jrhee17 commented May 23, 2023

But I think it would be difficult to specify a lambda function converting res.content into user defined clazz as a first argument of addThen.

To supplement what I had in mind, this is a poc I had worked on when leaving the above review. aed5603

I don't think this is necessarily the "correct" way, nor do I think the commit is refined enough for review, but this is just to give a basic idea of what I was thinking

In this issue, I think it would be better to extend the method intended to convert response contents to user defined clazz like asJson to use predicate.

I'm not sure I understood the intention of adding a predicate to the parameters of asJson() (am I understanding correctly? 😅 ). Can you give an example implentation of what you have in mind?

@ikhoon ikhoon modified the milestones: 1.24.0, 1.25.0 Jun 2, 2023
@my4-dev
Copy link
Contributor Author

my4-dev commented Jun 11, 2023

@jrhee17 : Thank you for sharing your poc! I could understand your idea and it would be better than before.

I have some questions and please share your opinion.

In your poc, we couldn't apply the mapping by HttpStatus in RestClient.
The intention of this PR is to produce the mapping functions to users in only tests.
So, not providing this functions in RestClient probably isn't a problem. What do you think?
(If needed, I haven't come up with a way to implement this functions to RestClient at this time.)

What do you think the necessity of HttpStatusPredicate and HttpStatusClassPredicate?
For me, it would be more convinient if we could use those classes.

@jrhee17
Copy link
Contributor

jrhee17 commented Jun 14, 2023

In your poc, we couldn't apply the mapping by HttpStatus in RestClient.

I thought we could do something like the following:

final ResponseEntity<MyResponse> res2 =
        RestClient.of(server.httpUri()).get("/")
                  .execute(ResponseAs.blocking()
                                     .<MyResponse>andThenJson(MyError.class, res -> res.status().isClientError())
                                     .andThenJson(EmptyMessage.class, res -> res.status().isInformational())
                                     .orElseJson(MyMessage.class));

https://github.com/jrhee17/armeria/tree/poc/responseas-if-3

The intention of this PR is to produce the mapping functions to users in only tests.
So, not providing this functions in RestClient probably isn't a problem. What do you think?
(If needed, I haven't come up with a way to implement this functions to RestClient at this time.)

I see. I think the issue is the current PR introduces new APIs in the core APIs (RestClient, BlockingClient), which makes us more cautious (since we don't want to introduce breaking changes).

I'm fine with an alternative approach which is either:

  1. less intrusive to core APIs
  2. or provides a way to extend later to support multiple predicates

What do you think the necessity of HttpStatusPredicate and HttpStatusClassPredicate?
For me, it would be more convinient if we could use those classes.

Sure, I think the predicates can be used for more shortcut methods.

@my4-dev
Copy link
Contributor Author

my4-dev commented Jul 1, 2023

This PR was closed and the new PR was suggested blow.
#5002

@jrhee17 jrhee17 modified the milestones: 1.25.0, 1.26.0 Aug 20, 2023
@minwoox minwoox removed this from the 1.26.0 milestone Oct 20, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Provide a way to automatically deserialize non-OK JSON response using WebClient and RestClient
5 participants