diff --git a/server/opensharing-server-core/pom.xml b/server/opensharing-server-core/pom.xml index aeeb03152..f6c0b6ae4 100644 --- a/server/opensharing-server-core/pom.xml +++ b/server/opensharing-server-core/pom.xml @@ -24,6 +24,14 @@ com.fasterxml.jackson.core jackson-databind + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.linecorp.armeria + armeria + 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()); + } + } +}