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/ApiFailure.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java new file mode 100644 index 000000000..c1fdcf36c --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java @@ -0,0 +1,101 @@ +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.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 -> { + log.error("The sharing server could not authenticate to the catalog", rejected); + yield new ApiFailure( + 502, + ErrorCodes.CATALOG_ERROR, + "the sharing server could not authenticate to the catalog"); + } + 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"); + } + + private static String fieldPath(InvalidFormatException invalid) { + StringBuilder path = new StringBuilder(); + for (JsonMappingException.Reference reference : invalid.getPath()) { + 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/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..aea36ae13 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorResponse.java @@ -0,0 +1,4 @@ +package io.opensharing.http; + +/** Error body returned by every non-2xx protocol response. */ +public record ErrorResponse(String errorCode, String message) {} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java new file mode 100644 index 000000000..ebd01e44e --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java @@ -0,0 +1,45 @@ +package io.opensharing.runtime; + +import io.opensharing.catalog.CatalogConnector; + +/** + * OpenSharing assembled from what its host supplies. The standalone server builds one from its + * Spring beans. An embedding host, such as Unity Catalog, builds one directly, serves it through + * its own HTTP stack, and authenticates callers itself. + */ +public final class OpenSharing { + + private final CatalogConnector catalog; + + private OpenSharing(CatalogConnector catalog) { + this.catalog = catalog; + } + + public static Builder builder() { + return new Builder(); + } + + public CatalogConnector catalog() { + return catalog; + } + + public static final class Builder { + + private CatalogConnector catalog; + + private Builder() {} + + /** The host's catalog integration (required). */ + public Builder catalog(CatalogConnector catalog) { + this.catalog = catalog; + return this; + } + + public OpenSharing build() { + if (catalog == null) { + throw new IllegalStateException("OpenSharing requires a CatalogConnector from the host"); + } + return new OpenSharing(catalog); + } + } +} 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..74dc74689 --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java @@ -0,0 +1,92 @@ +package io.opensharing.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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.CatalogException; +import io.opensharing.exception.UnsupportedAssetTypeException; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.sql.SQLException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ApiFailureTest { + + @Test + void mapsCatalogMissesAndDenials() { + Asset lookup = new Asset(AssetType.TABLE, "main.sales.missing"); + ApiFailure missing = ApiFailure.of(new AssetNotFoundException(lookup)); + assertEquals(404, missing.status()); + assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.errorCode()); + + UserContext bob = UserContext.fromUserIdAndName("bob@example.com", "bob@example.com"); + ApiFailure denied = ApiFailure.of(new AssetAccessDeniedException(lookup, bob)); + assertEquals(403, denied.status()); + assertEquals(ErrorCodes.PERMISSION_DENIED, denied.errorCode()); + } + + @Test + void mapsCatalogFailuresAsBadGateway() { + ApiFailure failure = ApiFailure.of(new CatalogException("catalog unreachable")); + assertEquals(502, failure.status()); + assertEquals(ErrorCodes.CATALOG_ERROR, failure.errorCode()); + assertEquals("catalog unreachable", failure.message()); + } + + @Test + void mapsUnsupportedAssetTypesAsBadRequest() { + ApiFailure failure = ApiFailure.of(new UnsupportedAssetTypeException("not a schema")); + assertEquals(400, failure.status()); + assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, failure.errorCode()); + } + + @Test + void keepsAnExplicitApiException() { + ApiFailure failure = ApiFailure.of(ApiException.unauthenticated("token is missing")); + assertEquals(401, failure.status()); + assertEquals(ErrorCodes.UNAUTHENTICATED, failure.errorCode()); + assertEquals("token is missing", failure.message()); + } + + @Test + void mapsUnknownEnumValuesAsInvalidParameters() { + InvalidFormatException invalid = + assertThrows( + InvalidFormatException.class, + () -> + new ObjectMapper() + .readValue("{\"items\":[{\"type\":\"VIEW\"}]}", Body.class)); + ApiFailure failure = ApiFailure.of(invalid); + assertEquals(400, failure.status()); + assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, failure.errorCode()); + assertEquals("items[0].type must be one of [TABLE, SCHEMA]", failure.message()); + } + + @Test + void mapsOtherUnreadableBodiesAsMalformed() { + JsonProcessingException malformed = + assertThrows(JsonProcessingException.class, () -> new ObjectMapper().readTree("{")); + ApiFailure failure = ApiFailure.of(malformed); + assertEquals(400, failure.status()); + assertEquals(ErrorCodes.MALFORMED_REQUEST, failure.errorCode()); + } + + @Test + void mapsConstraintViolationsAsAlreadyExists() { + SQLException duplicate = new SQLException("duplicate key", "23505"); + ApiFailure failure = ApiFailure.of(new RuntimeException("commit failed", duplicate)); + assertEquals(409, failure.status()); + assertEquals(ErrorCodes.RESOURCE_ALREADY_EXISTS, failure.errorCode()); + } + + record Body(List items) {} + + record Item(AssetType type) {} +} diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingTest.java new file mode 100644 index 000000000..e1c4140fe --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingTest.java @@ -0,0 +1,30 @@ +package io.opensharing.runtime; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.opensharing.catalog.CatalogConnector; +import java.lang.reflect.Proxy; +import org.junit.jupiter.api.Test; + +class OpenSharingTest { + + @Test + void refusesToBuildWithoutACatalogConnector() { + assertThrows(IllegalStateException.class, () -> OpenSharing.builder().build()); + } + + @Test + void buildsWithTheHostCatalog() { + CatalogConnector catalog = + (CatalogConnector) + Proxy.newProxyInstance( + CatalogConnector.class.getClassLoader(), + new Class[] {CatalogConnector.class}, + (proxy, method, args) -> { + throw new UnsupportedOperationException(); + }); + + assertSame(catalog, OpenSharing.builder().catalog(catalog).build().catalog()); + } +} diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingConfiguration.java b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingConfiguration.java new file mode 100644 index 000000000..72138065d --- /dev/null +++ b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingConfiguration.java @@ -0,0 +1,16 @@ +package io.opensharing.config; + +import io.opensharing.catalog.CatalogConnector; +import io.opensharing.runtime.OpenSharing; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Builds the standalone server's {@link OpenSharing} from its Spring beans. */ +@Configuration +public class OpenSharingConfiguration { + + @Bean + public OpenSharing openSharing(CatalogConnector catalog) { + return OpenSharing.builder().catalog(catalog).build(); + } +} diff --git a/server/opensharing-server/src/main/java/io/opensharing/http/GlobalExceptionHandler.java b/server/opensharing-server/src/main/java/io/opensharing/http/GlobalExceptionHandler.java new file mode 100644 index 000000000..b1b0c052e --- /dev/null +++ b/server/opensharing-server/src/main/java/io/opensharing/http/GlobalExceptionHandler.java @@ -0,0 +1,61 @@ +package io.opensharing.http; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +/** Renders every failure as {@code {errorCode, message}}. */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + public ResponseEntity handle(Exception e) { + ApiFailure failure = failureOf(e); + return ResponseEntity.status(failure.status()) + .body(new ErrorResponse(failure.errorCode(), failure.message())); + } + + /** Maps Spring MVC's own failures; everything else is mapped by {@link ApiFailure}. */ + static ApiFailure failureOf(Exception e) { + return switch (e) { + case MethodArgumentNotValidException invalid -> + new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, describe(invalid)); + case HandlerMethodValidationException ignored -> + new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, "request validation failed"); + case MethodArgumentTypeMismatchException mistyped -> + new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, describe(mistyped)); + case MissingServletRequestParameterException missing -> + new ApiFailure( + 400, ErrorCodes.INVALID_PARAMETER_VALUE, missing.getParameterName() + " is required"); + case HttpMessageNotReadableException unreadable -> + unreadable.getCause() instanceof JsonProcessingException json + ? ApiFailure.of(json) + : new ApiFailure(400, ErrorCodes.MALFORMED_REQUEST, "request body is malformed"); + case NoResourceFoundException ignored -> + new ApiFailure(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, "the endpoint does not exist"); + default -> ApiFailure.of(e); + }; + } + + private static String describe(MethodArgumentNotValidException invalid) { + return invalid.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getField() + " " + error.getDefaultMessage()) + .orElse("request validation failed"); + } + + private static String describe(MethodArgumentTypeMismatchException mistyped) { + Class wanted = mistyped.getRequiredType(); + boolean numeric = wanted == Integer.class || wanted == Long.class; + return numeric + ? mistyped.getName() + " must be a number" + : mistyped.getName() + " is not a valid value"; + } +} diff --git a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java index 6e3298faa..6f03adcde 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java @@ -1,6 +1,7 @@ package io.opensharing.config; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import io.opensharing.auth.AuthContext; import io.opensharing.auth.UserContext; @@ -9,6 +10,7 @@ import io.opensharing.catalog.CatalogConnector; import io.opensharing.catalog.DataSourceFormat; import io.opensharing.catalog.TableProperties; +import io.opensharing.runtime.OpenSharing; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -22,6 +24,7 @@ class CatalogConfigurationTest { @Autowired private CatalogConnector catalog; + @Autowired private OpenSharing openSharing; @Test void loadsTheConfiguredLocalCatalog() { @@ -35,4 +38,9 @@ void loadsTheConfiguredLocalCatalog() { .additionalProperties(); assertEquals(DataSourceFormat.DELTA, table.dataSourceFormat()); } + + @Test + void buildsOpenSharingWithTheConfiguredCatalog() { + assertSame(catalog, openSharing.catalog()); + } } diff --git a/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java b/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..825b17a42 --- /dev/null +++ b/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java @@ -0,0 +1,64 @@ +package io.opensharing.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import io.opensharing.catalog.AssetType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.mock.http.MockHttpInputMessage; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + properties = { + "spring.datasource.url=jdbc:h2:mem:http-errors;DB_CLOSE_DELAY=-1", + "opensharing.catalog.type=local", + "opensharing.catalog.local.file=classpath:local-catalog.yml" + }) +@AutoConfigureMockMvc +class GlobalExceptionHandlerTest { + + @Autowired private MockMvc mvc; + + @Test + void answersUnknownRoutesWithTheProtocolErrorBody() throws Exception { + mvc.perform(get("/no-such-route")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(ErrorCodes.RESOURCE_DOES_NOT_EXIST)) + .andExpect(jsonPath("$.message").value("the endpoint does not exist")); + } + + @Test + void mapsUnknownEnumValuesInABodyAsInvalidParameters() { + InvalidFormatException invalid = + assertThrows( + InvalidFormatException.class, + () -> new ObjectMapper().readValue("{\"type\":\"VIEW\"}", Body.class)); + ApiFailure failure = GlobalExceptionHandler.failureOf(unreadable(invalid)); + assertEquals(400, failure.status()); + assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, failure.errorCode()); + assertEquals("type must be one of [TABLE, SCHEMA]", failure.message()); + } + + @Test + void mapsOtherUnreadableBodiesAsMalformed() { + ApiFailure failure = GlobalExceptionHandler.failureOf(unreadable(null)); + assertEquals(400, failure.status()); + assertEquals(ErrorCodes.MALFORMED_REQUEST, failure.errorCode()); + } + + private static HttpMessageNotReadableException unreadable(Throwable cause) { + return new HttpMessageNotReadableException( + "unreadable", cause, new MockHttpInputMessage(new byte[0])); + } + + record Body(AssetType type) {} +}