Skip to content

Commit

Permalink
Add ability to map unknown exceptions to custom exceptions (#2205)
Browse files Browse the repository at this point in the history
* Add ability to map unknown exceptions to custom exceptions

Closes #2204

* Add tests for ErrorMapper

* Fix typo

* Add ErrorMapper to ElideStandalone

By default the mapper does not map anything.

* Add ErrorMapper to GraphQL QueryRunner
  • Loading branch information
Brutus5000 authored Sep 13, 2021
1 parent ba8776d commit 65eaaa1
Show file tree
Hide file tree
Showing 14 changed files with 345 additions and 68 deletions.
100 changes: 71 additions & 29 deletions elide-core/src/main/java/com/yahoo/elide/Elide.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
import com.yahoo.elide.core.dictionary.Injector;
import com.yahoo.elide.core.exceptions.BadRequestException;
import com.yahoo.elide.core.exceptions.CustomErrorException;
import com.yahoo.elide.core.exceptions.ErrorMapper;
import com.yahoo.elide.core.exceptions.ErrorObjects;
import com.yahoo.elide.core.exceptions.ForbiddenAccessException;
import com.yahoo.elide.core.exceptions.HttpStatus;
import com.yahoo.elide.core.exceptions.HttpStatusException;
import com.yahoo.elide.core.exceptions.InternalServerErrorException;
import com.yahoo.elide.core.exceptions.InvalidURLException;
import com.yahoo.elide.core.exceptions.JsonPatchExtensionException;
import com.yahoo.elide.core.exceptions.TimeoutException;
import com.yahoo.elide.core.exceptions.TransactionException;
import com.yahoo.elide.core.security.User;
import com.yahoo.elide.core.utils.ClassScanner;
Expand Down Expand Up @@ -79,6 +79,7 @@ public class Elide {
@Getter private final AuditLogger auditLogger;
@Getter private final DataStore dataStore;
@Getter private final JsonApiMapper mapper;
@Getter private final ErrorMapper errorMapper;
@Getter private final TransactionRegistry transactionRegistry;
@Getter private final ClassScanner scanner;

Expand All @@ -104,6 +105,7 @@ public Elide(ElideSettings elideSettings, ClassScanner scanner) {
this.dataStore = new InMemoryDataStore(elideSettings.getDataStore());
this.dataStore.populateEntityDictionary(elideSettings.getDictionary());
this.mapper = elideSettings.getMapper();
this.errorMapper = elideSettings.getErrorMapper();
this.transactionRegistry = new TransactionRegistry();

elideSettings.getSerdes().forEach((type, serde) -> registerCustomSerde(type, serde, type.getSimpleName()));
Expand Down Expand Up @@ -491,32 +493,71 @@ protected ElideResponse handleRequest(boolean isReadOnly, User user,

return response;

} catch (WebApplicationException e) {
throw e;
} catch (ForbiddenAccessException e) {
} catch (IOException e) {
log.error("IO Exception uncaught by Elide", e);
return buildErrorResponse(new TransactionException(e), isVerbose);
} catch (RuntimeException e) {
return handleRuntimeException(e, isVerbose);
} finally {
transactionRegistry.removeRunningTransaction(requestId);
auditLogger.clear();
}
}

protected ElideResponse buildErrorResponse(HttpStatusException error, boolean isVerbose) {
if (error instanceof InternalServerErrorException) {
log.error("Internal Server Error", error);
}

return buildResponse(isVerbose ? error.getVerboseErrorResponse()
: error.getErrorResponse());
}

private ElideResponse handleRuntimeException(RuntimeException error, boolean isVerbose) {
CustomErrorException mappedException = mapError(error);

if (mappedException != null) {
return buildErrorResponse(mappedException, isVerbose);
}

if (error instanceof WebApplicationException) {
throw error;
}

if (error instanceof ForbiddenAccessException) {
ForbiddenAccessException e = (ForbiddenAccessException) error;
if (log.isDebugEnabled()) {
log.debug("{}", e.getLoggedMessage());
}
return buildErrorResponse(e, isVerbose);
} catch (JsonPatchExtensionException e) {
}

if (error instanceof JsonPatchExtensionException) {
JsonPatchExtensionException e = (JsonPatchExtensionException) error;
log.debug("JSON patch extension exception caught", e);
return buildErrorResponse(e, isVerbose);
} catch (HttpStatusException e) {
}

if (error instanceof HttpStatusException) {
HttpStatusException e = (HttpStatusException) error;
log.debug("Caught HTTP status exception", e);
return buildErrorResponse(e, isVerbose);
} catch (IOException e) {
log.error("IO Exception uncaught by Elide", e);
return buildErrorResponse(new TransactionException(e), isVerbose);
} catch (ParseCancellationException e) {
}

if (error instanceof ParseCancellationException) {
ParseCancellationException e = (ParseCancellationException) error;
log.debug("Parse cancellation exception uncaught by Elide (i.e. invalid URL)", e);
return buildErrorResponse(new InvalidURLException(e), isVerbose);
} catch (ConstraintViolationException e) {
}

if (error instanceof ConstraintViolationException) {
ConstraintViolationException e = (ConstraintViolationException) error;
log.debug("Constraint violation exception caught", e);
String message = "Constraint violation";
final ErrorObjects.ErrorObjectsBuilder errorObjectsBuilder = ErrorObjects.builder();
for (ConstraintViolation<?> constraintViolation : e.getConstraintViolations()) {
errorObjectsBuilder.addError()
.withDetail(constraintViolation.getMessage());
.withDetail(constraintViolation.getMessage());
final String propertyPathString = constraintViolation.getPropertyPath().toString();
if (!propertyPathString.isEmpty()) {
Map<String, Object> source = new HashMap<>(1);
Expand All @@ -525,29 +566,30 @@ protected ElideResponse handleRequest(boolean isReadOnly, User user,
}
}
return buildErrorResponse(
new CustomErrorException(HttpStatus.SC_BAD_REQUEST, message, errorObjectsBuilder.build()),
isVerbose
new CustomErrorException(HttpStatus.SC_BAD_REQUEST, message, errorObjectsBuilder.build()),
isVerbose
);
} catch (Exception | Error e) {
if (e instanceof InterruptedException) {
log.debug("Request Thread interrupted.", e);
return buildErrorResponse(new TimeoutException(e), isVerbose);
}
log.error("Error or exception uncaught by Elide", e);
throw e;
} finally {
transactionRegistry.removeRunningTransaction(requestId);
auditLogger.clear();
}

log.error("Error or exception uncaught by Elide", error);
throw new RuntimeException(error);
}

protected ElideResponse buildErrorResponse(HttpStatusException error, boolean isVerbose) {
if (error instanceof InternalServerErrorException) {
log.error("Internal Server Error", error);
public CustomErrorException mapError(RuntimeException error) {
if (errorMapper != null) {
log.trace("Attempting to map unknown exception of type {}", error.getClass());
CustomErrorException customizedError = errorMapper.map(error);

if (customizedError != null) {
log.debug("Successfully mapped exception from type {} to {}",
error.getClass(), customizedError.getClass());
return customizedError;
} else {
log.debug("No error mapping present for {}", error.getClass());
}
}

return buildResponse(isVerbose ? error.getVerboseErrorResponse()
: error.getErrorResponse());
return null;
}

protected ElideResponse buildResponse(Pair<Integer, JsonNode> response) {
Expand Down
2 changes: 2 additions & 0 deletions elide-core/src/main/java/com/yahoo/elide/ElideSettings.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.yahoo.elide.core.audit.AuditLogger;
import com.yahoo.elide.core.datastore.DataStore;
import com.yahoo.elide.core.dictionary.EntityDictionary;
import com.yahoo.elide.core.exceptions.ErrorMapper;
import com.yahoo.elide.core.filter.dialect.graphql.FilterDialect;
import com.yahoo.elide.core.filter.dialect.jsonapi.JoinFilterDialect;
import com.yahoo.elide.core.filter.dialect.jsonapi.SubqueryFilterDialect;
Expand All @@ -32,6 +33,7 @@ public class ElideSettings {
@Getter private final DataStore dataStore;
@Getter private final EntityDictionary dictionary;
@Getter private final JsonApiMapper mapper;
@Getter private final ErrorMapper errorMapper;
@Getter private final Function<RequestScope, PermissionExecutor> permissionExecutor;
@Getter private final List<JoinFilterDialect> joinFilterDialects;
@Getter private final List<SubqueryFilterDialect> subqueryFilterDialects;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.yahoo.elide.core.audit.Slf4jLogger;
import com.yahoo.elide.core.datastore.DataStore;
import com.yahoo.elide.core.dictionary.EntityDictionary;
import com.yahoo.elide.core.exceptions.ErrorMapper;
import com.yahoo.elide.core.exceptions.HttpStatus;
import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect;
import com.yahoo.elide.core.filter.dialect.graphql.FilterDialect;
Expand Down Expand Up @@ -48,6 +49,7 @@ public class ElideSettingsBuilder {
private final DataStore dataStore;
private AuditLogger auditLogger;
private JsonApiMapper jsonApiMapper;
private ErrorMapper errorMapper;
private EntityDictionary entityDictionary;
private Function<RequestScope, PermissionExecutor> permissionExecutorFunction = ActivePermissionExecutor::new;
private List<JoinFilterDialect> joinFilterDialects;
Expand Down Expand Up @@ -110,6 +112,7 @@ public ElideSettings build() {
dataStore,
entityDictionary,
jsonApiMapper,
errorMapper,
permissionExecutorFunction,
joinFilterDialects,
subqueryFilterDialects,
Expand Down Expand Up @@ -142,6 +145,12 @@ public ElideSettingsBuilder withJsonApiMapper(JsonApiMapper jsonApiMapper) {
return this;
}


public ElideSettingsBuilder withErrorMapper(ErrorMapper errorMapper) {
this.errorMapper = errorMapper;
return this;
}

public ElideSettingsBuilder withJoinFilterDialect(JoinFilterDialect dialect) {
joinFilterDialects.add(dialect);
return this;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2021, Yahoo Inc.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
package com.yahoo.elide.core.exceptions;

import javax.annotation.Nullable;

/**
* The ErrorMapper allows mapping any RuntimeException of your choice into more meaningful
* CustomErrorExceptions to improved your error response to the client.
*/
@FunctionalInterface
public interface ErrorMapper {
/**
* @param origin any Exception not caught by default
* @return a mapped CustomErrorException or null if you do not want to map this error
*/
@Nullable CustomErrorException map(Exception origin);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* Copyright 2021, Yahoo Inc.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
package com.yahoo.elide.core.exceptions;

import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.yahoo.elide.Elide;
import com.yahoo.elide.ElideResponse;
import com.yahoo.elide.ElideSettings;
import com.yahoo.elide.ElideSettingsBuilder;
import com.yahoo.elide.core.RequestScope;
import com.yahoo.elide.core.datastore.DataStore;
import com.yahoo.elide.core.datastore.DataStoreTransaction;
import com.yahoo.elide.core.dictionary.EntityDictionary;
import com.yahoo.elide.core.dictionary.TestDictionary;
import com.yahoo.elide.core.lifecycle.FieldTestModel;
import com.yahoo.elide.core.lifecycle.LegacyTestModel;
import com.yahoo.elide.core.lifecycle.PropertyTestModel;
import com.yahoo.elide.core.security.TestUser;
import com.yahoo.elide.core.security.User;
import com.yahoo.elide.core.type.ClassType;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.UUID;

/**
* Tests the error mapping logic.
*/
public class ErrorMapperTest {

private final String baseUrl = "http://localhost:8080/api/v1";
private static final ErrorMapper MOCK_ERROR_MAPPER = mock(ErrorMapper.class);
private static final Exception EXPECTED_EXCEPTION = new IllegalStateException("EXPECTED_EXCEPTION");
private static final CustomErrorException MAPPED_EXCEPTION = new CustomErrorException(
422,
"MAPPED_EXCEPTION",
ErrorObjects.builder()
.addError()
.withCode("SOME_ERROR")
.build()
);
private EntityDictionary dictionary;

ErrorMapperTest() throws Exception {
dictionary = TestDictionary.getTestDictionary();
dictionary.bindEntity(FieldTestModel.class);
dictionary.bindEntity(PropertyTestModel.class);
dictionary.bindEntity(LegacyTestModel.class);
}

@AfterEach
private void afterEach() {
reset(MOCK_ERROR_MAPPER);
}

@Test
public void testElideCreateNoErrorMapper() throws Exception {
DataStore store = mock(DataStore.class);
DataStoreTransaction tx = mock(DataStoreTransaction.class);
FieldTestModel mockModel = mock(FieldTestModel.class);

Elide elide = getElide(store, dictionary, null);

String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}";

when(store.beginTransaction()).thenReturn(tx);
when(tx.createNewObject(ClassType.of(FieldTestModel.class))).thenReturn(mockModel);
doThrow(EXPECTED_EXCEPTION).when(tx).preCommit(any());

RuntimeException result = assertThrows(RuntimeException.class, () -> elide.post(baseUrl, "/testModel", body, null, NO_VERSION));
assertEquals(EXPECTED_EXCEPTION, result.getCause());

verify(tx).close();
}

@Test
public void testElideCreateWithErrorMapperUnmapped() throws Exception {
DataStore store = mock(DataStore.class);
DataStoreTransaction tx = mock(DataStoreTransaction.class);
FieldTestModel mockModel = mock(FieldTestModel.class);

Elide elide = getElide(store, dictionary, MOCK_ERROR_MAPPER);

String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}";

when(store.beginTransaction()).thenReturn(tx);
when(tx.createNewObject(ClassType.of(FieldTestModel.class))).thenReturn(mockModel);
doThrow(EXPECTED_EXCEPTION).when(tx).preCommit(any());

RuntimeException result = assertThrows(RuntimeException.class, () -> elide.post(baseUrl, "/testModel", body, null, NO_VERSION));
assertEquals(EXPECTED_EXCEPTION, result.getCause());

verify(tx).close();
}

@Test
public void testElideCreateWithErrorMapperMapped() throws Exception {
DataStore store = mock(DataStore.class);
DataStoreTransaction tx = mock(DataStoreTransaction.class);
FieldTestModel mockModel = mock(FieldTestModel.class);

Elide elide = getElide(store, dictionary, MOCK_ERROR_MAPPER);

String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}";

when(store.beginTransaction()).thenReturn(tx);
when(tx.createNewObject(ClassType.of(FieldTestModel.class))).thenReturn(mockModel);
doThrow(EXPECTED_EXCEPTION).when(tx).preCommit(any());
when(MOCK_ERROR_MAPPER.map(EXPECTED_EXCEPTION)).thenReturn(MAPPED_EXCEPTION);

ElideResponse response = elide.post(baseUrl, "/testModel", body, null, NO_VERSION);
assertEquals(422, response.getResponseCode());
assertEquals(
"{\"errors\":[{\"code\":\"SOME_ERROR\"}]}",
response.getBody());

verify(tx).close();
}

private Elide getElide(DataStore dataStore, EntityDictionary dictionary, ErrorMapper errorMapper) {
return new Elide(getElideSettings(dataStore, dictionary, errorMapper));
}

private ElideSettings getElideSettings(DataStore dataStore, EntityDictionary dictionary, ErrorMapper errorMapper) {
return new ElideSettingsBuilder(dataStore)
.withEntityDictionary(dictionary)
.withErrorMapper(errorMapper)
.withVerboseErrors()
.build();
}

private RequestScope buildRequestScope(EntityDictionary dict, DataStoreTransaction tx) {
User user = new TestUser("1");

return new RequestScope(null, null, NO_VERSION, null, tx, user, null, null, UUID.randomUUID(),
getElideSettings(null, dict, MOCK_ERROR_MAPPER));
}
}
Loading

0 comments on commit 65eaaa1

Please sign in to comment.