-
Notifications
You must be signed in to change notification settings - Fork 6
JCL-402: Data classes for HTTP Problem Details support #1160
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
Conversation
c4eb65c to
16a6143
Compare
| static String getStatusMessage(final int statusCode) { | ||
| return Arrays.stream(StatusMessages.values()) | ||
| .filter(status -> status.code == statusCode) | ||
| .findFirst() | ||
| .map(knownStatus -> knownStatus.message) | ||
| .orElseGet(() -> { | ||
| // If the status is unknown, default to 400 for client errors and 500 for server errors | ||
| if (statusCode >= 400 && statusCode <= 499) { | ||
| return BAD_REQUEST.message; | ||
| } | ||
| return INTERNAL_SERVER_ERROR.message; | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like this logic.
While I think I understand why it would seem helpful, I disagree with it.
Other implementations of HTTP status classes that I know of choose either to return nulls or to throw when they encounter unknown statuses.
The default chosen here is very opinionated and I don't think that a library like this one, a fairly low-level HTTP client library, should make such decisions about responses from the server.
I might even argue the it is always the server who is responsible for the content of HTTP status codes and phrases, so our library should just honor the ones generated by the server.
Another example of prior art is the JBoss RESTEasy implementation of JAX-RS (I think), that has a special HTTP status phrase 'Unknown Code' for some cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really need to do this status codes mapping at all or could you just pass the data on.
BAD_REQUEST(HttpStatus.BAD_REQUEST, "Bad Request"),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized"),
FORBIDDEN(HttpStatus.FORBIDDEN, "Forbidden"),
...
Unless you do all status codes there are going to be problems like this. Will your HttpStatus be used as API?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might even argue the it is always the server who is responsible for the content of HTTP status codes and phrases, so our library should just honor the ones generated by the server.
I would have preferred that as well, however not all HTTP libraries expose the HTTP status message, in particular java.net.http.HttpResponse does not, so the com.inrupt.client.ResponseInfo interface implements the smaller common denominator and doesn't expose the status message returned by the server.
From there, I don't think it is possible to expose what the server returned. The current behavior is aligned with https://www.rfc-editor.org/rfc/rfc9110.html#name-status-codes:
HTTP status codes are extensible. A client is not required to understand the meaning of all registered status codes, though such understanding is obviously desirable. However, a client MUST understand the class of any status code, as indicated by the first digit, and treat an unrecognized status code as being equivalent to the x00 status code of that class.
Based on this, I would say it is more correct to default to 400 and 500 for client and server status message respectively rather than returning a unique "Unknown" status message.
| final URI instance = Optional.ofNullable((String) pdData.get("instance")) | ||
| .map(URI::create) | ||
| .orElse(null); | ||
| // Note that the status code is disregarded from the body, and reused from the HTTP response directly, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could say that the mentioned requirement is for the server.
If the server decides to err, I think we should return what the server said.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the typed JSON deserialization, we no longer enforce the problem details status is the same as the HTTP response status, except in case of invalid status in the first place.
| // ProblemDetails doesn't have a default constructor, and we can't use JSON mapping annotations because | ||
| // the JSON service is an abstraction over JSON-B and Jackson, so we deserialize the JSON object in a Map | ||
| // and build the ProblemDetails from the Map values. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it not be possible to use typed JSON deserialization if this class had a default constructor and conventionally named property methods?
I would prefer that to this Map-based logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let me give this a try
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 7a59d45 via adding a package-private mutable data class used to initialize the immutable one.
| assertEquals( | ||
| HttpStatus.StatusMessages.getStatusMessage(418), | ||
| HttpStatus.StatusMessages.BAD_REQUEST.message | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test is a perfect example for why I disagree with the current defaulting logic.
That 418 is most certainly not a Bad Request.
| return this.instance; | ||
| }; | ||
|
|
||
| public static class ProblemDetailsData { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately JSONB demands things be public. I think my previous tests failed to capture this because of lack of correct recompilation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I lean towards keeping this over the previous Map-based approach: 490a1f3, but it does add some surface to the public API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do prefer this approach over the map-based one, even if it adds superfluous API.
It being an inner class is hiding enough for my taste.
I'd call it simply 'Data'.
And I'd annotate it with Javadocs explain intended usage (none/deserialization).
Some @SuppressWarnings might be in order if PMD/Sonar complain about a mutable class.
Initial ClientHttpException class Create HTTP status enum Add some tests Add javadoc Refactor HttpStatus Add specialized builder to ProblemDetails Add missing header Make problem details parsing more resilient Add tests for problem details parsing The tests are in the solid-client module, because it will need the additional json service anyways in the test dependencies for testing the rest of this feature, and putting these tests in the api module creates a circular dependency. License header
the JSON was invalid, causing the intended code path not to be used.
The exception are now built taking a ProblemDetails in. Use HttpStatus from API module as reference Lint
This reverts commit 499164d.
That is required by jsonb
8b193a9 to
71d6302
Compare
| * @return the body handler | ||
| * @param <T> the type of the body handler | ||
| */ | ||
| public static <T> Response.BodyHandler<T> throwOnError( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This body handler can be used in the solid module, where we already have a specific mapping from HTTP errors to exceptions
| * @return the body handler | ||
| * @param <T> the type of the body handler | ||
| */ | ||
| public static <T> Response.BodyHandler<T> throwOnError( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This handler can be used in the jena and rdf4j modules, where we want to throw a generic HTTP exception.
| return this.instance; | ||
| }; | ||
|
|
||
| public static class ProblemDetailsData { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do prefer this approach over the map-based one, even if it adds superfluous API.
It being an inner class is hiding enough for my taste.
I'd call it simply 'Data'.
And I'd annotate it with Javadocs explain intended usage (none/deserialization).
Some @SuppressWarnings might be in order if PMD/Sonar complain about a mutable class.
| public URI instance; | ||
| } | ||
|
|
||
| private static JsonService getJsonService() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This (and its two fields) might be simplified using an Optional field.
If the field is nut null then return its value (which might be null).
Otherwise try to get a service and set the field to an Optional.ofNullable with the service as value.
Might be nicer or not, I don't really know.
This looks OK otherwise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought it would be nicer, but after having implemented some of it, it looks quite the same. One of my assumptions which causes the code to look like what it does here is that the SPI lookup is a relatively expensive operation that you only want to do once, so I want to have a clear separation between "this is null because we haven't initialized it" and "this is null because the lookup failed", so that I don't do a systematic lookup in the latter case assuming we might be in the former.
If I have understood what you are describing correctly, having the Optional doesn't change much to the overall structure, in particular because a lookup failure results in an exception, and not in null, so the try/catch needs to be there even when initializing the Optional (I think)
| status, | ||
| pdData.instance | ||
| ); | ||
| } catch (IOException e) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any chance we have a more specific exception for JSON parsing errors?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately no, more specific exceptions are Jackson or JSON-B specific. Our abstraction layer doesn't define a specific exception beyond IOException (it might be a future improvement though, in the spirit of the ClientHttpException)
This creates the
ProblemDetailsclass, which represents the content of an RFC9457-compliant HTTP error response.To help setting defaults forProblemDetails, this also addsHttpStatusto the public API of theapimodule. Only the HTTP status are part of the API (they will be reused by error-specific exceptions), and theHttpStatusclass also defines a package-private way to build a default Problem Details title based on the HTTP status code.ProblemDetailsencapsulates the SPI lookup required to discover a JSON service used to deserialize RFC9457 error responses. If no JSON parser is available,ProblemDetailsfalls back to providing a minimal object with all its fields nulled except for the type set to a default value and the error code set to the value associated with the response. Unlike what was suggested in the initial design, no default is applied to the title based on the HTTP status phrase, because not all the HTTP client libraries we support expose sur HTTP status phrase.The tests for
ProblemDetailsparsing are in the solid-client module, because it will need the additional json service anyways in the test dependencies for testing the rest of this feature, and putting these tests in the api module creates a circular dependency.The
Responseinterface from theapimodule now also exposes a newBodyHandlerthat will throw on HTTP error. This body handler is composable with another body handler that handles success cases, and projects the HTTP response to the desirable object. Both the exception mapper and the success criterion are configurable. This new body handler throws aClientHttpException, which is a new Exception type parent ofSolidClientException. The HTTP-specific members ofSolidClientExceptionhave been lifted up toClientHttpException.