Skip to content
Merged
8 changes: 8 additions & 0 deletions server/opensharing-server-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.linecorp.armeria</groupId>
<artifactId>armeria</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down
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,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()));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
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,10 @@
package io.opensharing.http;

/**
* Error body returned by every non-2xx protocol response:
*
* <pre>{@code
* {"errorCode": "<error code>", "message": "<message>"}
* }</pre>
*/
public record ErrorResponse(String errorCode, String message) {}
Loading
Loading