diff --git a/api/src/main/java/com/inrupt/client/ClientHttpException.java b/api/src/main/java/com/inrupt/client/ClientHttpException.java
new file mode 100644
index 00000000000..38313f0d465
--- /dev/null
+++ b/api/src/main/java/com/inrupt/client/ClientHttpException.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright Inrupt Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+ * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+ * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.inrupt.client;
+
+import java.net.URI;
+
+/**
+ * A runtime exception representing an HTTP error response carrying a structured representation of the problem. The
+ * problem description is embedded in a {@link ProblemDetails} instance.
+ */
+public class ClientHttpException extends InruptClientException {
+ private final ProblemDetails problemDetails;
+ private final URI uri;
+ private final int statusCode;
+ private final String body;
+ private final transient Headers headers;
+
+ /**
+ * Create a ClientHttpException.
+ * @param message the exception message
+ * @param uri the error response URI
+ * @param statusCode the error response status code
+ * @param headers the error response headers
+ * @param body the error response body
+ */
+ public ClientHttpException(final String message, final URI uri, final int statusCode,
+ final Headers headers, final String body) {
+ super(message);
+ this.uri = uri;
+ this.statusCode = statusCode;
+ this.headers = headers;
+ this.body = body;
+ this.problemDetails = ProblemDetails.fromErrorResponse(statusCode, headers, body.getBytes());
+ }
+
+ /**
+ * Retrieve the URI associated with this exception.
+ *
+ * @return the uri
+ */
+ public URI getUri() {
+ return uri;
+ }
+
+ /**
+ * Retrieve the status code associated with this exception.
+ *
+ * @return the status code
+ */
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ /**
+ * Retrieve the headers associated with this exception.
+ *
+ * @return the headers
+ */
+ public Headers getHeaders() {
+ return headers;
+ }
+
+ /**
+ * Retrieve the body associated with this exception.
+ *
+ * @return the body
+ */
+ public String getBody() {
+ return body;
+ }
+
+ /**
+ * Retrieve the {@link ProblemDetails} instance describing the HTTP error response.
+ * @return the problem details object
+ */
+ public ProblemDetails getProblemDetails() {
+ return this.problemDetails;
+ }
+
+}
diff --git a/api/src/main/java/com/inrupt/client/ProblemDetails.java b/api/src/main/java/com/inrupt/client/ProblemDetails.java
new file mode 100644
index 00000000000..998dd534980
--- /dev/null
+++ b/api/src/main/java/com/inrupt/client/ProblemDetails.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright Inrupt Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+ * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+ * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.inrupt.client;
+
+import com.inrupt.client.spi.JsonService;
+import com.inrupt.client.spi.ServiceProvider;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.util.Optional;
+
+/**
+ * A data class representing a structured problem description sent by the server on error response.
+ *
+ * @see RFC 9457 Problem Details for HTTP APIs
+ */
+public class ProblemDetails {
+ public static final String MIME_TYPE = "application/problem+json";
+ public static final String DEFAULT_TYPE = "about:blank";
+ private final URI type;
+ private final String title;
+ private final String details;
+ private final int status;
+ private final URI instance;
+ private static JsonService jsonService;
+ private static boolean isJsonServiceInitialized;
+
+ public ProblemDetails(
+ final URI type,
+ final String title,
+ final String details,
+ final int status,
+ final URI instance
+ ) {
+ this.type = type;
+ this.title = title;
+ this.details = details;
+ this.status = status;
+ this.instance = instance;
+ }
+
+ public URI getType() {
+ return this.type;
+ };
+
+ public String getTitle() {
+ return this.title;
+ };
+
+ public String getDetails() {
+ return this.details;
+ };
+
+ public int getStatus() {
+ return this.status;
+ };
+
+ public URI getInstance() {
+ return this.instance;
+ };
+
+ /**
+ * This inner class is only ever used for JSON deserialization. Please do not use in any other context.
+ */
+ public static class Data {
+ public URI type;
+ public String title;
+ public String details;
+ public int status;
+ public URI instance;
+ }
+
+ private static JsonService getJsonService() {
+ if (ProblemDetails.isJsonServiceInitialized) {
+ return ProblemDetails.jsonService;
+ }
+ // It is a legitimate use case that this is loaded in a context where no implementation of the JSON service is
+ // available. On SPI lookup failure, the ProblemDetails exceptions will fall back to default and not be parsed.
+ try {
+ ProblemDetails.jsonService = ServiceProvider.getJsonService();
+ } catch (IllegalStateException e) {
+ ProblemDetails.jsonService = null;
+ }
+ ProblemDetails.isJsonServiceInitialized = true;
+ return ProblemDetails.jsonService;
+ }
+
+ public static ProblemDetails fromErrorResponse(
+ final int statusCode,
+ final Headers headers,
+ final byte[] body
+ ) {
+ final JsonService jsonService = getJsonService();
+ if (jsonService == null
+ || (headers != null && !headers.allValues("Content-Type").contains(ProblemDetails.MIME_TYPE))) {
+ return new ProblemDetails(
+ URI.create(ProblemDetails.DEFAULT_TYPE),
+ null,
+ null,
+ statusCode,
+ null
+ );
+ }
+ try {
+ final Data pdData = jsonService.fromJson(
+ new ByteArrayInputStream(body),
+ Data.class
+ );
+ final URI type = Optional.ofNullable(pdData.type)
+ .orElse(URI.create(ProblemDetails.DEFAULT_TYPE));
+ // JSON mappers map invalid integers to 0, which is an invalid value in our case anyway.
+ final int status = Optional.of(pdData.status).filter(s -> s != 0).orElse(statusCode);
+ return new ProblemDetails(
+ type,
+ pdData.title,
+ pdData.details,
+ status,
+ pdData.instance
+ );
+ } catch (IOException e) {
+ return new ProblemDetails(
+ URI.create(ProblemDetails.DEFAULT_TYPE),
+ null,
+ null,
+ statusCode,
+ null
+ );
+ }
+ }
+}
diff --git a/api/src/main/java/com/inrupt/client/Response.java b/api/src/main/java/com/inrupt/client/Response.java
index c8398c04c45..30acaffa1d7 100644
--- a/api/src/main/java/com/inrupt/client/Response.java
+++ b/api/src/main/java/com/inrupt/client/Response.java
@@ -26,6 +26,8 @@
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.
@@ -152,6 +154,51 @@ 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/pom.xml b/solid/pom.xml
index 15163ab2e65..3be4e20184b 100644
--- a/solid/pom.xml
+++ b/solid/pom.xml
@@ -79,6 +79,12 @@
${project.version}
test
+
+ com.inrupt.client
+ inrupt-client-jackson
+ ${project.version}
+ test
+
org.slf4j
slf4j-api
diff --git a/solid/src/main/java/com/inrupt/client/solid/SolidClientException.java b/solid/src/main/java/com/inrupt/client/solid/SolidClientException.java
index 668df265d3d..bf6decd11ee 100644
--- a/solid/src/main/java/com/inrupt/client/solid/SolidClientException.java
+++ b/solid/src/main/java/com/inrupt/client/solid/SolidClientException.java
@@ -20,23 +20,18 @@
*/
package com.inrupt.client.solid;
+import com.inrupt.client.ClientHttpException;
import com.inrupt.client.Headers;
-import com.inrupt.client.InruptClientException;
import java.net.URI;
/**
* A runtime exception for use with SolidClient HTTP operations.
*/
-public class SolidClientException extends InruptClientException {
+public class SolidClientException extends ClientHttpException {
private static final long serialVersionUID = 2868432164225689934L;
- private final URI uri;
- private final int statusCode;
- private final String body;
- private final transient Headers headers;
-
/**
* Create a SolidClient exception.
*
@@ -48,49 +43,18 @@ public class SolidClientException extends InruptClientException {
*/
public SolidClientException(final String message, final URI uri, final int statusCode,
final Headers headers, final String body) {
- super(message);
- this.uri = uri;
- this.statusCode = statusCode;
- this.headers = headers;
- this.body = body;
- }
-
- /**
- * Retrieve the URI associated with this exception.
- *
- * @return the uri
- */
- public URI getUri() {
- return uri;
- }
-
- /**
- * Retrieve the status code associated with this exception.
- *
- * @return the status code
- */
- public int getStatusCode() {
- return statusCode;
+ super(message, uri, statusCode, headers, body);
}
/**
- * Retrieve the headers associated with this exception.
*
- * @return the headers
+ * @param message the resulting exception message
+ * @param uri the request URL
+ * @param statusCode the response status code
+ * @param headers the response {@link Headers}
+ * @param body the response body
+ * @return an appropriate exception based on the status code.
*/
- public Headers getHeaders() {
- return headers;
- }
-
- /**
- * Retrieve the body associated with this exception.
- *
- * @return the body
- */
- public String getBody() {
- return body;
- }
-
public static SolidClientException handle(
final String message,
final URI uri,
diff --git a/solid/src/test/java/com/inrupt/client/solid/ProblemDetailsTest.java b/solid/src/test/java/com/inrupt/client/solid/ProblemDetailsTest.java
new file mode 100644
index 00000000000..68e007a76e7
--- /dev/null
+++ b/solid/src/test/java/com/inrupt/client/solid/ProblemDetailsTest.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright Inrupt Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+ * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+ * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.inrupt.client.solid;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import com.inrupt.client.Headers;
+import com.inrupt.client.ProblemDetails;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+// Ideally, this class should be in the api module, but it creates
+// a circular dependency with the JSON module implementation.
+public class ProblemDetailsTest {
+ Headers mockProblemDetailsHeader() {
+ final List headerValues = new ArrayList<>();
+ headerValues.add("application/problem+json");
+ final Map> headerMap = new HashMap<>();
+ headerMap.put("Content-Type", headerValues);
+ return Headers.of(headerMap);
+ }
+
+ @Test
+ void testEmptyProblemDetails() {
+ final int statusCode = 400;
+ final ProblemDetails pd = ProblemDetails.fromErrorResponse(
+ statusCode,
+ mockProblemDetailsHeader(),
+ "{}".getBytes()
+ );
+ assertEquals(ProblemDetails.DEFAULT_TYPE, pd.getType().toString());
+ assertEquals(statusCode, pd.getStatus());
+ assertNull(pd.getTitle());
+ assertNull(pd.getDetails());
+ assertNull(pd.getInstance());
+ }
+ @Test
+ void testCompleteProblemDetails() {
+ final int statusCode = 400;
+ final ProblemDetails pd = ProblemDetails.fromErrorResponse(
+ statusCode,
+ mockProblemDetailsHeader(),
+ ("{" +
+ "\"title\":\"Some title\"," +
+ "\"status\":400," +
+ "\"details\":\"Some details\"," +
+ "\"instance\":\"https://example.org/instance\"," +
+ "\"type\":\"https://example.org/type\"" +
+ "}").getBytes()
+ );
+ assertEquals("https://example.org/type", pd.getType().toString());
+ assertEquals(statusCode, pd.getStatus());
+ Assertions.assertEquals("Some title", pd.getTitle());
+ assertEquals("Some details", pd.getDetails());
+ assertEquals("https://example.org/instance", pd.getInstance().toString());
+ }
+
+ @Test
+ void testIgnoreUnknownProblemDetails() {
+ final int statusCode = 400;
+ final ProblemDetails pd = ProblemDetails.fromErrorResponse(
+ statusCode,
+ mockProblemDetailsHeader(),
+ ("{" +
+ "\"title\":\"Some title\"," +
+ "\"status\":400," +
+ "\"details\":\"Some details\"," +
+ "\"instance\":\"https://example.org/instance\"," +
+ "\"type\":\"https://example.org/type\"," +
+ "\"unknown\":\"Some unknown property\"" +
+ "}").getBytes()
+ );
+ assertEquals("https://example.org/type", pd.getType().toString());
+ assertEquals(statusCode, pd.getStatus());
+ Assertions.assertEquals("Some title", pd.getTitle());
+ assertEquals("Some details", pd.getDetails());
+ assertEquals("https://example.org/instance", pd.getInstance().toString());
+ }
+
+ @Test
+ void testInvalidStatusProblemDetails() {
+ final int statusCode = 400;
+ final ProblemDetails pd = ProblemDetails.fromErrorResponse(
+ statusCode,
+ mockProblemDetailsHeader(),
+ ("{" +
+ "\"status\":\"Some invalid status\"" +
+ "}").getBytes()
+ );
+ assertEquals(statusCode, pd.getStatus());
+ }
+
+ @Test
+ void testMismatchingStatusProblemDetails() {
+ final int statusCode = 400;
+ final ProblemDetails pd = ProblemDetails.fromErrorResponse(
+ statusCode,
+ mockProblemDetailsHeader(),
+ ("{" +
+ "\"status\":500" +
+ "}").getBytes()
+ );
+ assertEquals(500, pd.getStatus());
+ }
+
+ @Test
+ void testInvalidTypeProblemDetails() {
+ final ProblemDetails pd = ProblemDetails.fromErrorResponse(
+ 400,
+ mockProblemDetailsHeader(),
+ ("{" +
+ "\"type\":\"Some invalid type\"" +
+ "}").getBytes()
+ );
+ assertEquals(ProblemDetails.DEFAULT_TYPE, pd.getType().toString());
+ }
+
+ @Test
+ void testInvalidInstanceProblemDetails() {
+ final ProblemDetails pd = ProblemDetails.fromErrorResponse(
+ 400,
+ mockProblemDetailsHeader(),
+ ("{" +
+ "\"instance\":\"Some invalid instance\"" +
+ "}").getBytes()
+ );
+ assertNull(pd.getInstance());
+ }
+
+ @Test
+ void testInvalidProblemDetails() {
+ final int statusCode = 400;
+ final ProblemDetails pd = ProblemDetails.fromErrorResponse(
+ statusCode,
+ mockProblemDetailsHeader(),
+ "Not valid application/problem+json".getBytes()
+ );
+ assertEquals(ProblemDetails.DEFAULT_TYPE, pd.getType().toString());
+ assertEquals(statusCode, pd.getStatus());
+ assertNull(pd.getTitle());
+ assertNull(pd.getDetails());
+ assertNull(pd.getInstance());
+ }
+}
diff --git a/solid/src/test/java/com/inrupt/client/solid/SolidExceptionTest.java b/solid/src/test/java/com/inrupt/client/solid/SolidExceptionTest.java
index 15220345d68..147b48fb094 100644
--- a/solid/src/test/java/com/inrupt/client/solid/SolidExceptionTest.java
+++ b/solid/src/test/java/com/inrupt/client/solid/SolidExceptionTest.java
@@ -22,6 +22,8 @@
import static org.junit.jupiter.api.Assertions.*;
+import java.net.URI;
+
import org.junit.jupiter.api.Test;
class SolidExceptionTest {
@@ -41,4 +43,14 @@ void checkSolidWrappedException() {
assertEquals(upstream, err.getCause());
assertEquals(msg, err.getMessage());
}
+
+ @Test
+ void checkSolidClientException() {
+ final String msg = "Error";
+ final SolidClientException err = new SolidClientException(
+ msg, URI.create("https://example.org/request"), 123, null, "some body"
+ );
+ assertEquals(msg, err.getMessage());
+ assertEquals(123, err.getProblemDetails().getStatus());
+ }
}