org.slf4j
slf4j-api
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/exception/CatalogAuthenticationException.java b/server/opensharing-server-core/src/main/java/io/opensharing/exception/CatalogAuthenticationException.java
index e6ab40644..1f8e1c4e1 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/exception/CatalogAuthenticationException.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/exception/CatalogAuthenticationException.java
@@ -1,8 +1,8 @@
package io.opensharing.exception;
/**
- * The catalog rejected OpenSharing's own credentials, so the server cannot reach the catalog at
- * all. A server misconfiguration, not a problem with the end user's request.
+ * The catalog could not authenticate the caller on {@code CatalogConnector#authorize}: their token
+ * is missing or invalid.
*/
public class CatalogAuthenticationException extends CatalogException {
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/exception/CatalogAuthorizationException.java b/server/opensharing-server-core/src/main/java/io/opensharing/exception/CatalogAuthorizationException.java
index ffedd0459..a86e6d981 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/exception/CatalogAuthorizationException.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/exception/CatalogAuthorizationException.java
@@ -1,8 +1,8 @@
package io.opensharing.exception;
/**
- * The catalog refused the end user on {@code CatalogConnector#authorize}: their token is missing or
- * invalid, or they lack the requested privilege.
+ * The catalog authenticated the caller on {@code CatalogConnector#authorize}, but they lack the
+ * requested privilege.
*/
public class CatalogAuthorizationException extends CatalogException {
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java
new file mode 100644
index 000000000..f294bab62
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java
@@ -0,0 +1,50 @@
+package io.opensharing.http;
+
+/** An error with an explicit protocol error code and HTTP status. */
+public class ApiException extends RuntimeException {
+
+ private final int status;
+ private final String errorCode;
+
+ public ApiException(int status, String errorCode, String message) {
+ super(message);
+ this.status = status;
+ this.errorCode = errorCode;
+ }
+
+ public int getStatus() {
+ return status;
+ }
+
+ public String getErrorCode() {
+ return errorCode;
+ }
+
+ public static ApiException notFound(String message) {
+ return new ApiException(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, message);
+ }
+
+ public static ApiException alreadyExists(String message) {
+ return new ApiException(409, ErrorCodes.RESOURCE_ALREADY_EXISTS, message);
+ }
+
+ public static ApiException conflict(String message) {
+ return new ApiException(409, ErrorCodes.RESOURCE_CONFLICT, message);
+ }
+
+ public static ApiException invalidParameter(String message) {
+ return new ApiException(400, ErrorCodes.INVALID_PARAMETER_VALUE, message);
+ }
+
+ public static ApiException permissionDenied(String message) {
+ return new ApiException(403, ErrorCodes.PERMISSION_DENIED, message);
+ }
+
+ public static ApiException unauthenticated(String message) {
+ return new ApiException(401, ErrorCodes.UNAUTHENTICATED, message);
+ }
+
+ public static ApiException notImplemented(String message) {
+ return new ApiException(501, ErrorCodes.NOT_IMPLEMENTED, message);
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiExceptionHandler.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiExceptionHandler.java
new file mode 100644
index 000000000..770254af2
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiExceptionHandler.java
@@ -0,0 +1,74 @@
+package io.opensharing.http;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.linecorp.armeria.common.HttpHeaderNames;
+import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.common.ResponseHeaders;
+import com.linecorp.armeria.common.ResponseHeadersBuilder;
+import com.linecorp.armeria.server.DecoratingHttpServiceFunction;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.server.HttpStatusException;
+import com.linecorp.armeria.server.ServiceRequestContext;
+import com.linecorp.armeria.server.annotation.ExceptionHandlerFunction;
+import java.util.Locale;
+import java.util.concurrent.CompletionException;
+
+/**
+ * Turns any failure into the protocol's {@code {errorCode, message}} JSON body. Armeria hands it
+ * failures two ways, so register it twice: as the annotated service's exception handler, for what
+ * the service methods throw, and as the outermost decorator, for what other decorators such as a
+ * token check throw, which the exception handler never sees.
+ */
+public final class ApiExceptionHandler
+ implements ExceptionHandlerFunction, DecoratingHttpServiceFunction {
+
+ @Override
+ public HttpResponse handleException(
+ ServiceRequestContext ctx, HttpRequest req, Throwable cause) {
+ return response(failureOf(cause));
+ }
+
+ @Override
+ public HttpResponse serve(HttpService delegate, ServiceRequestContext ctx, HttpRequest req) {
+ try {
+ return delegate.serve(ctx, req).recover(cause -> handleException(ctx, req, cause));
+ } catch (Exception e) {
+ return handleException(ctx, req, e);
+ }
+ }
+
+ /** Maps Armeria's own failures; everything else is mapped by {@link ApiFailure}. */
+ static ApiFailure failureOf(Throwable cause) {
+ Throwable failure = cause instanceof CompletionException ? cause.getCause() : cause;
+ return switch (failure) {
+ case HttpStatusException status when status.httpStatus() == HttpStatus.NOT_FOUND ->
+ new ApiFailure(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, "the endpoint does not exist");
+ case HttpStatusException status -> statusFailure(status.httpStatus());
+ // Armeria reports an unreadable body as an IllegalArgumentException around Jackson's failure.
+ case IllegalArgumentException illegal
+ when illegal.getCause() instanceof JsonProcessingException json ->
+ ApiFailure.of(json);
+ case Exception e -> ApiFailure.of(e);
+ default -> ApiFailure.of(new RuntimeException(failure));
+ };
+ }
+
+ private static ApiFailure statusFailure(HttpStatus status) {
+ String message = status.reasonPhrase().toLowerCase(Locale.ROOT);
+ return status.isServerError()
+ ? new ApiFailure(status.code(), ErrorCodes.INTERNAL_ERROR, message)
+ : new ApiFailure(status.code(), ErrorCodes.MALFORMED_REQUEST, message);
+ }
+
+ private static HttpResponse response(ApiFailure failure) {
+ ResponseHeadersBuilder headers = ResponseHeaders.builder(failure.status());
+ if (failure.status() == 401) {
+ // RFC 9110 requires a 401 to name the expected scheme in WWW-Authenticate.
+ headers.add(HttpHeaderNames.WWW_AUTHENTICATE, "Bearer");
+ }
+ return ApiJson.response(
+ headers.build(), new ErrorResponse(failure.errorCode(), failure.message()));
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java
new file mode 100644
index 000000000..4db076005
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java
@@ -0,0 +1,104 @@
+package io.opensharing.http;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.exc.InvalidFormatException;
+import io.opensharing.exception.AssetAccessDeniedException;
+import io.opensharing.exception.AssetNotFoundException;
+import io.opensharing.exception.CatalogAuthenticationException;
+import io.opensharing.exception.CatalogAuthorizationException;
+import io.opensharing.exception.CatalogException;
+import io.opensharing.exception.UnsupportedAssetTypeException;
+import java.sql.SQLException;
+import java.util.Arrays;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Maps a failure to an HTTP status, protocol error code, and caller-facing message. Hosts map
+ * their own framework's failures first and pass everything else here.
+ */
+public record ApiFailure(int status, String errorCode, String message) {
+
+ private static final Logger log = LoggerFactory.getLogger(ApiFailure.class);
+
+ public static ApiFailure of(Exception e) {
+ return switch (e) {
+ case ApiException api ->
+ new ApiFailure(api.getStatus(), api.getErrorCode(), api.getMessage());
+ case AssetNotFoundException missing ->
+ new ApiFailure(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.getMessage());
+ case AssetAccessDeniedException denied ->
+ new ApiFailure(403, ErrorCodes.PERMISSION_DENIED, denied.getMessage());
+ case UnsupportedAssetTypeException unsupported ->
+ new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, unsupported.getMessage());
+ case CatalogAuthenticationException rejected ->
+ new ApiFailure(401, ErrorCodes.UNAUTHENTICATED, rejected.getMessage());
+ case CatalogAuthorizationException refused ->
+ new ApiFailure(403, ErrorCodes.PERMISSION_DENIED, refused.getMessage());
+ case CatalogException failed -> {
+ log.error("Catalog request failed", failed);
+ yield new ApiFailure(502, ErrorCodes.CATALOG_ERROR, failed.getMessage());
+ }
+ case JsonProcessingException unreadable -> describe(unreadable);
+ case IllegalArgumentException illegal ->
+ new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, illegal.getMessage());
+ default -> {
+ if (violatesConstraint(e)) {
+ log.debug("Rejected request that violated a uniqueness constraint", e);
+ yield new ApiFailure(
+ 409,
+ ErrorCodes.RESOURCE_ALREADY_EXISTS,
+ "the object already exists or conflicts with an existing object");
+ }
+ log.error("Unhandled server error", e);
+ yield new ApiFailure(500, ErrorCodes.INTERNAL_ERROR, "internal server error");
+ }
+ };
+ }
+
+ /** An unknown enum value is a bad parameter, not a malformed body. */
+ private static ApiFailure describe(JsonProcessingException unreadable) {
+ if (unreadable instanceof InvalidFormatException invalid
+ && invalid.getTargetType() != null
+ && invalid.getTargetType().isEnum()) {
+ return new ApiFailure(
+ 400,
+ ErrorCodes.INVALID_PARAMETER_VALUE,
+ fieldPath(invalid)
+ + " must be one of "
+ + Arrays.toString(invalid.getTargetType().getEnumConstants()));
+ }
+ return new ApiFailure(400, ErrorCodes.MALFORMED_REQUEST, "request body is malformed");
+ }
+
+ /**
+ * The JSON path of the invalid value, such as {@code a.b[0].c}, or {@code value}
+ * when the body itself is the invalid value.
+ */
+ private static String fieldPath(InvalidFormatException invalid) {
+ StringBuilder path = new StringBuilder();
+ for (JsonMappingException.Reference reference : invalid.getPath()) {
+ // A reference names an object field, or has a null field name and indexes into an array.
+ if (reference.getFieldName() != null) {
+ path.append(path.isEmpty() ? "" : ".").append(reference.getFieldName());
+ } else {
+ path.append('[').append(reference.getIndex()).append(']');
+ }
+ }
+ return path.isEmpty() ? "value" : path.toString();
+ }
+
+ // SQLSTATE class 23 is an integrity constraint violation in every database. Hosts wrap it in
+ // their own persistence exceptions, so look for it anywhere in the cause chain.
+ private static boolean violatesConstraint(Throwable e) {
+ for (Throwable cause = e; cause != null; cause = cause.getCause()) {
+ if (cause instanceof SQLException sql
+ && sql.getSQLState() != null
+ && sql.getSQLState().startsWith("23")) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiJson.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiJson.java
new file mode 100644
index 000000000..28b7436d0
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiJson.java
@@ -0,0 +1,44 @@
+package io.opensharing.http;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.MapperFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.linecorp.armeria.common.HttpData;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.common.ResponseHeaders;
+
+/**
+ * The JSON format of every OpenSharing endpoint, used instead of the host server's own mapper so
+ * responses look the same in every host: null fields are left out, enum values are read in any
+ * case, unknown fields are ignored, and dates are ISO-8601 strings.
+ */
+public final class ApiJson {
+
+ public static final ObjectMapper MAPPER =
+ JsonMapper.builder()
+ .serializationInclusion(JsonInclude.Include.NON_NULL)
+ .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
+ .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+ .addModule(new JavaTimeModule())
+ .build();
+
+ private ApiJson() {}
+
+ /** {@code body} as a JSON response with {@code headers}, which set its status. */
+ public static HttpResponse response(ResponseHeaders headers, Object body) {
+ try {
+ return HttpResponse.of(
+ headers.toBuilder().contentType(MediaType.JSON_UTF_8).build(),
+ HttpData.wrap(MAPPER.writeValueAsBytes(body)));
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("failed to write an OpenSharing response", e);
+ }
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorCodes.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorCodes.java
new file mode 100644
index 000000000..09614b1cd
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorCodes.java
@@ -0,0 +1,18 @@
+package io.opensharing.http;
+
+/** Error codes used in {@link ErrorResponse#errorCode()}. */
+public final class ErrorCodes {
+
+ public static final String MALFORMED_REQUEST = "MALFORMED_REQUEST";
+ public static final String INVALID_PARAMETER_VALUE = "INVALID_PARAMETER_VALUE";
+ public static final String UNAUTHENTICATED = "UNAUTHENTICATED";
+ public static final String PERMISSION_DENIED = "PERMISSION_DENIED";
+ public static final String RESOURCE_DOES_NOT_EXIST = "RESOURCE_DOES_NOT_EXIST";
+ public static final String RESOURCE_ALREADY_EXISTS = "RESOURCE_ALREADY_EXISTS";
+ public static final String RESOURCE_CONFLICT = "RESOURCE_CONFLICT";
+ public static final String CATALOG_ERROR = "CATALOG_ERROR";
+ public static final String NOT_IMPLEMENTED = "NOT_IMPLEMENTED";
+ public static final String INTERNAL_ERROR = "INTERNAL_ERROR";
+
+ private ErrorCodes() {}
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorResponse.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorResponse.java
new file mode 100644
index 000000000..cf4dcf395
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorResponse.java
@@ -0,0 +1,10 @@
+package io.opensharing.http;
+
+/**
+ * Error body returned by every non-2xx protocol response:
+ *
+ * {@code
+ * {"errorCode": "", "message": ""}
+ * }
+ */
+public record ErrorResponse(String errorCode, String message) {}
diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiExceptionHandlerTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiExceptionHandlerTest.java
new file mode 100644
index 000000000..1c099eb00
--- /dev/null
+++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiExceptionHandlerTest.java
@@ -0,0 +1,121 @@
+package io.opensharing.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.exc.InvalidFormatException;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpHeaderNames;
+import com.linecorp.armeria.common.HttpMethod;
+import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.server.HttpStatusException;
+import com.linecorp.armeria.server.ServiceRequestContext;
+import io.opensharing.catalog.AssetType;
+import java.util.concurrent.CompletionException;
+import org.junit.jupiter.api.Test;
+
+class ApiExceptionHandlerTest {
+
+ private final ApiExceptionHandler handler = new ApiExceptionHandler();
+
+ @Test
+ void mapsUnroutedRequestsAsNotFound() {
+ assertEquals(
+ new ApiFailure(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, "the endpoint does not exist"),
+ ApiExceptionHandler.failureOf(HttpStatusException.of(HttpStatus.NOT_FOUND)));
+ }
+
+ @Test
+ void mapsOtherArmeriaStatusesByTheirReason() {
+ assertEquals(
+ new ApiFailure(415, ErrorCodes.MALFORMED_REQUEST, "unsupported media type"),
+ ApiExceptionHandler.failureOf(
+ HttpStatusException.of(HttpStatus.UNSUPPORTED_MEDIA_TYPE)));
+ assertEquals(
+ new ApiFailure(503, ErrorCodes.INTERNAL_ERROR, "service unavailable"),
+ ApiExceptionHandler.failureOf(
+ HttpStatusException.of(HttpStatus.SERVICE_UNAVAILABLE)));
+ }
+
+ @Test
+ void mapsUnreadableBodiesThroughTheirJsonCause() {
+ InvalidFormatException invalid =
+ assertThrows(
+ InvalidFormatException.class,
+ () -> new ObjectMapper().readValue("{\"type\":\"VIEW\"}", Body.class));
+ assertEquals(
+ new ApiFailure(
+ 400, ErrorCodes.INVALID_PARAMETER_VALUE, "type must be one of [TABLE, SCHEMA]"),
+ ApiExceptionHandler.failureOf(new IllegalArgumentException(invalid)));
+ }
+
+ @Test
+ void mapsInvalidParametersByTheirMessage() {
+ assertEquals(
+ new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, "maxResults is not a number"),
+ ApiExceptionHandler.failureOf(
+ new IllegalArgumentException("maxResults is not a number")));
+ }
+
+ @Test
+ void leavesOtherFailuresToApiFailure() {
+ assertEquals(
+ new ApiFailure(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, "no share"),
+ ApiExceptionHandler.failureOf(
+ new CompletionException(ApiException.notFound("no share"))));
+ assertEquals(
+ new ApiFailure(500, ErrorCodes.INTERNAL_ERROR, "internal server error"),
+ ApiExceptionHandler.failureOf(new AssertionError("boom")));
+ }
+
+ @Test
+ void writesTheProtocolErrorBody() throws Exception {
+ AggregatedHttpResponse response = handle(ApiException.permissionDenied("not the owner"));
+
+ assertEquals(403, response.status().code());
+ assertEquals(MediaType.JSON_UTF_8, response.contentType());
+ assertNull(response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE));
+ JsonNode body = new ObjectMapper().readTree(response.contentUtf8());
+ assertEquals(ErrorCodes.PERMISSION_DENIED, body.get("errorCode").asText());
+ assertEquals("not the owner", body.get("message").asText());
+ }
+
+ @Test
+ void namesTheBearerSchemeOnUnauthenticated() {
+ AggregatedHttpResponse response = handle(ApiException.unauthenticated("token is missing"));
+
+ assertEquals(401, response.status().code());
+ assertEquals("Bearer", response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE));
+ }
+
+ @Test
+ void decoratesWhatAServiceThrowsAndWhatItsResponseFailsWith() throws Exception {
+ HttpService throwing =
+ (ctx, req) -> {
+ throw ApiException.notFound("thrown");
+ };
+ HttpService failing = (ctx, req) -> HttpResponse.ofFailure(ApiException.notFound("failed"));
+
+ assertEquals(404, serve(throwing.decorate(handler)).status().code());
+ assertEquals(404, serve(failing.decorate(handler)).status().code());
+ }
+
+ private AggregatedHttpResponse handle(Throwable cause) {
+ HttpRequest req = HttpRequest.of(HttpMethod.GET, "/");
+ return handler.handleException(ServiceRequestContext.of(req), req, cause).aggregate().join();
+ }
+
+ private static AggregatedHttpResponse serve(HttpService service) throws Exception {
+ HttpRequest req = HttpRequest.of(HttpMethod.GET, "/");
+ return service.serve(ServiceRequestContext.of(req), req).aggregate().join();
+ }
+
+ record Body(AssetType type) {}
+}
diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java
new file mode 100644
index 000000000..b33ee9653
--- /dev/null
+++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java
@@ -0,0 +1,182 @@
+package io.opensharing.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.exc.InvalidFormatException;
+import io.opensharing.auth.UserContext;
+import io.opensharing.catalog.Asset;
+import io.opensharing.catalog.AssetType;
+import io.opensharing.exception.AssetAccessDeniedException;
+import io.opensharing.exception.AssetNotFoundException;
+import io.opensharing.exception.CatalogAuthenticationException;
+import io.opensharing.exception.CatalogAuthorizationException;
+import io.opensharing.exception.CatalogException;
+import io.opensharing.exception.UnsupportedAssetTypeException;
+import java.sql.SQLException;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class ApiFailureTest {
+
+ private static final Asset MISSING = new Asset(AssetType.TABLE, "main.sales.missing");
+
+ @Test
+ void keepsAnExplicitApiException() {
+ assertFailure(
+ 401,
+ ErrorCodes.UNAUTHENTICATED,
+ "token is missing",
+ ApiException.unauthenticated("token is missing"));
+ assertFailure(
+ 501, ErrorCodes.NOT_IMPLEMENTED, "not yet", ApiException.notImplemented("not yet"));
+ }
+
+ @Test
+ void mapsMissingAssetsAsNotFound() {
+ assertFailure(
+ 404,
+ ErrorCodes.RESOURCE_DOES_NOT_EXIST,
+ "TABLE 'main.sales.missing' does not exist in the catalog",
+ new AssetNotFoundException(MISSING));
+ }
+
+ @Test
+ void mapsDeniedAssetsAsPermissionDenied() {
+ UserContext bob = UserContext.fromUserIdAndName("bob@example.com", "bob@example.com");
+ assertFailure(
+ 403,
+ ErrorCodes.PERMISSION_DENIED,
+ "'bob@example.com' may not share TABLE 'main.sales.missing'",
+ new AssetAccessDeniedException(MISSING, bob));
+ }
+
+ @Test
+ void mapsUnsupportedAssetTypesAsBadRequest() {
+ assertFailure(
+ 400,
+ ErrorCodes.INVALID_PARAMETER_VALUE,
+ "not a schema",
+ new UnsupportedAssetTypeException("not a schema"));
+ }
+
+ @Test
+ void mapsCatalogAuthenticationFailuresAsUnauthenticated() {
+ assertFailure(
+ 401,
+ ErrorCodes.UNAUTHENTICATED,
+ "invalid bearer token",
+ new CatalogAuthenticationException("invalid bearer token"));
+ }
+
+ @Test
+ void mapsOtherCatalogFailuresAsBadGateway() {
+ assertFailure(
+ 502,
+ ErrorCodes.CATALOG_ERROR,
+ "catalog unreachable",
+ new CatalogException("catalog unreachable"));
+ }
+
+ @Test
+ void mapsCatalogAuthorizationFailuresAsPermissionDenied() {
+ assertFailure(
+ 403,
+ ErrorCodes.PERMISSION_DENIED,
+ "user lacks CREATE_SHARE",
+ new CatalogAuthorizationException("user lacks CREATE_SHARE"));
+ }
+
+ @Test
+ void mapsUnknownEnumValuesAsInvalidParameters() {
+ assertFailure(
+ 400,
+ ErrorCodes.INVALID_PARAMETER_VALUE,
+ "items[0].type must be one of [TABLE, SCHEMA]",
+ unreadable("{\"items\":[{\"type\":\"VIEW\"}]}", Body.class));
+ }
+
+ @Test
+ void namesATopLevelEnumValueAsValue() {
+ assertFailure(
+ 400,
+ ErrorCodes.INVALID_PARAMETER_VALUE,
+ "value must be one of [TABLE, SCHEMA]",
+ unreadable("\"VIEW\"", AssetType.class));
+ }
+
+ @Test
+ void mapsOtherInvalidFormatsAsMalformed() {
+ InvalidFormatException invalid = unreadable("{\"count\":\"ten\"}", Counted.class);
+ assertFailure(400, ErrorCodes.MALFORMED_REQUEST, "request body is malformed", invalid);
+ }
+
+ @Test
+ void mapsUnparsableBodiesAsMalformed() {
+ JsonProcessingException malformed =
+ assertThrows(JsonProcessingException.class, () -> new ObjectMapper().readTree("{"));
+ assertFailure(400, ErrorCodes.MALFORMED_REQUEST, "request body is malformed", malformed);
+ }
+
+ @Test
+ void mapsIllegalArgumentsAsInvalidParameters() {
+ assertFailure(
+ 400,
+ ErrorCodes.INVALID_PARAMETER_VALUE,
+ "name must not be blank",
+ new IllegalArgumentException("name must not be blank"));
+ }
+
+ @Test
+ void mapsConstraintViolationsAnywhereInTheCauseChainAsAlreadyExists() {
+ SQLException duplicate = new SQLException("duplicate key", "23505");
+ assertFailure(
+ 409,
+ ErrorCodes.RESOURCE_ALREADY_EXISTS,
+ "the object already exists or conflicts with an existing object",
+ new RuntimeException("commit failed", new IllegalStateException("rollback", duplicate)));
+ }
+
+ @Test
+ void mapsOtherSqlFailuresAsInternalErrors() {
+ assertFailure(
+ 500,
+ ErrorCodes.INTERNAL_ERROR,
+ "internal server error",
+ new RuntimeException(new SQLException("connection lost", "08006")));
+ assertFailure(
+ 500,
+ ErrorCodes.INTERNAL_ERROR,
+ "internal server error",
+ new RuntimeException(new SQLException("no state")));
+ }
+
+ @Test
+ void hidesUnhandledFailuresBehindInternalError() {
+ assertFailure(
+ 500,
+ ErrorCodes.INTERNAL_ERROR,
+ "internal server error",
+ new IllegalStateException("secret detail"));
+ }
+
+ private static void assertFailure(int status, String errorCode, String message, Exception e) {
+ ApiFailure failure = ApiFailure.of(e);
+ assertEquals(status, failure.status());
+ assertEquals(errorCode, failure.errorCode());
+ assertEquals(message, failure.message());
+ }
+
+ private static InvalidFormatException unreadable(String json, Class> type) {
+ return assertThrows(
+ InvalidFormatException.class, () -> new ObjectMapper().readValue(json, type));
+ }
+
+ record Body(List- items) {}
+
+ record Item(AssetType type) {}
+
+ record Counted(int count) {}
+}
diff --git a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
index a6a413a9b..864f6db0c 100644
--- a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
+++ b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
@@ -1,13 +1,15 @@
package io.opensharing;
-import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.server.HttpStatusException;
import com.linecorp.armeria.server.Server;
import io.opensharing.catalog.CatalogConnector;
import io.opensharing.catalog.local.LocalCatalogConnector;
import io.opensharing.catalog.local.LocalCatalogLoader;
import io.opensharing.config.ServerProperties;
import io.opensharing.config.ServerProperties.Property;
+import io.opensharing.http.ApiExceptionHandler;
import io.opensharing.runtime.OpenSharing;
import java.util.Locale;
@@ -47,11 +49,14 @@ public static void main(String[] args) throws InterruptedException {
*/
public static OpenSharingServer start(ServerProperties properties) {
OpenSharing openSharing = OpenSharing.builder().catalog(catalog(properties)).build();
+ HttpService notFound =
+ (ctx, req) -> {
+ throw HttpStatusException.of(HttpStatus.NOT_FOUND);
+ };
Server server =
Server.builder()
.http(properties.getPort())
- // Armeria refuses to start without a service; routes nothing serves are not found.
- .serviceUnder("/", (ctx, req) -> HttpResponse.of(HttpStatus.NOT_FOUND))
+ .serviceUnder("/", notFound.decorate(new ApiExceptionHandler()))
.build();
try {
server.start().join();
@@ -62,6 +67,11 @@ public static OpenSharingServer start(ServerProperties properties) {
return new OpenSharingServer(server, openSharing);
}
+ /** The port the server listens on, which it picks itself when configured with 0. */
+ public int port() {
+ return server.activeLocalPort();
+ }
+
/** The OpenSharing instance built from the server's settings. */
public OpenSharing openSharing() {
return openSharing;
diff --git a/server/opensharing-server/src/main/java/io/opensharing/catalog/local/LocalCatalogConnector.java b/server/opensharing-server/src/main/java/io/opensharing/catalog/local/LocalCatalogConnector.java
index 247b1a95f..ce8360c0f 100644
--- a/server/opensharing-server/src/main/java/io/opensharing/catalog/local/LocalCatalogConnector.java
+++ b/server/opensharing-server/src/main/java/io/opensharing/catalog/local/LocalCatalogConnector.java
@@ -10,7 +10,7 @@
import io.opensharing.exception.AssetNotFoundException;
import io.opensharing.catalog.AssetType;
import io.opensharing.catalog.CatalogConnector;
-import io.opensharing.exception.CatalogAuthorizationException;
+import io.opensharing.exception.CatalogAuthenticationException;
import io.opensharing.exception.CatalogException;
import io.opensharing.catalog.CloudProvider;
import io.opensharing.catalog.CredentialRequest;
@@ -203,7 +203,7 @@ public UserContext authorize(AuthContext auth, Privilege privilege) {
}
String token = auth == null || auth.user() == null ? null : auth.user().userAuthToken();
if (token == null || token.isBlank()) {
- throw new CatalogAuthorizationException("missing bearer token");
+ throw new CatalogAuthenticationException("missing bearer token");
}
return principals.stream()
.filter(principal -> token.equals(principal.bearerToken()))
@@ -211,7 +211,7 @@ public UserContext authorize(AuthContext auth, Privilege privilege) {
.map(
principal ->
new UserContext(principal.userId(), principal.bearerToken(), principal.userName()))
- .orElseThrow(() -> new CatalogAuthorizationException("invalid bearer token"));
+ .orElseThrow(() -> new CatalogAuthenticationException("invalid bearer token"));
}
private static boolean covers(LocalCatalogFile.Asset asset, String location) {
diff --git a/server/opensharing-server/src/test/java/io/opensharing/catalog/local/LocalCatalogConnectorTest.java b/server/opensharing-server/src/test/java/io/opensharing/catalog/local/LocalCatalogConnectorTest.java
index 7abff0368..717bcfbbd 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/catalog/local/LocalCatalogConnectorTest.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/catalog/local/LocalCatalogConnectorTest.java
@@ -13,7 +13,7 @@
import io.opensharing.catalog.AssetPage;
import io.opensharing.exception.AssetNotFoundException;
import io.opensharing.catalog.AssetType;
-import io.opensharing.exception.CatalogAuthorizationException;
+import io.opensharing.exception.CatalogAuthenticationException;
import io.opensharing.exception.CatalogException;
import io.opensharing.catalog.CloudProvider;
import io.opensharing.catalog.CredentialRequest;
@@ -423,7 +423,7 @@ void authorizesConfiguredLocalPrincipals() {
assertEquals("catalog-alice-id", alice.userId());
assertEquals("alice", alice.userName());
assertThrows(
- CatalogAuthorizationException.class,
+ CatalogAuthenticationException.class,
() ->
connector.authorize(
new AuthContext(null, new UserContext(null, "mallory-token", null)), null));
diff --git a/server/opensharing-server/src/test/java/io/opensharing/http/UnknownRouteTest.java b/server/opensharing-server/src/test/java/io/opensharing/http/UnknownRouteTest.java
new file mode 100644
index 000000000..d9aae590f
--- /dev/null
+++ b/server/opensharing-server/src/test/java/io/opensharing/http/UnknownRouteTest.java
@@ -0,0 +1,30 @@
+package io.opensharing.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import io.opensharing.OpenSharingServer;
+import io.opensharing.config.ServerProperties;
+import java.util.Properties;
+import org.junit.jupiter.api.Test;
+
+class UnknownRouteTest {
+
+ @Test
+ void answersUnknownRoutesWithTheProtocolErrorBody() throws Exception {
+ Properties props = new Properties();
+ props.setProperty("server.port", "0");
+ try (OpenSharingServer server = OpenSharingServer.start(new ServerProperties(props))) {
+ WebClient client = WebClient.of("http://127.0.0.1:" + server.port());
+ AggregatedHttpResponse response = client.get("/no-such-route").aggregate().join();
+
+ assertEquals(404, response.status().code());
+ JsonNode body = new ObjectMapper().readTree(response.contentUtf8());
+ assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, body.get("errorCode").asText());
+ assertEquals("the endpoint does not exist", body.get("message").asText());
+ }
+ }
+}