Skip to content

Commit 27dde17

Browse files
authored
JCL-402: Low-level client throws enriched exceptions (#1157)
`SolidClient` now uses the `throwOnError` body mapper to handle error responses, and provides a custom exception mapper to throw the appropriate specialized exception on HTTP error response. Overall, the behavior of the library doesn't change, the only addition is that the thrown Exception object now has a `ProblemDetails` instance attached to it.
1 parent f18cc8a commit 27dde17

File tree

4 files changed

+382
-129
lines changed

4 files changed

+382
-129
lines changed

api/src/main/java/com/inrupt/client/ProblemDetails.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@
3434
* @see <a href="https://www.rfc-editor.org/rfc/rfc9457">RFC 9457 Problem Details for HTTP APIs</a>
3535
*/
3636
public class ProblemDetails {
37+
/**
38+
* The <a href="https://www.rfc-editor.org/rfc/rfc9457">RFC9457</a> default MIME type.
39+
*/
3740
public static final String MIME_TYPE = "application/problem+json";
41+
/**
42+
* The <a href="https://www.rfc-editor.org/rfc/rfc9457">RFC9457</a> default problem type.
43+
*/
3844
public static final String DEFAULT_TYPE = "about:blank";
3945
private final URI type;
4046
private final String title;
@@ -44,6 +50,15 @@ public class ProblemDetails {
4450
private static JsonService jsonService;
4551
private static boolean isJsonServiceInitialized;
4652

53+
/**
54+
* Build a ProblemDetails instance providing the expected fields as described in
55+
* <a href="https://www.rfc-editor.org/rfc/rfc9457">RFC9457</a>.
56+
* @param type the problem type
57+
* @param title the problem title
58+
* @param details the problem details
59+
* @param status the error response status code
60+
* @param instance the problem instance
61+
*/
4762
public ProblemDetails(
4863
final URI type,
4964
final String title,
@@ -58,22 +73,42 @@ public ProblemDetails(
5873
this.instance = instance;
5974
}
6075

76+
/**
77+
* The problem type.
78+
* @return the type
79+
*/
6180
public URI getType() {
6281
return this.type;
6382
};
6483

84+
/**
85+
* The problem title.
86+
* @return the title
87+
*/
6588
public String getTitle() {
6689
return this.title;
6790
};
6891

92+
/**
93+
* The problem details.
94+
* @return the details
95+
*/
6996
public String getDetails() {
7097
return this.details;
7198
};
7299

100+
/**
101+
* The problem status code.
102+
* @return the status code
103+
*/
73104
public int getStatus() {
74105
return this.status;
75106
};
76107

108+
/**
109+
* The problem instance.
110+
* @return the instance
111+
*/
77112
public URI getInstance() {
78113
return this.instance;
79114
};
@@ -82,10 +117,25 @@ public URI getInstance() {
82117
* This inner class is only ever used for JSON deserialization. Please do not use in any other context.
83118
*/
84119
public static class Data {
120+
/**
121+
* The problem type.
122+
*/
85123
public URI type;
124+
/**
125+
* The problem title.
126+
*/
86127
public String title;
128+
/**
129+
* The problem details.
130+
*/
87131
public String details;
132+
/**
133+
* The problem status code.
134+
*/
88135
public int status;
136+
/**
137+
* The problem instance.
138+
*/
89139
public URI instance;
90140
}
91141

@@ -104,6 +154,13 @@ private static JsonService getJsonService() {
104154
return ProblemDetails.jsonService;
105155
}
106156

157+
/**
158+
* Builds a {@link ProblemDetails} instance from an HTTP error response.
159+
* @param statusCode the HTTP error response status code
160+
* @param headers the HTTP error response headers
161+
* @param body the HTTP error response body
162+
* @return a {@link ProblemDetails} instance
163+
*/
107164
public static ProblemDetails fromErrorResponse(
108165
final int statusCode,
109166
final Headers headers,

api/src/main/java/com/inrupt/client/Response.java

Lines changed: 9 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@
2626
import java.io.InputStream;
2727
import java.net.URI;
2828
import java.nio.ByteBuffer;
29-
import java.nio.charset.StandardCharsets;
30-
import java.util.function.Function;
3129

3230
/**
3331
* An HTTP Response.
@@ -97,6 +95,15 @@ interface ResponseInfo {
9795
ByteBuffer body();
9896
}
9997

98+
/**
99+
* Indicates whether a status code reflects a successful HTTP response.
100+
* @param statusCode the HTTP response status code
101+
* @return true if the status code is in the success range, namely [200, 299].
102+
*/
103+
static boolean isSuccess(final int statusCode) {
104+
return statusCode >= 200 && statusCode < 300;
105+
}
106+
100107
/**
101108
* An interface for mapping an HTTP response into a specific Java type.
102109
* @param <T> the body type
@@ -154,51 +161,6 @@ public static BodyHandler<Void> discarding() {
154161
return responseInfo -> null;
155162
}
156163

157-
/**
158-
* Throws on HTTP error using the provided mapper, or apply the provided body handler.
159-
* @param handler the body handler to apply on non-error HTTP responses
160-
* @param isSuccess a callback determining error cases
161-
* @param exceptionMapper the exception mapper
162-
* @return the body handler
163-
* @param <T> the type of the body handler
164-
*/
165-
public static <T> Response.BodyHandler<T> throwOnError(
166-
final Response.BodyHandler<T> handler,
167-
final Function<Response.ResponseInfo, Boolean> isSuccess,
168-
final Function<Response.ResponseInfo, ClientHttpException> exceptionMapper
169-
) {
170-
return responseinfo -> {
171-
if (!isSuccess.apply(responseinfo)) {
172-
throw exceptionMapper.apply(responseinfo);
173-
}
174-
return handler.apply(responseinfo);
175-
};
176-
}
177-
178-
/**
179-
* Throws on HTTP error, or apply the provided body handler.
180-
* @param handler the body handler to apply on non-error HTTP responses
181-
* @param isSuccess a callback determining error cases
182-
* @return the body handler
183-
* @param <T> the type of the body handler
184-
*/
185-
public static <T> Response.BodyHandler<T> throwOnError(
186-
final Response.BodyHandler<T> handler,
187-
final Function<Response.ResponseInfo, Boolean> isSuccess
188-
) {
189-
final Function<Response.ResponseInfo, ClientHttpException> defaultMapper = responseInfo ->
190-
new ClientHttpException(
191-
"An HTTP error has been returned, with status code " + responseInfo.statusCode(),
192-
responseInfo.uri(),
193-
responseInfo.statusCode(),
194-
responseInfo.headers(),
195-
new String(responseInfo.body().array(), StandardCharsets.UTF_8)
196-
);
197-
return throwOnError(handler, isSuccess, defaultMapper);
198-
}
199-
200-
201-
202164
private BodyHandlers() {
203165
// Prevent instantiation
204166
}

solid/src/main/java/com/inrupt/client/solid/SolidClient.java

Lines changed: 59 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,14 @@
2020
*/
2121
package com.inrupt.client.solid;
2222

23-
import static java.nio.charset.StandardCharsets.UTF_8;
24-
25-
import com.inrupt.client.Client;
26-
import com.inrupt.client.ClientProvider;
27-
import com.inrupt.client.Headers;
28-
import com.inrupt.client.RDFSource;
29-
import com.inrupt.client.Request;
30-
import com.inrupt.client.Resource;
31-
import com.inrupt.client.Response;
32-
import com.inrupt.client.ValidationResult;
23+
import com.inrupt.client.*;
3324
import com.inrupt.client.auth.Session;
3425

3526
import java.io.ByteArrayInputStream;
3627
import java.io.IOException;
3728
import java.io.InputStream;
3829
import java.net.URI;
30+
import java.nio.charset.StandardCharsets;
3931
import java.util.Collections;
4032
import java.util.List;
4133
import java.util.Map;
@@ -131,36 +123,43 @@ public <T extends Resource> CompletionStage<T> read(final URI identifier, final
131123
headers.firstValue(USER_AGENT).ifPresent(agent -> builder.setHeader(USER_AGENT, agent));
132124

133125
final Request request = builder.build();
134-
return client.send(request, Response.BodyHandlers.ofByteArray())
135-
.thenApply(response -> {
136-
if (response.statusCode() >= ERROR_STATUS) {
137-
throw SolidClientException.handle("Unable to read resource at " + request.uri(), request.uri(),
138-
response.statusCode(), response.headers(), new String(response.body()));
139-
} else {
140-
final String contentType = response.headers().firstValue(CONTENT_TYPE)
141-
.orElse("application/octet-stream");
142-
try {
143-
// Check that this is an RDFSoure
144-
if (RDFSource.class.isAssignableFrom(clazz)) {
145-
final Dataset dataset = SolidResourceHandlers.buildDataset(contentType, response.body(),
146-
request.uri().toString()).orElse(null);
147-
final T obj = construct(request.uri(), clazz, dataset, response.headers());
148-
final ValidationResult res = RDFSource.class.cast(obj).validate();
149-
if (!res.isValid()) {
150-
throw new DataMappingException(
151-
"Unable to map resource into type: [" + clazz.getSimpleName() + "] ",
152-
res.getResults());
153-
}
154-
return obj;
155-
// Otherwise, create a non-RDF-bearing resource
156-
} else {
157-
return construct(request.uri(), clazz, contentType,
158-
new ByteArrayInputStream(response.body()), response.headers());
126+
return client.send(
127+
request,
128+
Response.BodyHandlers.ofByteArray()
129+
).thenApply(response -> {
130+
if (!Response.isSuccess(response.statusCode())) {
131+
throw SolidClientException.handle(
132+
"Reading resource failed.",
133+
response.uri(),
134+
response.statusCode(),
135+
response.headers(),
136+
new String(response.body(), StandardCharsets.UTF_8)
137+
);
138+
}
139+
140+
final String contentType = response.headers().firstValue(CONTENT_TYPE)
141+
.orElse("application/octet-stream");
142+
try {
143+
// Check that this is an RDFSoure
144+
if (RDFSource.class.isAssignableFrom(clazz)) {
145+
final Dataset dataset = SolidResourceHandlers.buildDataset(contentType, response.body(),
146+
request.uri().toString()).orElse(null);
147+
final T obj = construct(request.uri(), clazz, dataset, response.headers());
148+
final ValidationResult res = RDFSource.class.cast(obj).validate();
149+
if (!res.isValid()) {
150+
throw new DataMappingException(
151+
"Unable to map resource into type: [" + clazz.getSimpleName() + "] ",
152+
res.getResults());
159153
}
160-
} catch (final ReflectiveOperationException ex) {
161-
throw new SolidResourceException("Unable to read resource into type " + clazz.getName(),
162-
ex);
154+
return obj;
155+
// Otherwise, create a non-RDF-bearing resource
156+
} else {
157+
return construct(request.uri(), clazz, contentType,
158+
new ByteArrayInputStream(response.body()), response.headers());
163159
}
160+
} catch (final ReflectiveOperationException ex) {
161+
throw new SolidResourceException("Unable to read resource into type " + clazz.getName(),
162+
ex);
164163
}
165164
});
166165
}
@@ -280,13 +279,21 @@ public <T extends Resource> CompletionStage<Void> delete(final T resource, final
280279
defaultHeaders.firstValue(USER_AGENT).ifPresent(agent -> builder.setHeader(USER_AGENT, agent));
281280
headers.firstValue(USER_AGENT).ifPresent(agent -> builder.setHeader(USER_AGENT, agent));
282281

283-
return client.send(builder.build(), Response.BodyHandlers.ofByteArray()).thenApply(res -> {
284-
if (isSuccess(res.statusCode())) {
285-
return null;
286-
} else {
287-
throw SolidClientException.handle("Unable to delete resource", resource.getIdentifier(),
288-
res.statusCode(), res.headers(), new String(res.body(), UTF_8));
282+
283+
return client.send(
284+
builder.build(),
285+
Response.BodyHandlers.ofByteArray()
286+
).thenApply(response -> {
287+
if (!Response.isSuccess(response.statusCode())) {
288+
throw SolidClientException.handle(
289+
"Deleting resource failed.",
290+
response.uri(),
291+
response.statusCode(),
292+
response.headers(),
293+
new String(response.body(), StandardCharsets.UTF_8)
294+
);
289295
}
296+
return null;
290297
});
291298
}
292299

@@ -370,9 +377,14 @@ public SolidClient build() {
370377
<T extends Resource> Function<Response<byte[]>, CompletionStage<T>> handleResponse(final T resource,
371378
final Headers headers, final String message) {
372379
return res -> {
373-
if (!isSuccess(res.statusCode())) {
374-
throw SolidClientException.handle(message, resource.getIdentifier(),
375-
res.statusCode(), res.headers(), new String(res.body(), UTF_8));
380+
if (!Response.isSuccess(res.statusCode())) {
381+
throw SolidClientException.handle(
382+
message,
383+
resource.getIdentifier(),
384+
res.statusCode(),
385+
res.headers(),
386+
new String(res.body(), StandardCharsets.UTF_8)
387+
);
376388
}
377389

378390
if (!fetchAfterWrite) {
@@ -382,7 +394,6 @@ <T extends Resource> Function<Response<byte[]>, CompletionStage<T>> handleRespon
382394
@SuppressWarnings("unchecked")
383395
final Class<T> clazz = (Class<T>) resource.getClass();
384396
return read(resource.getIdentifier(), headers, clazz);
385-
386397
};
387398
}
388399

@@ -445,10 +456,6 @@ static void decorateHeaders(final Request.Builder builder, final Headers headers
445456
}
446457
}
447458

448-
static boolean isSuccess(final int statusCode) {
449-
return statusCode >= 200 && statusCode < 300;
450-
}
451-
452459
static Request.BodyPublisher cast(final Resource resource) {
453460
try {
454461
return Request.BodyPublishers.ofInputStream(resource.getEntity());

0 commit comments

Comments
 (0)