diff --git a/api/src/main/java/com/inrupt/client/ProblemDetails.java b/api/src/main/java/com/inrupt/client/ProblemDetails.java
index 998dd534980..6ea244f92eb 100644
--- a/api/src/main/java/com/inrupt/client/ProblemDetails.java
+++ b/api/src/main/java/com/inrupt/client/ProblemDetails.java
@@ -34,7 +34,13 @@
* @see RFC 9457 Problem Details for HTTP APIs
*/
public class ProblemDetails {
+ /**
+ * The RFC9457 default MIME type.
+ */
public static final String MIME_TYPE = "application/problem+json";
+ /**
+ * The RFC9457 default problem type.
+ */
public static final String DEFAULT_TYPE = "about:blank";
private final URI type;
private final String title;
@@ -44,6 +50,15 @@ public class ProblemDetails {
private static JsonService jsonService;
private static boolean isJsonServiceInitialized;
+ /**
+ * Build a ProblemDetails instance providing the expected fields as described in
+ * RFC9457.
+ * @param type the problem type
+ * @param title the problem title
+ * @param details the problem details
+ * @param status the error response status code
+ * @param instance the problem instance
+ */
public ProblemDetails(
final URI type,
final String title,
@@ -58,22 +73,42 @@ public ProblemDetails(
this.instance = instance;
}
+ /**
+ * The problem type.
+ * @return the type
+ */
public URI getType() {
return this.type;
};
+ /**
+ * The problem title.
+ * @return the title
+ */
public String getTitle() {
return this.title;
};
+ /**
+ * The problem details.
+ * @return the details
+ */
public String getDetails() {
return this.details;
};
+ /**
+ * The problem status code.
+ * @return the status code
+ */
public int getStatus() {
return this.status;
};
+ /**
+ * The problem instance.
+ * @return the instance
+ */
public URI getInstance() {
return this.instance;
};
@@ -82,10 +117,25 @@ public URI getInstance() {
* This inner class is only ever used for JSON deserialization. Please do not use in any other context.
*/
public static class Data {
+ /**
+ * The problem type.
+ */
public URI type;
+ /**
+ * The problem title.
+ */
public String title;
+ /**
+ * The problem details.
+ */
public String details;
+ /**
+ * The problem status code.
+ */
public int status;
+ /**
+ * The problem instance.
+ */
public URI instance;
}
@@ -104,6 +154,13 @@ private static JsonService getJsonService() {
return ProblemDetails.jsonService;
}
+ /**
+ * Builds a {@link ProblemDetails} instance from an HTTP error response.
+ * @param statusCode the HTTP error response status code
+ * @param headers the HTTP error response headers
+ * @param body the HTTP error response body
+ * @return a {@link ProblemDetails} instance
+ */
public static ProblemDetails fromErrorResponse(
final int statusCode,
final Headers headers,
diff --git a/api/src/main/java/com/inrupt/client/Response.java b/api/src/main/java/com/inrupt/client/Response.java
index 30acaffa1d7..38bbd540163 100644
--- a/api/src/main/java/com/inrupt/client/Response.java
+++ b/api/src/main/java/com/inrupt/client/Response.java
@@ -26,8 +26,6 @@
import java.io.InputStream;
import java.net.URI;
import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-import java.util.function.Function;
/**
* An HTTP Response.
@@ -97,6 +95,15 @@ interface ResponseInfo {
ByteBuffer body();
}
+ /**
+ * Indicates whether a status code reflects a successful HTTP response.
+ * @param statusCode the HTTP response status code
+ * @return true if the status code is in the success range, namely [200, 299].
+ */
+ static boolean isSuccess(final int statusCode) {
+ return statusCode >= 200 && statusCode < 300;
+ }
+
/**
* An interface for mapping an HTTP response into a specific Java type.
* @param the body type
@@ -154,51 +161,6 @@ public static BodyHandler discarding() {
return responseInfo -> null;
}
- /**
- * Throws on HTTP error using the provided mapper, or apply the provided body handler.
- * @param handler the body handler to apply on non-error HTTP responses
- * @param isSuccess a callback determining error cases
- * @param exceptionMapper the exception mapper
- * @return the body handler
- * @param the type of the body handler
- */
- public static Response.BodyHandler throwOnError(
- final Response.BodyHandler handler,
- final Function isSuccess,
- final Function exceptionMapper
- ) {
- return responseinfo -> {
- if (!isSuccess.apply(responseinfo)) {
- throw exceptionMapper.apply(responseinfo);
- }
- return handler.apply(responseinfo);
- };
- }
-
- /**
- * Throws on HTTP error, or apply the provided body handler.
- * @param handler the body handler to apply on non-error HTTP responses
- * @param isSuccess a callback determining error cases
- * @return the body handler
- * @param the type of the body handler
- */
- public static Response.BodyHandler throwOnError(
- final Response.BodyHandler handler,
- final Function isSuccess
- ) {
- final Function defaultMapper = responseInfo ->
- new ClientHttpException(
- "An HTTP error has been returned, with status code " + responseInfo.statusCode(),
- responseInfo.uri(),
- responseInfo.statusCode(),
- responseInfo.headers(),
- new String(responseInfo.body().array(), StandardCharsets.UTF_8)
- );
- return throwOnError(handler, isSuccess, defaultMapper);
- }
-
-
-
private BodyHandlers() {
// Prevent instantiation
}
diff --git a/solid/src/main/java/com/inrupt/client/solid/SolidClient.java b/solid/src/main/java/com/inrupt/client/solid/SolidClient.java
index 2b20c3a5105..d13adacbe47 100644
--- a/solid/src/main/java/com/inrupt/client/solid/SolidClient.java
+++ b/solid/src/main/java/com/inrupt/client/solid/SolidClient.java
@@ -20,22 +20,14 @@
*/
package com.inrupt.client.solid;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.inrupt.client.Client;
-import com.inrupt.client.ClientProvider;
-import com.inrupt.client.Headers;
-import com.inrupt.client.RDFSource;
-import com.inrupt.client.Request;
-import com.inrupt.client.Resource;
-import com.inrupt.client.Response;
-import com.inrupt.client.ValidationResult;
+import com.inrupt.client.*;
import com.inrupt.client.auth.Session;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -131,36 +123,43 @@ public CompletionStage read(final URI identifier, final
headers.firstValue(USER_AGENT).ifPresent(agent -> builder.setHeader(USER_AGENT, agent));
final Request request = builder.build();
- return client.send(request, Response.BodyHandlers.ofByteArray())
- .thenApply(response -> {
- if (response.statusCode() >= ERROR_STATUS) {
- throw SolidClientException.handle("Unable to read resource at " + request.uri(), request.uri(),
- response.statusCode(), response.headers(), new String(response.body()));
- } else {
- final String contentType = response.headers().firstValue(CONTENT_TYPE)
- .orElse("application/octet-stream");
- try {
- // Check that this is an RDFSoure
- if (RDFSource.class.isAssignableFrom(clazz)) {
- final Dataset dataset = SolidResourceHandlers.buildDataset(contentType, response.body(),
- request.uri().toString()).orElse(null);
- final T obj = construct(request.uri(), clazz, dataset, response.headers());
- final ValidationResult res = RDFSource.class.cast(obj).validate();
- if (!res.isValid()) {
- throw new DataMappingException(
- "Unable to map resource into type: [" + clazz.getSimpleName() + "] ",
- res.getResults());
- }
- return obj;
- // Otherwise, create a non-RDF-bearing resource
- } else {
- return construct(request.uri(), clazz, contentType,
- new ByteArrayInputStream(response.body()), response.headers());
+ return client.send(
+ request,
+ Response.BodyHandlers.ofByteArray()
+ ).thenApply(response -> {
+ if (!Response.isSuccess(response.statusCode())) {
+ throw SolidClientException.handle(
+ "Reading resource failed.",
+ response.uri(),
+ response.statusCode(),
+ response.headers(),
+ new String(response.body(), StandardCharsets.UTF_8)
+ );
+ }
+
+ final String contentType = response.headers().firstValue(CONTENT_TYPE)
+ .orElse("application/octet-stream");
+ try {
+ // Check that this is an RDFSoure
+ if (RDFSource.class.isAssignableFrom(clazz)) {
+ final Dataset dataset = SolidResourceHandlers.buildDataset(contentType, response.body(),
+ request.uri().toString()).orElse(null);
+ final T obj = construct(request.uri(), clazz, dataset, response.headers());
+ final ValidationResult res = RDFSource.class.cast(obj).validate();
+ if (!res.isValid()) {
+ throw new DataMappingException(
+ "Unable to map resource into type: [" + clazz.getSimpleName() + "] ",
+ res.getResults());
}
- } catch (final ReflectiveOperationException ex) {
- throw new SolidResourceException("Unable to read resource into type " + clazz.getName(),
- ex);
+ return obj;
+ // Otherwise, create a non-RDF-bearing resource
+ } else {
+ return construct(request.uri(), clazz, contentType,
+ new ByteArrayInputStream(response.body()), response.headers());
}
+ } catch (final ReflectiveOperationException ex) {
+ throw new SolidResourceException("Unable to read resource into type " + clazz.getName(),
+ ex);
}
});
}
@@ -280,13 +279,21 @@ public CompletionStage delete(final T resource, final
defaultHeaders.firstValue(USER_AGENT).ifPresent(agent -> builder.setHeader(USER_AGENT, agent));
headers.firstValue(USER_AGENT).ifPresent(agent -> builder.setHeader(USER_AGENT, agent));
- return client.send(builder.build(), Response.BodyHandlers.ofByteArray()).thenApply(res -> {
- if (isSuccess(res.statusCode())) {
- return null;
- } else {
- throw SolidClientException.handle("Unable to delete resource", resource.getIdentifier(),
- res.statusCode(), res.headers(), new String(res.body(), UTF_8));
+
+ return client.send(
+ builder.build(),
+ Response.BodyHandlers.ofByteArray()
+ ).thenApply(response -> {
+ if (!Response.isSuccess(response.statusCode())) {
+ throw SolidClientException.handle(
+ "Deleting resource failed.",
+ response.uri(),
+ response.statusCode(),
+ response.headers(),
+ new String(response.body(), StandardCharsets.UTF_8)
+ );
}
+ return null;
});
}
@@ -370,9 +377,14 @@ public SolidClient build() {
Function, CompletionStage> handleResponse(final T resource,
final Headers headers, final String message) {
return res -> {
- if (!isSuccess(res.statusCode())) {
- throw SolidClientException.handle(message, resource.getIdentifier(),
- res.statusCode(), res.headers(), new String(res.body(), UTF_8));
+ if (!Response.isSuccess(res.statusCode())) {
+ throw SolidClientException.handle(
+ message,
+ resource.getIdentifier(),
+ res.statusCode(),
+ res.headers(),
+ new String(res.body(), StandardCharsets.UTF_8)
+ );
}
if (!fetchAfterWrite) {
@@ -382,7 +394,6 @@ Function, CompletionStage> handleRespon
@SuppressWarnings("unchecked")
final Class clazz = (Class) resource.getClass();
return read(resource.getIdentifier(), headers, clazz);
-
};
}
@@ -445,10 +456,6 @@ static void decorateHeaders(final Request.Builder builder, final Headers headers
}
}
- static boolean isSuccess(final int statusCode) {
- return statusCode >= 200 && statusCode < 300;
- }
-
static Request.BodyPublisher cast(final Resource resource) {
try {
return Request.BodyPublishers.ofInputStream(resource.getEntity());
diff --git a/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java b/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java
index fab400019f5..dbe200c135b 100644
--- a/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java
+++ b/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java
@@ -22,17 +22,18 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
-import com.inrupt.client.ClientProvider;
-import com.inrupt.client.Headers;
-import com.inrupt.client.Request;
-import com.inrupt.client.Response;
+import com.inrupt.client.*;
import com.inrupt.client.auth.Session;
+import com.inrupt.client.jackson.JacksonService;
+import com.inrupt.client.spi.JsonService;
import com.inrupt.client.spi.RDFFactory;
import com.inrupt.client.util.URIBuilder;
import com.inrupt.client.vocabulary.PIM;
import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
@@ -57,6 +58,7 @@ class SolidClientTest {
private static final Map config = new HashMap<>();
private static final RDF rdf = RDFFactory.getInstance();
private static final SolidClient client = SolidClient.getClient().session(Session.anonymous());
+ private static final JsonService jsonService = new JacksonService();
@BeforeAll
static void setup() {
@@ -366,20 +368,15 @@ void testExceptionalResources(
private static Stream testExceptionalResources() {
return Stream.of(
- Arguments.of(
- URI.create(config.get("solid_resource_uri") + "/unauthorized"), 401,
- UnauthorizedException.class),
- Arguments.of(
- URI.create(config.get("solid_resource_uri") + "/forbidden"), 403,
- ForbiddenException.class),
- Arguments.of(
- URI.create(config.get("solid_resource_uri") + "/missing"), 404,
- NotFoundException.class));
+ arguments(URI.create(config.get("solid_resource_uri") + "/unauthorized"), 401, UnauthorizedException.class),
+ arguments(URI.create(config.get("solid_resource_uri") + "/forbidden"), 403, ForbiddenException.class),
+ arguments(URI.create(config.get("solid_resource_uri") + "/missing"), 404, NotFoundException.class)
+ );
}
@ParameterizedTest
@MethodSource
- void testSpecialisedExceptions(
+ void testLegacyExceptions(
final Class clazz,
final int statusCode
) {
@@ -387,20 +384,215 @@ void testSpecialisedExceptions(
final SolidClient solidClient = new SolidClient(ClientProvider.getClient(), headers, false);
final SolidContainer resource = new SolidContainer(URI.create("http://example.com"));
+ final SolidClientException exception = assertThrows(
+ clazz,
+ () -> solidClient.handleResponse(resource, headers, "message")
+ .apply(new Response() {
+ @Override
+ public byte[] body() {
+ return new byte[0];
+ }
+
+ @Override
+ public Headers headers() {
+ return null;
+ }
+
+ @Override
+ public URI uri() {
+ return null;
+ }
+
+ @Override
+ public int statusCode() {
+ return statusCode;
+ }
+ })
+ );
+ assertEquals(statusCode, exception.getStatusCode());
+ // The following assertions check that in absence of an RFC9457 compliant response, we properly initialize the
+ // default values for the attached Problem Details.
+ assertEquals(ProblemDetails.DEFAULT_TYPE, exception.getProblemDetails().getType().toString());
+ assertEquals(statusCode, exception.getProblemDetails().getStatus());
+ assertNull(exception.getProblemDetails().getTitle());
+ assertNull(exception.getProblemDetails().getDetails());
+ assertNull(exception.getProblemDetails().getInstance());
+ }
+
+ private static Stream testLegacyExceptions() {
+ return Stream.of(
+ arguments(BadRequestException.class, 400),
+ arguments(UnauthorizedException.class, 401),
+ arguments(ForbiddenException.class, 403),
+ arguments(NotFoundException.class, 404),
+ arguments(MethodNotAllowedException.class, 405),
+ arguments(NotAcceptableException.class, 406),
+ arguments(ConflictException.class, 409),
+ arguments(GoneException.class, 410),
+ arguments(PreconditionFailedException.class, 412),
+ arguments(UnsupportedMediaTypeException.class, 415),
+ arguments(TooManyRequestsException.class, 429),
+ arguments(InternalServerErrorException.class, 500),
+ arguments(SolidClientException.class, 418),
+ arguments(SolidClientException.class,599),
+ arguments(SolidClientException.class,600)
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void testRfc9457Exceptions(
+ final Class clazz,
+ final ProblemDetails problemDetails
+ ) {
+ final Headers headers = Headers.of(Collections.singletonMap("x-key", Arrays.asList("value")));
+ final SolidClient solidClient = new SolidClient(ClientProvider.getClient(), headers, false);
+ final SolidContainer resource = new SolidContainer(URI.create("http://example.com"));
+
final SolidClientException exception = assertThrows(
clazz,
() -> solidClient.handleResponse(resource, headers, "message")
.apply(new Response() {
@Override
public byte[] body() {
- return new byte[0];
+ try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+ jsonService.toJson(problemDetails, bos);
+ return bos.toByteArray();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
}
@Override
public Headers headers() {
+ final List headerValues = new ArrayList<>();
+ headerValues.add("application/problem+json");
+ final Map> headerMap = new HashMap<>();
+ headerMap.put("Content-Type", headerValues);
+ return Headers.of(headerMap);
+ }
+
+ @Override
+ public URI uri() {
return null;
}
+ @Override
+ public int statusCode() {
+ return problemDetails.getStatus();
+ }
+ })
+ );
+ assertEquals(problemDetails.getStatus(), exception.getStatusCode());
+ assertEquals(problemDetails.getType(), exception.getProblemDetails().getType());
+ assertEquals(problemDetails.getTitle(), exception.getProblemDetails().getTitle());
+ assertEquals(problemDetails.getStatus(), exception.getProblemDetails().getStatus());
+ assertEquals(problemDetails.getDetails(), exception.getProblemDetails().getDetails());
+ assertEquals(problemDetails.getInstance(), exception.getProblemDetails().getInstance());
+ }
+
+ private static ProblemDetails mockProblemDetails(final String title, final String details, final int status) {
+ return new ProblemDetails(URI.create("https://example.org/type"),
+ title,
+ details,
+ status,
+ URI.create("https://example.org/instance")
+ );
+ }
+
+ private static Stream testRfc9457Exceptions() {
+ return Stream.of(
+ arguments(
+ BadRequestException.class,
+ mockProblemDetails("Bad Request", "Some details", 400)
+ ),
+ arguments(
+ UnauthorizedException.class,
+ mockProblemDetails("Unauthorized", "Some details", 401)
+ ),
+ arguments(
+ ForbiddenException.class,
+ mockProblemDetails("Forbidden", "Some details", 403)
+ ),
+ arguments(
+ NotFoundException.class,
+ mockProblemDetails("Not Found", "Some details", 404)
+ ),
+ arguments(
+ MethodNotAllowedException.class,
+ mockProblemDetails("Method Not Allowed", "Some details", 405)
+ ),
+ arguments(
+ NotAcceptableException.class,
+ mockProblemDetails("Not Acceptable", "Some details", 406)
+ ),
+ arguments(
+ ConflictException.class,
+ mockProblemDetails("Conflict", "Some details", 409)
+ ),
+ arguments(
+ GoneException.class,
+ mockProblemDetails("Gone", "Some details", 410)
+ ),
+ arguments(
+ PreconditionFailedException.class,
+ mockProblemDetails("Precondition Failed", "Some details", 412)
+ ),
+ arguments(
+ UnsupportedMediaTypeException.class,
+ mockProblemDetails("Unsupported Media Type", "Some details", 415)
+ ),
+ arguments(
+ TooManyRequestsException.class,
+ mockProblemDetails("Too Many Requests", "Some details", 429)
+ ),
+ arguments(
+ InternalServerErrorException.class,
+ mockProblemDetails("Internal Server Error", "Some details", 500)
+ ),
+ arguments(
+ // Custom errors that do not map to a predefined Exception class
+ // default to the generic SolidClientException
+ SolidClientException.class,
+ mockProblemDetails("I'm a Teapot", "Some details", 418)
+ ),
+ arguments(
+ // Custom errors that do not map to a predefined Exception class
+ // default to the generic SolidClientException.
+ SolidClientException.class,
+ mockProblemDetails("Custom server error", "Some details", 599)
+ )
+ );
+ }
+
+ @Test
+ void testMalformedProblemDetails() {
+ // The specific error code is irrelevant to this test.
+ final int statusCode = 400;
+ final Headers headers = Headers.of(Collections.singletonMap("x-key", Arrays.asList("value")));
+ final SolidClient solidClient = new SolidClient(ClientProvider.getClient(), headers, false);
+ final SolidContainer resource = new SolidContainer(URI.create("http://example.com"));
+
+ final SolidClientException exception = assertThrows(
+ BadRequestException.class,
+ () -> solidClient.handleResponse(resource, headers, "message")
+ .apply(new Response() {
+ // Pretend we return RFC9457 content...
+ @Override
+ public Headers headers() {
+ final List headerValues = new ArrayList<>();
+ headerValues.add("application/problem+json");
+ final Map> headerMap = new HashMap<>();
+ headerMap.put("Content-Type", headerValues);
+ return Headers.of(headerMap);
+ }
+
+ // ... but actually return malformed JSON.
+ @Override
+ public byte[] body() {
+ return "This isn't valid application/problem+json.".getBytes();
+ }
+
@Override
public URI uri() {
return null;
@@ -413,23 +605,58 @@ public int statusCode() {
})
);
assertEquals(statusCode, exception.getStatusCode());
+ // On malformed response, the ProblemDetails should fall back to defaults.
+ assertEquals(ProblemDetails.DEFAULT_TYPE, exception.getProblemDetails().getType().toString());
+ assertNull(exception.getProblemDetails().getTitle());
+ assertEquals(statusCode, exception.getProblemDetails().getStatus());
+ assertNull(exception.getProblemDetails().getDetails());
+ assertNull(exception.getProblemDetails().getInstance());
}
- private static Stream testSpecialisedExceptions() {
- return Stream.of(
- Arguments.of(BadRequestException.class, 400),
- Arguments.of(UnauthorizedException.class, 401),
- Arguments.of(ForbiddenException.class, 403),
- Arguments.of(NotFoundException.class, 404),
- Arguments.of(MethodNotAllowedException.class, 405),
- Arguments.of(NotAcceptableException.class, 406),
- Arguments.of(ConflictException.class, 409),
- Arguments.of(GoneException.class, 410),
- Arguments.of(PreconditionFailedException.class, 412),
- Arguments.of(UnsupportedMediaTypeException.class, 415),
- Arguments.of(TooManyRequestsException.class, 429),
- Arguments.of(InternalServerErrorException.class, 500),
- Arguments.of(SolidClientException.class, 418)
+ @Test
+ void testMinimalProblemDetails() {
+ // The specific error code is irrelevant to this test.
+ final int statusCode = 400;
+ final Headers headers = Headers.of(Collections.singletonMap("x-key", Arrays.asList("value")));
+ final SolidClient solidClient = new SolidClient(ClientProvider.getClient(), headers, false);
+ final SolidContainer resource = new SolidContainer(URI.create("http://example.com"));
+
+ final SolidClientException exception = assertThrows(
+ BadRequestException.class,
+ () -> solidClient.handleResponse(resource, headers, "message")
+ .apply(new Response() {
+ @Override
+ public Headers headers() {
+ final List headerValues = new ArrayList<>();
+ headerValues.add("application/problem+json");
+ final Map> headerMap = new HashMap<>();
+ headerMap.put("Content-Type", headerValues);
+ return Headers.of(headerMap);
+ }
+
+ // Return minimal problem details..
+ @Override
+ public byte[] body() {
+ return "{\"status\":400}".getBytes();
+ }
+
+ @Override
+ public URI uri() {
+ return null;
+ }
+
+ @Override
+ public int statusCode() {
+ return statusCode;
+ }
+ })
);
+ assertEquals(statusCode, exception.getStatusCode());
+ // On malformed response, the ProblemDetails should fall back to defaults.
+ assertEquals(ProblemDetails.DEFAULT_TYPE, exception.getProblemDetails().getType().toString());
+ assertNull(exception.getProblemDetails().getTitle());
+ assertEquals(statusCode, exception.getProblemDetails().getStatus());
+ assertNull(exception.getProblemDetails().getDetails());
+ assertNull(exception.getProblemDetails().getInstance());
}
}