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()); + } }