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