Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.opensharing.http;

/** Error body returned by every non-2xx protocol response. */
public record ErrorResponse(String errorCode, String message) {}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Item> items) {}

record Item(AssetType type) {}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading