diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/AutomaticValidationTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/AutomaticValidationTests.java index ba7d730b47..7e061deb18 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/AutomaticValidationTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/AutomaticValidationTests.java @@ -90,7 +90,7 @@ void noSuchActivityType() throws IOException, InterruptedException { Thread.sleep(1000); // TODO consider a while loop here final var activityValidations = hasura.getActivityValidations(planId); final ActivityValidation activityValidation = activityValidations.get((long) activityId); - assertEquals(new ActivityValidation.NoSuchActivityTypeFailure("no such activity type", "NopeBanana"), activityValidation); + assertEquals(new ActivityValidation.NoSuchActivityTypeFailure("No such activity type NopeBanana", "NopeBanana"), activityValidation); } @Test @@ -150,7 +150,7 @@ void noSuchMissionModelError() throws IOException, InterruptedException { final var activityValidations = hasura.getActivityValidations(planId); final ActivityValidation activityValidation = activityValidations.get((long) activityId); assertEquals( - new ActivityValidation.NoSuchMissionModelFailure("no such mission model", 0), + new ActivityValidation.NoSuchMissionModelFailure("No mission model exists with id `0`", 0), activityValidation ); } diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/ConstraintsTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/ConstraintsTests.java index ab8fbc3ae9..2af257da37 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/ConstraintsTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/ConstraintsTests.java @@ -6,6 +6,7 @@ import gov.nasa.jpl.aerie.e2e.types.ExternalDataset.ProfileInput; import gov.nasa.jpl.aerie.e2e.types.ExternalDataset.ProfileInput.ProfileSegmentInput; import gov.nasa.jpl.aerie.e2e.types.ValueSchema; +import gov.nasa.jpl.aerie.e2e.types.workspaces.HasuraRequestFailure; import gov.nasa.jpl.aerie.e2e.utils.GatewayRequests; import gov.nasa.jpl.aerie.e2e.utils.HasuraRequests; import org.junit.jupiter.api.*; @@ -94,13 +95,17 @@ void afterEach() throws IOException { @Test void constraintsFailNoSimData() { - final var exception = assertThrows(RuntimeException.class, () -> hasura.checkConstraints(planId)); - final var message = exception.getMessage().split("\"message\":\"")[1].split("\"}]")[0]; - // Hasura strips the cause message ("Assumption falsified -- mission model for existing plan does not exist") - // from the error it returns - if (!message.equals("input mismatch exception")) { - throw exception; - } + final var exception = assertThrows(HasuraRequestFailure.class, () -> hasura.checkConstraints(planId)); + + // Check the response message + final var expectedMessage = "plan with id " + planId + " has not yet been simulated at its current revision"; + assertEquals(expectedMessage, exception.getMessage()); + + // Check the attached extensions object + final var extensions = exception.getExtensions(); + assertEquals("INPUT_MISMATCH_EXCEPTION", extensions.getString("type")); + assertEquals(expectedMessage, extensions.getString("message")); + assertEquals("aerie_merlin", extensions.getString("service")); } @Test diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/MerlinBindingsTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/MerlinBindingsTests.java index e4431ee659..214738bae5 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/MerlinBindingsTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/MerlinBindingsTests.java @@ -110,7 +110,7 @@ void invalidPlanId() { .toString(); final var response = request.post("/getSimulationResults", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such plan", getBody(response).getString("message")); + assertEquals("No plan exists with id `-1`", getBody(response).getString("message")); } @Test @@ -184,7 +184,6 @@ class ResourceSamples { @Test void invalidPlanId() { // Returns a 404 if the PlanId is invalid - // message is "no such plan" final String data = Json.createObjectBuilder() .add("action", Json.createObjectBuilder().add("name", "resource_samples")) .add("input", Json.createObjectBuilder().add("planId", -1)) @@ -194,7 +193,7 @@ void invalidPlanId() { .toString(); final var response = request.post("/resourceSamples", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such plan", getBody(response).getString("message")); + assertEquals("No plan exists with id `-1`", getBody(response).getString("message")); } @Test @@ -259,13 +258,12 @@ void invalidPlanId() { .toString(); final var response = request.post("/constraintViolations", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such plan", getBody(response).getString("message")); + assertEquals("No plan exists with id `-1`", getBody(response).getString("message")); } @Test void invalidSimDatasetId() throws IOException { // Returns a 404 if the SimDatasetId is invalid - // Message is an "input mismatch exception" hasura.awaitSimulation(planId); final String data = Json.createObjectBuilder() .add("action", Json.createObjectBuilder().add("name", "check_constraints")) @@ -279,15 +277,17 @@ void invalidSimDatasetId() throws IOException { .toString(); final var response = request.post("/constraintViolations", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - final var expectedResponse = Json.createObjectBuilder() - .add("message", "input mismatch exception") - .add( - "extensions", Json.createObjectBuilder() - .add( - "cause", - "simulation dataset with id `-1` does not exist")) - .build(); - assertEquals(expectedResponse, getBody(response)); + + final var body = getBody(response); + final var extensions = body.getJsonObject("extensions"); + // Check the message field + final var expectedMessage = "simulation dataset with id `-1` does not exist"; + assertEquals(expectedMessage, body.getString("message")); + + // Check the extensions + assertEquals("INPUT_MISMATCH_EXCEPTION", extensions.getString("type")); + assertEquals(expectedMessage, extensions.getString("message")); + assertEquals("aerie_merlin", extensions.getString("service")); } @Test @@ -303,7 +303,6 @@ void incorrectSimDatasetId() throws IOException { final int simDatasetId = hasura.awaitSimulation(secondPlanId).simDatasetId(); // Returns a 404 because the simDataset belonged to a different plan - // Message is 'simulation dataset mismatch exception' final String data = Json.createObjectBuilder() .add("action", Json.createObjectBuilder().add("name", "check_constraints")) .add( @@ -316,13 +315,19 @@ void incorrectSimDatasetId() throws IOException { .toString(); final var response = request.post("/constraintViolations", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - final var expectedCause = - "Simulation Dataset with id `" + simDatasetId + "` does not belong to Plan with id `" + planId + "`"; - final var expectedResponse = Json.createObjectBuilder() - .add("message", "simulation dataset mismatch exception") - .add("extensions", Json.createObjectBuilder().add("cause", expectedCause)) - .build(); - assertEquals(expectedResponse, getBody(response)); + + // Check the response + final var body = getBody(response); + final var extensions = body.getJsonObject("extensions"); + + // Check the message field + final var expectedMessage = "Simulation Dataset with id `" + simDatasetId + "` does not belong to Plan with id `" + planId + "`"; + assertEquals(expectedMessage, body.getString("message")); + + // Check the extensions object + assertEquals("SIM_DATASET_MISMATCH_EXCEPTION", extensions.getString("type")); + assertEquals(expectedMessage, extensions.getString("message")); + assertEquals("aerie_merlin", extensions.getString("service")); } finally { hasura.deletePlan(secondPlanId); } @@ -358,12 +363,19 @@ void noSimDatasets() { .toString(); final var response = request.post("/constraintViolations", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - final var expectedCause = "plan with id " + planId + " has not yet been simulated at its current revision"; - final var expectedBody = Json.createObjectBuilder() - .add("message", "input mismatch exception") - .add("extensions", Json.createObjectBuilder().add("cause", expectedCause)) - .build(); - assertEquals(expectedBody, getBody(response)); + + // Check the response + final var body = getBody(response); + final var extensions = body.getJsonObject("extensions"); + + // Check the message field + final var expectedMessage = "plan with id " + planId + " has not yet been simulated at its current revision"; + assertEquals(expectedMessage, body.getString("message")); + + // Check the extensions object + assertEquals("INPUT_MISMATCH_EXCEPTION", extensions.getString("type")); + assertEquals(expectedMessage, extensions.getString("message")); + assertEquals("aerie_merlin", extensions.getString("service")); } @Test @@ -434,7 +446,7 @@ void invalidMissionModelId() { .toString(); final var response = request.post("/refreshModelParameters", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such mission model", getBody(response).getString("message")); + assertEquals("No mission model exists with id `-1`", getBody(response).getString("message")); } @Test @@ -474,7 +486,7 @@ void invalidMissionModelId() { .toString(); final var response = request.post("/refreshActivityTypes", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such mission model", getBody(response).getString("message")); + assertEquals("No mission model exists with id `-1`", getBody(response).getString("message")); } @Test @@ -502,7 +514,6 @@ class RefreshResourceTypes { @Test void invalidMissionModelId() { // Returns a 404 if the MissionModelId is invalid - // message is "no such mission model" final String data = Json.createObjectBuilder() .add( "event", Json.createObjectBuilder() @@ -514,7 +525,7 @@ void invalidMissionModelId() { .toString(); final var response = request.post("/refreshResourceTypes", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such mission model", getBody(response).getString("message")); + assertEquals("No mission model exists with id `-1`", getBody(response).getString("message")); } @Test @@ -542,7 +553,6 @@ class ValidateActivityArguments { @Test void invalidMissionModelId() { // Returns a 404 if the MissionModelId is invalid - // message is "no such mission model" final String data = Json.createObjectBuilder() .add("action", Json.createObjectBuilder().add("name", "validateActivityArguments")) .add( @@ -556,7 +566,10 @@ void invalidMissionModelId() { .toString(); final var response = request.post("/validateActivityArguments", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such mission model", getBody(response).getString("message")); + final var body = getBody(response); + assertEquals("No mission model exists with id `-1`", body.getString("message")); + assertTrue(body.containsKey("extensions")); + assertEquals("NO_SUCH_MISSION_MODEL", body.getJsonObject("extensions").getString("type")); } @Test @@ -585,7 +598,6 @@ class ValidateModelArguments { @Test void invalidMissionModelId() { // Returns a 404 if the MissionModelId is invalid - // message is "no such mission model" final String data = Json.createObjectBuilder() .add("action", Json.createObjectBuilder().add("name", "validateModelArguments")) .add( @@ -598,7 +610,7 @@ void invalidMissionModelId() { .toString(); final var response = request.post("/validateModelArguments", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such mission model", getBody(response).getString("message")); + assertEquals("No mission model exists with id `-1`", getBody(response).getString("message")); } @Test @@ -636,7 +648,7 @@ void invalidPlanId() { .toString(); final var response = request.post("/validatePlan", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such plan", getBody(response).getString("message")); + assertEquals("No plan exists with id `-1`", getBody(response).getString("message")); } @Test @@ -674,7 +686,7 @@ void invalidMissionModelId() { .toString(); final var response = request.post("/getModelEffectiveArguments", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such mission model", getBody(response).getString("message")); + assertEquals("No mission model exists with id `-1`", getBody(response).getString("message")); } @Test @@ -733,7 +745,7 @@ void invalidMissionModelId() { .toString(); final var response = request.post("/getActivityEffectiveArgumentsBulk", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such mission model", getBody(response).getString("message")); + assertEquals("No mission model exists with id `-1`", getBody(response).getString("message")); } @Test @@ -818,7 +830,7 @@ void invalidPlanId() { .toString(); final var response = request.post("/addExternalDataset", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such plan", getBody(response).getString("message")); + assertEquals("No plan exists with id `-1`", getBody(response).getString("message")); } @Test @@ -885,7 +897,7 @@ void invalidDatasetId() { .toString(); final var response = request.post("/extendExternalDataset", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such plan dataset", getBody(response).getString("message")); + assertEquals("No plan dataset exists with id `-1`", getBody(response).getString("message")); } @Test diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/SchedulerBindingsTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/SchedulerBindingsTests.java index 95939226d2..7f0065ca77 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/SchedulerBindingsTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/SchedulerBindingsTests.java @@ -89,7 +89,6 @@ class Schedule{ @Test void invalidSpecId(){ // Returns a 404 if the SpecId is invalid - // message is "no such scheduling specification" final String data = Json.createObjectBuilder() .add("action", Json.createObjectBuilder().add("name", "scheduler")) .add("input", Json.createObjectBuilder().add("specificationId", -1)) @@ -99,7 +98,19 @@ void invalidSpecId(){ .toString(); final var response = request.post("/schedule", RequestOptions.create().setData(data)); assertEquals(404, response.status()); - assertEquals("no such scheduling specification", getBody(response).getString("message")); + + // Check the response + final var body = getBody(response); + final var extensions = body.getJsonObject("extensions"); + + // Check the message field + final var expectedMessage = "Could not check permissions on scheduling specification -1: specification does not exist."; + assertEquals(expectedMessage, body.getString("message")); + + // Check the extensions object + assertEquals("NO_SUCH_SCHEDULING_SPECIFICATION", extensions.getString("type")); + assertEquals(expectedMessage, extensions.getString("message")); + assertEquals("aerie_permissions", extensions.getString("service")); } @Test void forbidden(){ @@ -172,7 +183,7 @@ void invalidPlanId() { assertEquals(200, response.status()); final var expectedBody = Json.createObjectBuilder() .add("status", "failure") - .add("reason", "No plan exists with id `PlanId[id=-1]`") + .add("reason", "No plan exists with id `-1`") .build(); assertEquals(expectedBody, getBody(response)); } diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/MetadataWorkspaceRoutesTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/MetadataWorkspaceRoutesTests.java index 018e733305..952751bdcc 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/MetadataWorkspaceRoutesTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/MetadataWorkspaceRoutesTests.java @@ -231,7 +231,7 @@ void forbiddenInsufficientPrivileges() { assertEquals("FORBIDDEN", body.getString("type")); assertEquals("Role 'viewer' is not allowed to perform action 'write_file_directory'", body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** @@ -247,7 +247,7 @@ void forbiddenNotOwner() { assertEquals(("User '%s' with role 'user' cannot perform 'write_file_directory' " + "because they are not a 'OWNER_COLLABORATOR' for workspace with id '%d'").formatted(nonOwner.name(), workspaceId), body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** @@ -259,7 +259,7 @@ void noSuchWorkspace() { assertEquals(404, resp.status()); final var body = getBody(resp); assertEquals("NO_SUCH_WORKSPACE", body.getString("type")); - assertEquals("Could not check permissions on Workspace -1.", body.getString("message")); + assertEquals("Could not check permissions on workspace -1: workspace does not exist.", body.getString("message")); } /** @@ -719,7 +719,7 @@ void forbiddenInsufficientPrivileges() { assertEquals("FORBIDDEN", body.getString("type")); assertEquals("Role 'viewer' is not allowed to perform action 'write_file_directory'", body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** @@ -735,7 +735,7 @@ void forbiddenNotOwner() { assertEquals(("User '%s' with role 'user' cannot perform 'write_file_directory' " + "because they are not a 'OWNER_COLLABORATOR' for workspace with id '%d'").formatted(nonOwner.name(), workspaceId), body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** @@ -747,7 +747,7 @@ void noSuchWorkspace() { assertEquals(404, resp.status()); final var body = getBody(resp); assertEquals("NO_SUCH_WORKSPACE", body.getString("type")); - assertEquals("Could not check permissions on Workspace -1.", body.getString("message")); + assertEquals("Could not check permissions on workspace -1: workspace does not exist.", body.getString("message")); } /** @@ -1099,7 +1099,7 @@ void forbiddenInsufficientPrivileges() { assertEquals("FORBIDDEN", body.getString("type")); assertEquals(("Role 'viewer' is not allowed to perform action 'delete_file_directory'"), body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** @@ -1115,7 +1115,7 @@ void forbiddenNotOwner() { assertEquals(("User '%s' with role 'user' cannot perform 'delete_file_directory' " + "because they are not a 'OWNER_COLLABORATOR' for workspace with id '%d'").formatted(nonOwner.name(), workspaceId), body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** @@ -1127,7 +1127,7 @@ void noSuchWorkspace() { assertEquals(404, resp.status()); final var body = getBody(resp); assertEquals("NO_SUCH_WORKSPACE", body.getString("type")); - assertEquals("Could not check permissions on Workspace -1.", body.getString("message")); + assertEquals("Could not check permissions on workspace -1: workspace does not exist.", body.getString("message")); } /** diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/WorkspaceAuthorizeTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/WorkspaceAuthorizeTests.java index 7fcebe28ff..b742009d94 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/WorkspaceAuthorizeTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/WorkspaceAuthorizeTests.java @@ -192,7 +192,7 @@ void validSecretUserIdActiveRole() { final var body = getBody(response); assertEquals("FORBIDDEN", body.getString("type")); assertEquals("Role 'viewer' is not allowed to perform action 'write_file_directory'", body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } } @@ -288,7 +288,7 @@ void validJWTValidRole() { final var body = getBody(response); assertEquals("FORBIDDEN", body.getString("type")); assertEquals("Role 'viewer' is not allowed to perform action 'write_file_directory'", body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/WorkspaceManagementRoutesTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/WorkspaceManagementRoutesTests.java index c727b49cf3..6e6e2b8581 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/WorkspaceManagementRoutesTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/bindings/workspace/WorkspaceManagementRoutesTests.java @@ -93,7 +93,7 @@ void forbiddenInsufficientPrivileges() { final var body = getBody(response); assertEquals("FORBIDDEN", body.getString("type")); assertEquals("Role 'viewer' is not allowed to perform action 'create_workspace'", body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } @ParameterizedTest @@ -202,7 +202,7 @@ void forbiddenInsufficientPrivileges() { assertEquals(("User 'bindings_not_owner' with role 'user' cannot perform 'delete_workspace' " + "because they are not a 'OWNER' for workspace with id '%d'").formatted(workspaceId), body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** @@ -218,7 +218,7 @@ void forbiddenNotOwner() { assertEquals(("User 'bindings_not_owner' with role 'user' cannot perform 'delete_workspace' " + "because they are not a 'OWNER' for workspace with id '%d'").formatted(workspaceId), body.getString("message")); - assertEquals("aerie_workspace", body.getString("service")); + assertEquals("aerie_permissions", body.getString("service")); } /** diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicSchedulingTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicSchedulingTests.java index e489f92d8e..0a49b49103 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicSchedulingTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicSchedulingTests.java @@ -62,7 +62,7 @@ void proceduralUploadWorks() throws IOException { void executeSchedulingRunWithoutArguments() throws IOException { final var resp = hasura.awaitFailingScheduling(specId); final var message = resp.reason().getString("message"); - assertTrue(message.contains("java.lang.RuntimeException: gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException: Invalid arguments for input type \"DumbRecurrenceGoal\": extraneous arguments: [], unconstructable arguments: [], missing arguments: [MissingArgument[parameterName=biteSize, schema=IntSchema[]]], valid arguments: [ValidArgument[parameterName=quantity, serializedValue=NumericValue[value=360]]]")); + assertTrue(message.contains("gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException: Invalid arguments for input type \"DumbRecurrenceGoal\": extraneous arguments: [], unconstructable arguments: [], missing arguments: [MissingArgument[parameterName=biteSize, schema=IntSchema[]]], valid arguments: [ValidArgument[parameterName=quantity, serializedValue=NumericValue[value=360]]]")); } /** diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/ActivityValidation.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/ActivityValidation.java index d5e379496b..2dbd499f7c 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/ActivityValidation.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/ActivityValidation.java @@ -44,10 +44,12 @@ static ActivityValidation fromJSON(JsonObject obj) { $ -> new ValidationNotice( getStringArray($, "subjects"), $.asJsonObject().getString("message")))); - case "NO_SUCH_ACTIVITY_TYPE" -> new NoSuchActivityTypeFailure(errors.getJsonObject("noSuchActivityError").getString("message"), errors.getJsonObject("noSuchActivityError").getString("activity_type")); + case "NO_SUCH_ACTIVITY_TYPE" -> new NoSuchActivityTypeFailure( + errors.getJsonObject("noSuchActivityError").getString("message"), + errors.getJsonObject("noSuchActivityError").getJsonObject("data").getString("activity_type")); case "NO_SUCH_MISSION_MODEL" -> new NoSuchMissionModelFailure( errors.getJsonObject("noSuchMissionModelError").getString("message"), - errors.getJsonObject("noSuchMissionModelError").getJsonNumber("mission_model_id").longValue() + errors.getJsonObject("noSuchMissionModelError").getJsonObject("data").getJsonNumber("mission_model_id").longValue() ); default -> throw new RuntimeException("Unhandled error type: " + type); }; diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/workspaces/HasuraRequestFailure.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/workspaces/HasuraRequestFailure.java new file mode 100644 index 0000000000..dc9acae28f --- /dev/null +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/workspaces/HasuraRequestFailure.java @@ -0,0 +1,27 @@ +package gov.nasa.jpl.aerie.e2e.types.workspaces; + +import javax.json.JsonArray; +import javax.json.JsonObject; + +public class HasuraRequestFailure extends RuntimeException { + private final JsonObject responseObject; + + public HasuraRequestFailure(JsonArray errors) + { + super(errors.toString()); + responseObject = errors.getJsonObject(0); + } + + public JsonObject getResponse() { + return responseObject; + } + + @Override + public String getMessage() { + return responseObject.getString("message"); + } + + public JsonObject getExtensions() { + return responseObject.getJsonObject("extensions"); + } +} diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/HasuraRequests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/HasuraRequests.java index b72b0cfd69..301aea3c23 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/HasuraRequests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/HasuraRequests.java @@ -6,6 +6,7 @@ import com.microsoft.playwright.TimeoutError; import com.microsoft.playwright.options.RequestOptions; import gov.nasa.jpl.aerie.e2e.types.*; +import gov.nasa.jpl.aerie.e2e.types.workspaces.HasuraRequestFailure; import org.apache.commons.lang3.tuple.Pair; import javax.json.Json; @@ -93,7 +94,7 @@ private JsonObject makeRequest( final var bodyJson = RequestBodyHelper.getBody(response); if (bodyJson.containsKey("errors")) { System.err.println("Errors in response: \n" + bodyJson.get("errors")); - throw new RuntimeException(bodyJson.toString()); + throw new HasuraRequestFailure(bodyJson.getJsonArray("errors")); } return bodyJson.getJsonObject("data"); } diff --git a/examples/banananation/src/main/java/gov/nasa/jpl/aerie/banananation/activities/ExceptionActivity.java b/examples/banananation/src/main/java/gov/nasa/jpl/aerie/banananation/activities/ExceptionActivity.java index 167daea947..7c270d536c 100644 --- a/examples/banananation/src/main/java/gov/nasa/jpl/aerie/banananation/activities/ExceptionActivity.java +++ b/examples/banananation/src/main/java/gov/nasa/jpl/aerie/banananation/activities/ExceptionActivity.java @@ -1,6 +1,8 @@ package gov.nasa.jpl.aerie.banananation.activities; +import gov.nasa.jpl.aerie.banananation.Mission; import gov.nasa.jpl.aerie.merlin.framework.annotations.ActivityType; +import gov.nasa.jpl.aerie.merlin.framework.annotations.ActivityType.EffectModel; import gov.nasa.jpl.aerie.merlin.framework.annotations.Export.Parameter; import gov.nasa.jpl.aerie.merlin.framework.annotations.Export.Validation; @@ -21,7 +23,8 @@ public boolean conditionallyThrowException() { return true; } - public void run() { + @EffectModel + public void run(final Mission mission) { if (this.throwException) { throw new RuntimeException("Throwing runtime exception during runtime"); } diff --git a/merlin-server/build.gradle b/merlin-server/build.gradle index ff0d727156..6494b98b1e 100644 --- a/merlin-server/build.gradle +++ b/merlin-server/build.gradle @@ -92,6 +92,7 @@ dependencies { implementation 'io.javalin:javalin:5.6.3' implementation 'org.slf4j:slf4j-simple:2.0.7' implementation 'org.glassfish:javax.json:1.1.4' + implementation("com.fasterxml.jackson.core:jackson-databind:2.20.1") implementation 'org.postgresql:postgresql:42.6.1' implementation 'com.zaxxer:HikariCP:5.0.1' diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/AerieAppDriver.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/AerieAppDriver.java index 1549f10367..714080a75a 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/AerieAppDriver.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/AerieAppDriver.java @@ -5,9 +5,7 @@ import gov.nasa.jpl.aerie.merlin.server.config.AppConfiguration; import gov.nasa.jpl.aerie.merlin.server.config.PostgresStore; import gov.nasa.jpl.aerie.merlin.server.config.Store; -import gov.nasa.jpl.aerie.merlin.server.http.LocalAppExceptionBindings; import gov.nasa.jpl.aerie.merlin.server.http.MerlinBindings; -import gov.nasa.jpl.aerie.merlin.server.http.MissionModelRepositoryExceptionBindings; import gov.nasa.jpl.aerie.merlin.server.remotes.ConstraintRepository; import gov.nasa.jpl.aerie.merlin.server.remotes.MissionModelRepository; import gov.nasa.jpl.aerie.merlin.server.remotes.PlanRepository; @@ -118,8 +116,6 @@ public static void main(final String[] args) { if (configuration.enableJavalinDevLogging()) config.plugins.enableDevLogging(); config.plugins.enableCors(cors -> cors.add(it -> it.anyHost())); config.plugins.register(merlinBindings); - config.plugins.register(new LocalAppExceptionBindings()); - config.plugins.register(new MissionModelRepositoryExceptionBindings()); config.jetty.server(() -> server); }); diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/MerlinFormattedError.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/MerlinFormattedError.java new file mode 100644 index 0000000000..6d91e12ecc --- /dev/null +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/MerlinFormattedError.java @@ -0,0 +1,106 @@ +package gov.nasa.jpl.aerie.merlin.server.exceptions; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import gov.nasa.jpl.aerie.constraints.InputMismatchException; +import gov.nasa.jpl.aerie.json.FormattedError; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader.MissionModelLoadException; +import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonEntityException; +import gov.nasa.jpl.aerie.merlin.server.models.ProcedureLoader; +import gov.nasa.jpl.aerie.merlin.server.remotes.postgres.DatabaseException; +import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService.NoSuchMissionModelException; +import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService.NoSuchActivityTypeException; + +import javax.json.Json; + +/** + * Class for formatting exceptions thrown into JSON objects that meet the Aerie HTTP endpoint error message format + * Relevant ticket going over said format: https://github.com/NASA-AMMOS/aerie/issues/1732 + */ +@JsonSerialize(using = FormattedError.FormattedErrorSerializer.class) +public class MerlinFormattedError extends FormattedError { + // region "NO SUCH X" Exceptions + public MerlinFormattedError(NoSuchPlanException npe) { + super( + AerieService.MERLIN_SERVER, + "NO_SUCH_PLAN", + npe, + Json.createObjectBuilder() + .add("plan_id", npe.id.id()) + .build() + ); + } + + public MerlinFormattedError(NoSuchPlanDatasetException npe) { + super( + AerieService.MERLIN_SERVER, + "NO_SUCH_PLAN_DATASET", + npe, + Json.createObjectBuilder() + .add("dataset_id", npe.id.id()) + .build() + ); + } + + public MerlinFormattedError(NoSuchMissionModelException nme) { + super( + AerieService.MERLIN_SERVER, + "NO_SUCH_MISSION_MODEL", + nme, + Json.createObjectBuilder() + .add("mission_model_id", nme.missionModelId.id()) + .build() + ); + } + + public MerlinFormattedError(NoSuchActivityTypeException nae) { + super( + AerieService.MERLIN_SERVER, + "NO_SUCH_ACTIVITY_TYPE", + nae, + Json.createObjectBuilder() + .add("activity_type", nae.activityTypeId) + .build() + ); + } + + public MerlinFormattedError(NoSuchActivityTypeException nae, String message) { + super( + AerieService.MERLIN_SERVER, + "NO_SUCH_ACTIVITY_TYPE", + message, + nae, + Json.createObjectBuilder() + .add("activity_type", nae.activityTypeId) + .build() + ); + } + + public MerlinFormattedError(NoSuchConstraintException ex) { + super(AerieService.MERLIN_SERVER, "NO_SUCH_CONSTRAINT", ex); + } + // endregion + + public MerlinFormattedError(MissionModelLoadException mle) { + super(AerieService.MERLIN_SERVER, "MISSION_MODEL_LOAD_EXCEPTION", mle); + } + + public MerlinFormattedError(InvalidJsonEntityException ex) { + super(AerieService.MERLIN_SERVER, "JSON_PARSING_EXCEPTION", ex); + } + + public MerlinFormattedError(InputMismatchException ex) { + super(AerieService.MERLIN_SERVER, "INPUT_MISMATCH_EXCEPTION", ex); + } + + public MerlinFormattedError(SimulationDatasetMismatchException ex) { + super(AerieService.MERLIN_SERVER, "SIM_DATASET_MISMATCH_EXCEPTION", ex); + } + + public MerlinFormattedError(DatabaseException ex) { + super(AerieService.MERLIN_SERVER, "DATABASE_EXCEPTION", ex); + } + + public MerlinFormattedError(ProcedureLoader.ProcedureLoadException ex) { + super(AerieService.MERLIN_SERVER, "PROCEDURE_LOAD_EXCEPTION", ex); + } +} diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/NoSuchPlanDatasetException.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/NoSuchPlanDatasetException.java index 0c285bd7a1..fca3e9d6f7 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/NoSuchPlanDatasetException.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/NoSuchPlanDatasetException.java @@ -6,7 +6,7 @@ public final class NoSuchPlanDatasetException extends Exception { public final DatasetId id; public NoSuchPlanDatasetException(final DatasetId id) { - super("No plan dataset exists with id `" + id + "`"); + super("No plan dataset exists with id `" + id.id() + "`"); this.id = id; } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/NoSuchPlanException.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/NoSuchPlanException.java index f790fbeb9c..07bad878c7 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/NoSuchPlanException.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/NoSuchPlanException.java @@ -6,7 +6,7 @@ public final class NoSuchPlanException extends Exception { public final PlanId id; public NoSuchPlanException(final PlanId id) { - super("No plan exists with id `" + id + "`"); + super("No plan exists with id `" + id.id() + "`"); this.id = id; } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidEntityException.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidEntityException.java deleted file mode 100644 index 3fd71ed8e5..0000000000 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidEntityException.java +++ /dev/null @@ -1,14 +0,0 @@ -package gov.nasa.jpl.aerie.merlin.server.http; - -import java.util.List; - -import static gov.nasa.jpl.aerie.json.JsonParseResult.FailureReason; - -public class InvalidEntityException extends Exception { - - public final List failures; - - public InvalidEntityException(List failures) { - this.failures = List.copyOf(failures); - } -} diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidJsonEntityException.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidJsonEntityException.java new file mode 100644 index 0000000000..6ec3194ee9 --- /dev/null +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidJsonEntityException.java @@ -0,0 +1,15 @@ +package gov.nasa.jpl.aerie.merlin.server.http; + +import java.util.List; +import static gov.nasa.jpl.aerie.json.JsonParseResult.FailureReason; + +public class InvalidJsonEntityException extends Exception { + + public final List failures; + + public InvalidJsonEntityException(List failures) { + super("JSON Parsing Exception was caused by the following failures:\n\t"+ + String.join(",\n\t", failures.stream().map(FailureReason::toString).toList())); + this.failures = List.copyOf(failures); + } +} diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidJsonException.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidJsonException.java deleted file mode 100644 index dce8eca1c8..0000000000 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidJsonException.java +++ /dev/null @@ -1,7 +0,0 @@ -package gov.nasa.jpl.aerie.merlin.server.http; - -public class InvalidJsonException extends Exception { - public InvalidJsonException(Throwable cause) { - super(cause); - } -} diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/LocalAppExceptionBindings.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/LocalAppExceptionBindings.java deleted file mode 100644 index 06a2621f99..0000000000 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/LocalAppExceptionBindings.java +++ /dev/null @@ -1,16 +0,0 @@ -package gov.nasa.jpl.aerie.merlin.server.http; - -import gov.nasa.jpl.aerie.merlin.server.services.LocalMissionModelService; -import io.javalin.Javalin; -import io.javalin.plugin.Plugin; - - -public final class LocalAppExceptionBindings implements Plugin { - @Override - public void apply(final Javalin javalin) { - javalin.exception(LocalMissionModelService.MissionModelLoadException.class, (ex, ctx) -> ctx - .status(500) - .result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()) - .contentType("application/json")); - } -} diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java index 434b41d014..728fc9827d 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java @@ -1,7 +1,13 @@ package gov.nasa.jpl.aerie.merlin.server.http; import gov.nasa.jpl.aerie.constraints.InputMismatchException; -import gov.nasa.jpl.aerie.permissions.exceptions.Forbidden; +import gov.nasa.jpl.aerie.json.FormattedError; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader.MissionModelLoadException; +import gov.nasa.jpl.aerie.merlin.server.exceptions.MerlinFormattedError; +import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchConstraintException; +import gov.nasa.jpl.aerie.merlin.server.models.ProcedureLoader; +import gov.nasa.jpl.aerie.merlin.server.remotes.postgres.DatabaseException; +import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsException; import gov.nasa.jpl.aerie.types.SerializedActivity; import gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanDatasetException; @@ -11,20 +17,23 @@ import gov.nasa.jpl.aerie.merlin.server.models.PlanId; import gov.nasa.jpl.aerie.merlin.server.services.GenerateConstraintsLibAction; import gov.nasa.jpl.aerie.merlin.server.services.GetSimulationResultsAction; -import gov.nasa.jpl.aerie.merlin.server.services.LocalMissionModelService; import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService; import gov.nasa.jpl.aerie.merlin.server.services.PlanService; import gov.nasa.jpl.aerie.permissions.HasuraAction; import gov.nasa.jpl.aerie.permissions.PermissionsService; -import gov.nasa.jpl.aerie.permissions.exceptions.ExceptionSerializers; -import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; import io.javalin.Javalin; import io.javalin.http.Context; +import io.javalin.http.HttpResponseException; +import io.javalin.http.UnauthorizedResponse; import io.javalin.plugin.Plugin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.json.Json; +import javax.json.JsonException; import javax.json.stream.JsonParsingException; import java.io.IOException; +import java.sql.SQLException; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -69,6 +78,7 @@ public final class MerlinBindings implements Plugin { private final GenerateConstraintsLibAction generateConstraintsLibAction; private final ConstraintAction constraintAction; private final PermissionsService permissionsService; + private static final Logger logger = LoggerFactory.getLogger(MerlinBindings.class); public MerlinBindings( final MissionModelService missionModelService, @@ -88,6 +98,9 @@ public MerlinBindings( @Override public void apply(final Javalin javalin) { + // Since all of these endpoints are Hasura Actions, toggle Formatted Error writing to Hasura style + FormattedError.FormattedErrorSerializer.USE_HASURA_FORMATTING = true; + javalin.routes(() -> { before(ctx -> ctx.contentType("application/json")); @@ -112,11 +125,53 @@ public void apply(final Javalin javalin) { path("health", () -> get(ctx -> ctx.status(200))); }); - // This exception is expected when the request body entity is not a legal JsonValue. - javalin.exception(JsonParsingException.class, (ex, ctx) -> ctx - .status(400) - .result(ResponseSerializers.serializeJsonParsingException(ex).toString()) - .contentType("application/json")); + // Default exception handlers for common endpoint exceptions + javalin.exception(JsonException.class, + (ex, ctx) -> ctx.status(400) + .json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex))); + javalin.exception(NoSuchPlanException.class, + (ex, ctx) -> ctx.status(404).json(new MerlinFormattedError(ex))); + javalin.exception(IOException.class, (ex, ctx) -> { + final var fe = new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex); + logger.warn("IO Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception( + SQLException.class, (ex, ctx) -> { + final var fe = new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex); + logger.warn("SQL Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception( + UnauthorizedResponse.class, (ex, ctx) -> { + final var message = ex.getMessage() != null ? ex.getMessage() : "Unauthorized"; + logger.warn("401 Unauthorized: {}", message); + ctx.status(401).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + }); + javalin.exception(NumberFormatException.class, (ex, ctx) -> + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex))); + javalin.exception(SecurityException.class, (ex, ctx) -> { + final var fe = new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex); + logger.warn("Security Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception(DatabaseException.class, (ex, ctx) -> { + final var fe = new MerlinFormattedError(ex); + logger.warn("Database Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception(MissionModelLoadException.class, (ex, ctx) -> + ctx.status(500).json(new MerlinFormattedError(ex))); + javalin.exception( + HttpResponseException.class, (ex, ctx) -> + ctx.status(ex.getStatus()).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, "HTTP_RESPONSE_EXCEPTION", ex))); + javalin.exception(Exception.class, (ex, ctx) -> { + // Catch-all for unexpected issues + final var message = ex.getMessage() != null ? ex.getMessage() : "Unknown error."; + final var fe = new FormattedError(FormattedError.AerieService.MERLIN_SERVER, "UNKNOWN_ERROR", message, ex); + logger.error("Unexpected error processing request: {}", fe); + ctx.status(500).json(fe); + }); } private void postRefreshModelParameters(final Context ctx) { @@ -124,14 +179,14 @@ private void postRefreshModelParameters(final Context ctx) { final var missionModelId = parseJson(ctx.body(), hasuraMissionModelEventTriggerP).missionModelId(); this.missionModelService.refreshModelParameters(missionModelId); ctx.status(200); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -140,14 +195,14 @@ private void postRefreshActivityTypes(final Context ctx) { final var missionModelId = parseJson(ctx.body(), hasuraMissionModelEventTriggerP).missionModelId(); this.missionModelService.refreshActivityTypes(missionModelId); ctx.status(200); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -156,14 +211,14 @@ private void postRefreshResourceTypes(Context ctx) { final var missionModelId = parseJson(ctx.body(), hasuraMissionModelEventTriggerP).missionModelId(); this.missionModelService.refreshResourceTypes(missionModelId); ctx.status(200); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -175,14 +230,14 @@ private void getResourceTypes(final Context ctx) { final var schemaMap = this.missionModelService.getResourceSchemas(missionModelId); ctx.result(ResponseSerializers.serializeValueSchemas(schemaMap).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -200,10 +255,14 @@ private void refreshConstrainProcedureParameterTypes(final Context ctx) { final var revision = body.revision(); this.constraintAction.refreshConstraintProcedureParameterTypes(constraintId, revision); ctx.status(200); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (NoSuchConstraintException ex) { + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (ProcedureLoader.ProcedureLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -217,22 +276,19 @@ private void getSimulationResults(final Context ctx) { final var response = this.simulationAction.run(planId, force, body.session()); ctx.result(ResponseSerializers.serializeSimulationResultsResponse(response).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch(final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); + } catch (PermissionsException pe) { + if (pe.httpStatusCode() == 500) { + logger.warn("Permissions Service Exception: {}", pe.formattedError()); + } + ctx.status(pe.httpStatusCode()).json(pe.formattedError()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); + } catch(final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); } catch (final NoSuchPlanException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchPlanException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final gov.nasa.jpl.aerie.permissions.exceptions.NoSuchPlanException ex) { - ctx.status(404).result(ExceptionSerializers.serializeNoSuchPlanException(ex).toString()); - } catch (final PermissionsServiceException ex) { - ctx.status(503).result(ExceptionSerializers.serializePermissionsServiceException(ex).toString()); - } catch (final Forbidden ex) { - ctx.status(403).result(ExceptionSerializers.serializeForbiddenException(ex).toString()); - } catch (final IOException ex) { - ctx.status(500).result(ExceptionSerializers.serializeIOException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } } @@ -244,23 +300,20 @@ private void getResourceSamples(final Context ctx) { this.checkPermissions(HasuraAction.resource_samples, body.session(), planId); final var resourceSamples = this.simulationAction.getResourceSamples(planId); - ctx.result(ResponseSerializers.serializeResourceSamples(resourceSamples).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + ctx.json(ResponseSerializers.serializeResourceSamples(resourceSamples).toString()); + } catch (PermissionsException pe) { + if (pe.httpStatusCode() == 500) { + logger.warn("Permissions Service Exception: {}", pe.formattedError()); + } + ctx.status(pe.httpStatusCode()).json(pe.formattedError()); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } catch (final NoSuchPlanException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchPlanException(ex).toString()); - } catch (final gov.nasa.jpl.aerie.permissions.exceptions.NoSuchPlanException ex) { - ctx.status(404).result(ExceptionSerializers.serializeNoSuchPlanException(ex).toString()); - } catch (final PermissionsServiceException ex) { - ctx.status(503).result(ExceptionSerializers.serializePermissionsServiceException(ex).toString()); - } catch (final Forbidden ex) { - ctx.status(403).result(ExceptionSerializers.serializeForbiddenException(ex).toString()); - } catch (final IOException ex) { - ctx.status(500).result(ExceptionSerializers.serializeIOException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } -} + } private void getConstraintViolations(final Context ctx) { try { final var body = parseJson(ctx.body(), hasuraConstraintsViolationsActionP); @@ -275,26 +328,23 @@ private void getConstraintViolations(final Context ctx) { final var constraintViolations = this.constraintAction.getViolations(planId, simulationDatasetId, force, body.session()); ctx.result(ResponseSerializers.serializeConstraintResults(constraintViolations.getLeft(), constraintViolations.getRight()).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + } catch (PermissionsException pe) { + if (pe.httpStatusCode() == 500) { + logger.warn("Permissions Service Exception: {}", pe.formattedError()); + } + ctx.status(pe.httpStatusCode()).json(pe.formattedError()); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex).toString()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } catch (final NoSuchPlanException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchPlanException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final gov.nasa.jpl.aerie.permissions.exceptions.NoSuchPlanException ex) { - ctx.status(404).result(ExceptionSerializers.serializeNoSuchPlanException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } catch (final InputMismatchException ex) { - ctx.status(404).result(ResponseSerializers.serializeInputMismatchException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } catch (SimulationDatasetMismatchException ex) { - ctx.status(404).result(ResponseSerializers.serializeSimulationDatasetMismatchException(ex).toString()); - } catch (final PermissionsServiceException ex) { - ctx.status(503).result(ExceptionSerializers.serializePermissionsServiceException(ex).toString()); - } catch (final Forbidden ex) { - ctx.status(403).result(ExceptionSerializers.serializeForbiddenException(ex).toString()); - } catch (final IOException ex) { - ctx.status(500).result(ExceptionSerializers.serializeIOException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } } @@ -315,13 +365,13 @@ private void validateActivityArguments(final Context ctx) { ctx.status(400) .result(ResponseSerializers.serializeFailures(List.of(ex.getMessage())).toString()); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -335,16 +385,14 @@ private void validateModelArguments(final Context ctx) { ctx.result(ResponseSerializers.serializeValidationNotices(notices).toString()); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } catch (final InstantiationException ex) { ctx.status(400) .result(ResponseSerializers.serializeFailures(List.of(ex.getMessage())).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -359,16 +407,16 @@ private void validatePlan(final Context ctx) { final var failures = this.missionModelService.validateActivityInstantiations(plan.missionModelId(), activities); ctx.result(ResponseSerializers.serializeUnconstructableActivityFailures(failures).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } catch (final NoSuchPlanException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchPlanException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -383,13 +431,13 @@ private void getModelEffectiveArguments(final Context ctx) { } catch (final InstantiationException ex) { ctx.status(200).result(ResponseSerializers.serializeInstantiationException(ex).toString()); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -410,13 +458,13 @@ private void getActivityEffectiveArguments(final Context ctx) { ctx.result(ResponseSerializers.serializeBulkEffectiveArgumentResponse(arguments.get(0)).toString()); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -430,13 +478,13 @@ private void getActivityEffectiveArgumentsBulk(final Context ctx) { ctx.result(ResponseSerializers.serializeBulkEffectiveArgumentResponseList(response).toString()); } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final LocalMissionModelService.MissionModelLoadException ex) { - ctx.status(400).result(ResponseSerializers.serializeMissionModelLoadException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); + } catch (final MissionModelLoadException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -446,10 +494,8 @@ private void getConstraintProcedureEffectiveArgumentsBulk(final Context ctx) { final var responses = this.constraintAction.getConstraintProcedureEffectiveArgumentsBulk(input.input()); ctx.result(ResponseSerializers.serializeIterable( ResponseSerializers::serializeConstraintBulkEffectiveArgumentResponse, responses).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -469,20 +515,17 @@ private void addExternalDataset(final Context ctx) { final var datasetId = this.planService.addExternalDataset(planId, simulationDatasetId, datasetStart, profileSet); ctx.status(201).result(ResponseSerializers.serializeCreatedDatasetId(datasetId).toString()); + } catch (PermissionsException pe) { + if (pe.httpStatusCode() == 500) { + logger.warn("Permissions Service Exception: {}", pe.formattedError()); + } + ctx.status(pe.httpStatusCode()).json(pe.formattedError()); } catch (final NoSuchPlanException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchPlanException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final gov.nasa.jpl.aerie.permissions.exceptions.NoSuchPlanException ex) { - ctx.status(404).result(ExceptionSerializers.serializeNoSuchPlanException(ex).toString()); - } catch (final PermissionsServiceException ex) { - ctx.status(503).result(ExceptionSerializers.serializePermissionsServiceException(ex).toString()); - } catch (final Forbidden ex) { - ctx.status(403).result(ExceptionSerializers.serializeForbiddenException(ex).toString()); - } catch (final IOException ex) { - ctx.status(500).result(ExceptionSerializers.serializeIOException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -500,11 +543,11 @@ private void extendExternalDataset(final Context ctx) { .add("datasetId", datasetId.id()) .build().toString()); } catch (final NoSuchPlanDatasetException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchPlanDatasetException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + ctx.status(404).json(new MerlinFormattedError(ex)); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); } } @@ -542,13 +585,14 @@ private void getConstraintsDslTypescript(final Context ctx) { .add("reason", r.reason()) .build().toString(); } else { - throw new Error("Unhandled variant of Response: " + response); + ctx.status(500).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, "Unhandled variant of Constraints Response: " + response)); + return; } ctx.result(resultString); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new MerlinFormattedError(ex)); + } catch (final JsonParsingException ex) { + ctx.status(400).json(new FormattedError(FormattedError.AerieService.MERLIN_SERVER, ex)); } } @@ -556,8 +600,7 @@ private void checkPermissions( final HasuraAction action, final gov.nasa.jpl.aerie.merlin.server.models.HasuraAction.Session session, final PlanId planId - ) throws gov.nasa.jpl.aerie.permissions.exceptions.NoSuchPlanException, Forbidden, IOException, PermissionsServiceException - { + ) throws PermissionsException { final var permissionsPlanId = new gov.nasa.jpl.aerie.permissions.gql.PlanId(planId.id()); permissionsService.check(action, session.hasuraRole(), session.hasuraUserId(), permissionsPlanId); } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinParsers.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinParsers.java index 43174bb9b4..220292bcbb 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinParsers.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinParsers.java @@ -119,15 +119,11 @@ public static JsonParser constraintIdP() { public static T parseJson(final String subject, final JsonParser parser) - throws InvalidJsonException, InvalidEntityException + throws JsonParsingException, InvalidJsonEntityException { - try { final var requestJson = Json.createReader(new StringReader(subject)).readValue(); final var result = parser.parse(requestJson); - return result.getSuccessOrThrow($ -> new InvalidEntityException(List.of($))); - } catch (JsonParsingException e) { - throw new InvalidJsonException(e); - } + return result.getSuccessOrThrow($ -> new InvalidJsonEntityException(List.of($))); } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MissionModelRepositoryExceptionBindings.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MissionModelRepositoryExceptionBindings.java deleted file mode 100644 index 4cfd948f99..0000000000 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MissionModelRepositoryExceptionBindings.java +++ /dev/null @@ -1,15 +0,0 @@ -package gov.nasa.jpl.aerie.merlin.server.http; - -import gov.nasa.jpl.aerie.merlin.server.remotes.MissionModelAccessException; -import io.javalin.Javalin; -import io.javalin.plugin.Plugin; - -public final class MissionModelRepositoryExceptionBindings implements Plugin { - @Override - public void apply(final Javalin javalin) { - javalin.exception(MissionModelAccessException.class, (ex, ctx) -> ctx - .status(500) - .result(ResponseSerializers.serializeMissionModelAccessException(ex).toString()) - .contentType("application/json")); - } -} diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/ResponseSerializers.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/ResponseSerializers.java index 0b65c0c82a..e3b11e9af7 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/ResponseSerializers.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/ResponseSerializers.java @@ -1,37 +1,27 @@ package gov.nasa.jpl.aerie.merlin.server.http; -import gov.nasa.jpl.aerie.constraints.InputMismatchException; import gov.nasa.jpl.aerie.constraints.model.ConstraintResult; -import gov.nasa.jpl.aerie.json.JsonParseResult.FailureReason; -import gov.nasa.jpl.aerie.merlin.server.models.ConstraintId; +import gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException; +import gov.nasa.jpl.aerie.merlin.server.exceptions.MerlinFormattedError; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintRecord; import gov.nasa.jpl.aerie.merlin.driver.json.ValueSchemaJsonParser; import gov.nasa.jpl.aerie.merlin.protocol.model.InputType.Parameter; import gov.nasa.jpl.aerie.merlin.protocol.model.InputType.ValidationNotice; import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; -import gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException; import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; import gov.nasa.jpl.aerie.merlin.protocol.types.ValueSchema; -import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanDatasetException; -import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; -import gov.nasa.jpl.aerie.merlin.server.exceptions.SimulationDatasetMismatchException; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintsCompilationError; -import gov.nasa.jpl.aerie.merlin.server.models.ProcedureLoader; -import gov.nasa.jpl.aerie.merlin.server.remotes.MissionModelAccessException; import gov.nasa.jpl.aerie.merlin.server.services.BulkConstraintEffectiveArgumentResponse; import gov.nasa.jpl.aerie.merlin.server.services.GetSimulationResultsAction; -import gov.nasa.jpl.aerie.merlin.server.services.LocalMissionModelService; import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService; import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService.BulkEffectiveArgumentResponse; import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService.BulkArgumentValidationResponse; -import gov.nasa.jpl.aerie.merlin.server.services.UnexpectedSubtypeError; import gov.nasa.jpl.aerie.types.ActivityDirectiveId; import org.apache.commons.lang3.tuple.Pair; import javax.json.Json; import javax.json.JsonObjectBuilder; import javax.json.JsonValue; -import javax.json.stream.JsonParsingException; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -122,125 +112,107 @@ public static JsonValue serializeBulkEffectiveArgumentResponseList(final List effectiveArguments)) { - return Json.createObjectBuilder() - .add("id", constraintId.id()) - .add("revision", constraintId.revision()) - .add("success", JsonValue.TRUE) - .add("arguments", - serializeMap( - ResponseSerializers::serializeArgument, - effectiveArguments)) - .build(); - } else if (response instanceof BulkConstraintEffectiveArgumentResponse.TypeFailure(ConstraintId constraintId)) { - return Json.createObjectBuilder() - .add("id", constraintId.id()) - .add("revision", constraintId.revision()) - .add("success", JsonValue.FALSE) - .add("errors", "Constraint is not procedural") - .build(); - } else if (response instanceof BulkConstraintEffectiveArgumentResponse.InstantiationFailure( - ConstraintId constraintId, - InstantiationException ex)) { - return Json.createObjectBuilder( - serializeInstantiationException(ex).asJsonObject()) - .add("success", JsonValue.FALSE) - .add("id", constraintId.id()) - .add("revision", constraintId.revision()) - .build(); - } else if (response instanceof BulkConstraintEffectiveArgumentResponse.NoConstraintFailure(ConstraintId constraintId)) { - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("id", constraintId.id()) - .add("revision", constraintId.revision()) - .add("errors", "There is no constraint with this id") - .build(); - } else if (response instanceof BulkConstraintEffectiveArgumentResponse.ProcedureLoadFailure( - ConstraintId constraintId, ProcedureLoader.ProcedureLoadException ex)) { - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("id", constraintId.id()) - .add("revision", constraintId.revision()) - .add("errors", "Error when loading the procedure jar") - .build(); - } - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("errors", String.format("Internal error: %s", response)) - .build(); + return switch (response) { + case BulkConstraintEffectiveArgumentResponse.Success s -> + Json.createObjectBuilder() + .add("id", s.constraintId().id()) + .add("revision", s.constraintId().revision()) + .add("success", JsonValue.TRUE) + .add("arguments", + serializeMap( + ResponseSerializers::serializeArgument, + s.effectiveArguments())) + .build(); + case BulkConstraintEffectiveArgumentResponse.TypeFailure tf -> + Json.createObjectBuilder() + .add("id", tf.constraintId().id()) + .add("revision", tf.constraintId().revision()) + .add("success", JsonValue.FALSE) + .add("errors", "Constraint is not procedural") + .build(); + case BulkConstraintEffectiveArgumentResponse.InstantiationFailure inf -> + Json.createObjectBuilder( + serializeInstantiationException(inf.ex()).asJsonObject()) + .add("success", JsonValue.FALSE) + .add("id", inf.constraintId().id()) + .add("revision", inf.constraintId().revision()) + .build(); + case BulkConstraintEffectiveArgumentResponse.NoConstraintFailure ncf -> + Json.createObjectBuilder() + .add("success", JsonValue.FALSE) + .add("id", ncf.constraintId().id()) + .add("revision", ncf.constraintId().revision()) + .add("errors", "There is no constraint with this id") + .build(); + case BulkConstraintEffectiveArgumentResponse.ProcedureLoadFailure plf -> + Json.createObjectBuilder() + .add("success", JsonValue.FALSE) + .add("id", plf.constraintId().id()) + .add("revision", plf.constraintId().revision()) + .add("errors", "Error when loading the procedure jar") + .build(); + }; } public static JsonValue serializeBulkEffectiveArgumentResponse(BulkEffectiveArgumentResponse response) { - // TODO use pattern matching in switch statement with JDK 21 - if (response instanceof BulkEffectiveArgumentResponse.Success s) { - return Json.createObjectBuilder() - .add("typeName", - s.activity().getTypeName()) - .add("success", JsonValue.TRUE) - .add("arguments", - serializeMap( - ResponseSerializers::serializeArgument, - s.activity().getArguments())) - .build(); - } else if (response instanceof BulkEffectiveArgumentResponse.TypeFailure f) { - return Json.createObjectBuilder() - .add("typeName", f.ex().activityTypeId) - .add("success", JsonValue.FALSE) - .add("errors", "No such activity type") - .build(); - } else if (response instanceof BulkEffectiveArgumentResponse.InstantiationFailure f) { - return Json.createObjectBuilder(serializeInstantiationException(f.ex()).asJsonObject()) - .add("typeName", f.ex().containerName) - .build(); - } - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("errors", String.format("Internal error: %s", response)) - .build(); + return switch (response) { + case BulkEffectiveArgumentResponse.Success s -> + Json.createObjectBuilder() + .add("typeName", s.activity().getTypeName()) + .add("success", JsonValue.TRUE) + .add("arguments", + serializeMap( + ResponseSerializers::serializeArgument, + s.activity().getArguments())) + .build(); + case BulkEffectiveArgumentResponse.TypeFailure tf -> + Json.createObjectBuilder() + .add("typeName", tf.ex().activityTypeId) + .add("success", JsonValue.FALSE) + .add("errors", "No such activity type") + .build(); + case BulkEffectiveArgumentResponse.InstantiationFailure inf -> + Json.createObjectBuilder(serializeInstantiationException(inf.ex()).asJsonObject()) + .add("typeName", inf.ex().containerName) + .build(); + }; } public static JsonValue serializeBulkArgumentValidationResponse(BulkArgumentValidationResponse response) { - // TODO use pattern matching in switch statement with JDK 21 - if (response instanceof BulkArgumentValidationResponse.Success) { - return Json.createObjectBuilder() - .add("success", JsonValue.TRUE) - .build(); - } else if (response instanceof BulkArgumentValidationResponse.Validation v) { - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("type", "VALIDATION_NOTICES") - .add("errors", Json.createObjectBuilder() - .add("validationNotices", serializeIterable(ResponseSerializers::serializeValidationNotice, v.notices())) - .build()) - .build(); - } else if (response instanceof BulkArgumentValidationResponse.NoSuchActivityError e) { - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("type", "NO_SUCH_ACTIVITY_TYPE") - .add("errors", Json.createObjectBuilder() - .add("noSuchActivityError", serializeNoSuchActivityTypeException(e.ex())) - .build()) - .build(); - } else if (response instanceof BulkArgumentValidationResponse.InstantiationError f) { - return Json.createObjectBuilder(serializeInstantiationException(f.ex()).asJsonObject()) - .add("type", "INSTANTIATION_ERRORS") - .build(); - } else if (response instanceof BulkArgumentValidationResponse.NoSuchMissionModelError m) { - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("type", "NO_SUCH_MISSION_MODEL") - .add("errors", Json.createObjectBuilder() - .add("noSuchMissionModelError", serializeNoSuchMissionModelException(m.ex())) - .build()) - .build(); - } - - // This should never happen, but we don't have exhaustive pattern matching - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("errors", String.format("Internal error: %s", response)) - .build(); + return switch (response){ + case BulkArgumentValidationResponse.Success s -> + Json.createObjectBuilder() + .add("success", JsonValue.TRUE) + .build(); + case BulkArgumentValidationResponse.Validation v -> + Json.createObjectBuilder() + .add("success", JsonValue.FALSE) + .add("type", "VALIDATION_NOTICES") + .add("errors", Json.createObjectBuilder() + .add("validationNotices", serializeIterable(ResponseSerializers::serializeValidationNotice, v.notices())) + .build()) + .build(); + case BulkArgumentValidationResponse.NoSuchActivityError e -> + Json.createObjectBuilder() + .add("success", JsonValue.FALSE) + .add("type", "NO_SUCH_ACTIVITY_TYPE") + .add("errors", Json.createObjectBuilder() + .add("noSuchActivityError", new MerlinFormattedError(e.ex()).toJson()) + .build()) + .build(); + case BulkArgumentValidationResponse.InstantiationError f -> + Json.createObjectBuilder(serializeInstantiationException(f.ex()).asJsonObject()) + .add("type", "INSTANTIATION_ERRORS") + .build(); + case BulkArgumentValidationResponse.NoSuchMissionModelError m -> + Json.createObjectBuilder() + .add("success", JsonValue.FALSE) + .add("type", "NO_SUCH_MISSION_MODEL") + .add("errors", Json.createObjectBuilder() + .add("noSuchMissionModelError", new MerlinFormattedError(m.ex()).toJson()) + .build()) + .build(); + }; } public static JsonValue serializeCreatedDatasetId(final long datasetId) { @@ -250,15 +222,13 @@ public static JsonValue serializeCreatedDatasetId(final long datasetId) { } private static JsonValue serializeUnconstructableActivityFailure(final MissionModelService.ActivityInstantiationFailure reason) { - // TODO use pattern-matching switch expression here when available with LTS final var builder = Json.createObjectBuilder(); - if (reason instanceof final MissionModelService.ActivityInstantiationFailure.InstantiationFailure r) { - return builder.add("reason", serializeInstantiationException(r.ex())).build(); - } - else if (reason instanceof final MissionModelService.ActivityInstantiationFailure.NoSuchActivityType r) { - return builder.add("reason", serializeNoSuchActivityTypeException(r.ex())).build(); - } - throw new UnexpectedSubtypeError(MissionModelService.ActivityInstantiationFailure.class, reason); + return switch (reason) { + case MissionModelService.ActivityInstantiationFailure.InstantiationFailure r -> + builder.add("reason", serializeInstantiationException(r.ex())).build(); + case MissionModelService.ActivityInstantiationFailure.NoSuchActivityType r -> + builder.add("reason", new MerlinFormattedError(r.ex()).toJson()).build(); + }; } public static JsonValue serializeUnconstructableActivityFailures(final Map failures) { @@ -425,29 +395,13 @@ public static JsonValue serializeInstantiationException(final InstantiationExcep .build(); } - private static JsonValue serializeUnconstructableArgument( - final InstantiationException.UnconstructableArgument argument) - { + private static JsonValue serializeUnconstructableArgument(final InstantiationException.UnconstructableArgument argument) { return Json.createObjectBuilder() .add("name", argument.parameterName()) .add("failure", argument.failure()) .build(); } - public static JsonValue serializeJsonParsingException(final JsonParsingException ex) { - // TODO: Improve diagnostic information - return Json.createObjectBuilder() - .add("message", "invalid json") - .build(); - } - - public static JsonValue serializeInvalidJsonException(final InvalidJsonException ex) { - return Json.createObjectBuilder() - .add("kind", "invalid-entity") - .add("message", "invalid json") - .build(); - } - public static JsonValue serializeConstraintErrors(final List errors) { final var failureArrayBuilder = Json.createArrayBuilder(); for (final var e : errors) { @@ -470,103 +424,4 @@ public static JsonValue serializeConstraintErrors(final List() { - @Override - public JsonValue onString(final String s) { - return Json.createValue(s); - } - - @Override - public JsonValue onInteger(final Integer i) { - return Json.createValue(i); - } - }); - } - - public static JsonValue serializeNoSuchPlanException(final NoSuchPlanException ex) { - return Json.createObjectBuilder() - .add("message", "no such plan") - .add("plan_id", ex.id.id()) - .build(); - } - - public static JsonValue serializeNoSuchPlanDatasetException(final NoSuchPlanDatasetException ex) { - return Json.createObjectBuilder() - .add("message", "no such plan dataset") - .add("plan_id", ex.id.id()) - .build(); - } - - public static JsonValue serializeNoSuchMissionModelException(final MissionModelService.NoSuchMissionModelException ex) { - return Json.createObjectBuilder() - .add("message", "no such mission model") - .add("mission_model_id", ex.missionModelId.id()) - .build(); - } - - public static JsonValue serializeNoSuchActivityTypeException(final MissionModelService.NoSuchActivityTypeException ex) { - return Json.createObjectBuilder() - .add("message", "no such activity type") - .add("activity_type", ex.activityTypeId) - .build(); - } - - public static JsonValue serializeInputMismatchException(final InputMismatchException ex) { - return Json.createObjectBuilder() - .add("message", "input mismatch exception") - .add("extensions", serializeCauseAsExtension(ex.getMessage())) - .build(); - } - - public static JsonValue serializeSimulationDatasetMismatchException(final SimulationDatasetMismatchException ex){ - return Json.createObjectBuilder() - .add("message", "simulation dataset mismatch exception") - .add("extensions", serializeCauseAsExtension(ex.getMessage())) - .build(); - } - - /** - * Any exception that gets sent through a Hasura action needs to be wrapped in an "extensions" object to be - * preserved in the response. - * Reference - * - * @param message - * @return An object builder that sets "cause" to the message. - */ - public static JsonObjectBuilder serializeCauseAsExtension(String message) { - return Json.createObjectBuilder().add("cause", message); - } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/MissionModelAccessException.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/MissionModelAccessException.java deleted file mode 100644 index f73a480d35..0000000000 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/MissionModelAccessException.java +++ /dev/null @@ -1,16 +0,0 @@ -package gov.nasa.jpl.aerie.merlin.server.remotes; - -import java.nio.file.Path; - -public class MissionModelAccessException extends RuntimeException { - private final Path path; - - public MissionModelAccessException(final Path path, final Throwable cause) { - super(cause); - this.path = path; - } - - public Path getPath() { - return this.path; - } -} diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/MissionModelRepository.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/MissionModelRepository.java index e64e19e078..69622bc8ad 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/MissionModelRepository.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/MissionModelRepository.java @@ -5,6 +5,7 @@ import gov.nasa.jpl.aerie.merlin.server.models.ActivityDirectiveForValidation; import gov.nasa.jpl.aerie.merlin.server.models.ActivityType; import gov.nasa.jpl.aerie.merlin.server.models.MissionModelJar; +import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService.NoSuchMissionModelException; import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService.BulkArgumentValidationResponse; import gov.nasa.jpl.aerie.types.MissionModelId; import org.apache.commons.lang3.tuple.Pair; @@ -24,6 +25,4 @@ public interface MissionModelRepository { void updateResourceTypes(MissionModelId missionModelId, final Map> resourceTypes) throws NoSuchMissionModelException; Map> getUnvalidatedDirectives(); void updateDirectiveValidations(List> updates); - - final class NoSuchMissionModelException extends Exception {} } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/ExternalEventRepository.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/ExternalEventRepository.java index ac62e96ac4..78253fe30f 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/ExternalEventRepository.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/ExternalEventRepository.java @@ -2,7 +2,7 @@ import gov.nasa.ammos.aerie.procedural.timeline.payloads.ExternalEvent; import gov.nasa.ammos.aerie.procedural.timeline.payloads.ExternalSource; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidEntityException; +import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; import java.sql.Connection; @@ -16,7 +16,7 @@ static Map> getExternalEvents( final Connection connection, final PlanId planId, final Instant horizonStart - ) throws SQLException, InvalidEntityException + ) throws SQLException, InvalidJsonEntityException { try (final var getPlanExternalEventsAction = new GetPlanExternalEventsAction(connection)) { Map sourceMap = getExternalSources(connection, planId); @@ -27,7 +27,7 @@ static Map> getExternalEvents( static Map getExternalSources( final Connection connection, final PlanId planId - ) throws SQLException, InvalidEntityException { + ) throws SQLException, InvalidJsonEntityException { try (final var getExternalSourcesMapAction = new GetExternalSourcesMapAction(connection)) { return getExternalSourcesMapAction.get(planId); } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetConstraintAction.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetConstraintAction.java index a5128a3d5e..6a78369b81 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetConstraintAction.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetConstraintAction.java @@ -1,9 +1,7 @@ package gov.nasa.jpl.aerie.merlin.server.remotes.postgres; import gov.nasa.jpl.aerie.merlin.driver.json.SerializedValueJsonParser; -import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidEntityException; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonException; +import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintId; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintRecord; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintType; @@ -79,7 +77,7 @@ public Map get(List ids) throws SQ parseJson(results.getString("arguments"), new SerializedValueJsonParser()).asMap().orElse(Map.of()) ); constraints.put(id, c); - } catch (InvalidJsonException | InvalidEntityException e) { + } catch (InvalidJsonEntityException e) { throw new SQLException(e); } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetExternalSourcesMapAction.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetExternalSourcesMapAction.java index f19e74ac59..79d27202ff 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetExternalSourcesMapAction.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetExternalSourcesMapAction.java @@ -1,7 +1,7 @@ package gov.nasa.jpl.aerie.merlin.server.remotes.postgres; import gov.nasa.ammos.aerie.procedural.timeline.payloads.ExternalSource; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidEntityException; +import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; import org.intellij.lang.annotations.Language; @@ -33,7 +33,7 @@ public GetExternalSourcesMapAction(final Connection connection) throws SQLExcept } public Map get(final PlanId planId) - throws SQLException, InvalidEntityException + throws SQLException, InvalidJsonEntityException { final var result = new HashMap(); this.statement.setLong(1, planId.id()); @@ -44,7 +44,7 @@ public Map get(final PlanId planId) final String key = resultSet.getString("key"); // get source attributes final var attributes = getJsonColumn(resultSet, "attributes", eventAttributesP) - .getSuccessOrThrow(reason -> new InvalidEntityException(List.of(reason))); + .getSuccessOrThrow(reason -> new InvalidJsonEntityException(List.of(reason))); // get derivation group final String derivationGroup = resultSet.getString("derivation_group_name"); diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetPlanConstraintsAction.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetPlanConstraintsAction.java index e1b9788823..12ddb292e8 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetPlanConstraintsAction.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetPlanConstraintsAction.java @@ -1,12 +1,12 @@ package gov.nasa.jpl.aerie.merlin.server.remotes.postgres; import gov.nasa.jpl.aerie.merlin.driver.json.SerializedValueJsonParser; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidEntityException; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonException; +import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintRecord; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintType; import org.intellij.lang.annotations.Language; +import javax.json.stream.JsonParsingException; import java.nio.file.Path; import java.sql.Connection; import java.sql.PreparedStatement; @@ -107,7 +107,7 @@ public Optional> get(final long planId) throws SQLExcepti } while (results.next()); return Optional.of(constraints); - } catch (InvalidJsonException | InvalidEntityException e) { + } catch (JsonParsingException | InvalidJsonEntityException e) { throw new SQLException(e); } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetPlanExternalEventsAction.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetPlanExternalEventsAction.java index c58201ce7f..39f17cb6c3 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetPlanExternalEventsAction.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/GetPlanExternalEventsAction.java @@ -4,7 +4,7 @@ import gov.nasa.ammos.aerie.procedural.timeline.payloads.ExternalEvent; import gov.nasa.ammos.aerie.procedural.timeline.payloads.ExternalSource; import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidEntityException; +import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; import org.intellij.lang.annotations.Language; @@ -47,7 +47,7 @@ public GetPlanExternalEventsAction(final Connection connection) throws SQLExcept } public Map> get(final PlanId planId, final Instant horizonStart, Map sources) - throws SQLException, InvalidEntityException + throws SQLException, InvalidJsonEntityException { final var result = new HashMap>(); this.statement.setLong(1, planId.id()); @@ -64,7 +64,7 @@ public Map> get(final PlanId planId, final Instant h // get event attributes final var eventAttributes = getJsonColumn(resultSet, "attributes", eventAttributesP) - .getSuccessOrThrow(reason -> new InvalidEntityException(List.of(reason))); + .getSuccessOrThrow(reason -> new InvalidJsonEntityException(List.of(reason))); // get derivation group final String derivationGroup = resultSet.getString("derivation_group_name"); // get event key diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/PostgresMissionModelRepository.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/PostgresMissionModelRepository.java index f9cf090955..4ada623ac6 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/PostgresMissionModelRepository.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/PostgresMissionModelRepository.java @@ -7,6 +7,7 @@ import gov.nasa.jpl.aerie.merlin.server.models.MissionModelJar; import gov.nasa.jpl.aerie.merlin.server.remotes.MissionModelRepository; import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService; +import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService.NoSuchMissionModelException; import gov.nasa.jpl.aerie.types.MissionModelId; import org.apache.commons.lang3.tuple.Pair; @@ -48,7 +49,7 @@ public MissionModelJar getMissionModel(final MissionModelId missionModelId) thro return getMissionModelAction .get(missionModelId.id()) .map(PostgresMissionModelRepository::missionModelRecordToMissionModelJar) - .orElseThrow(NoSuchMissionModelException::new); + .orElseThrow(() -> new NoSuchMissionModelException(missionModelId)); } } catch (final SQLException ex) { throw new DatabaseException("Failed to retrieve mission model with id `%s`".formatted(missionModelId), ex); @@ -56,7 +57,7 @@ public MissionModelJar getMissionModel(final MissionModelId missionModelId) thro } @Override - public Map getActivityTypes(final MissionModelId missionModelId) throws NoSuchMissionModelException { + public Map getActivityTypes(final MissionModelId missionModelId) { try (final var connection = this.dataSource.getConnection()) { try (final var getActivityTypesAction = new GetActivityTypesAction(connection)) { final var id = missionModelId.id(); @@ -74,7 +75,7 @@ public Map getActivityTypes(final MissionModelId missionMo @Override public void updateModelParameters(final MissionModelId missionModelId, final List modelParameters) - throws NoSuchMissionModelException { + { try (final var connection = this.dataSource.getConnection()) { try (final var createModelParametersAction = new CreateModelParametersAction(connection)) { final var id = missionModelId.id(); @@ -88,7 +89,7 @@ public void updateModelParameters(final MissionModelId missionModelId, final Lis @Override public void updateActivityTypes(final MissionModelId missionModelId, final Map activityTypes, final List subsystems) - throws NoSuchMissionModelException { + { try (final var connection = this.dataSource.getConnection()) { final Map mapSubsystemsToIds; try (final var insertSubsystemsAction = new InsertSubsystemsAction(connection)) { @@ -110,7 +111,7 @@ public void updateActivityTypes(final MissionModelId missionModelId, final Map> resources) - throws NoSuchMissionModelException { + { final var resourceTypes = resources.entrySet() .stream() .collect(Collectors.toMap( diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/PostgresPlanRepository.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/PostgresPlanRepository.java index 74444dc996..b17d9faf23 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/PostgresPlanRepository.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/postgres/PostgresPlanRepository.java @@ -6,7 +6,7 @@ import gov.nasa.jpl.aerie.merlin.protocol.types.ValueSchema; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanDatasetException; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidEntityException; +import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintRecord; import gov.nasa.jpl.aerie.merlin.server.models.DatasetId; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; @@ -283,7 +283,7 @@ public Map> getExternalEvents( } catch (final SQLException ex) { throw new DatabaseException( "Failed to get external events for plan with id `%s`".formatted(planId), ex); - } catch (final InvalidEntityException in) { + } catch (final InvalidJsonEntityException in) { throw new RuntimeException( ("Failed to get external events for plan with id `%s; " + "failed to parse jsonb for external event/source attributes`").formatted(planId), in); diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/BulkConstraintEffectiveArgumentResponse.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/BulkConstraintEffectiveArgumentResponse.java index 498e6b270b..af5acdb4d2 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/BulkConstraintEffectiveArgumentResponse.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/BulkConstraintEffectiveArgumentResponse.java @@ -8,6 +8,7 @@ import java.util.Map; public sealed interface BulkConstraintEffectiveArgumentResponse { + ConstraintId constraintId(); record Success(ConstraintId constraintId, Map effectiveArguments) implements BulkConstraintEffectiveArgumentResponse { } record NoConstraintFailure(ConstraintId constraintId) implements BulkConstraintEffectiveArgumentResponse { } record InstantiationFailure(ConstraintId constraintId, InstantiationException ex) implements BulkConstraintEffectiveArgumentResponse { } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintAction.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintAction.java index ce1b42c137..23c6e58fff 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintAction.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintAction.java @@ -5,7 +5,9 @@ import gov.nasa.jpl.aerie.constraints.model.DiscreteProfile; import gov.nasa.jpl.aerie.constraints.model.*; import gov.nasa.jpl.aerie.constraints.tree.Expression; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; import gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException; +import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchConstraintException; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.merlin.server.exceptions.SimulationDatasetMismatchException; import gov.nasa.jpl.aerie.merlin.server.http.Fallible; @@ -39,7 +41,9 @@ public ConstraintAction( * @param constraintId The id of the constraint's metadata * @param revision The definition to be updated */ - public void refreshConstraintProcedureParameterTypes(long constraintId, long revision) { + public void refreshConstraintProcedureParameterTypes(long constraintId, long revision) + throws NoSuchConstraintException, ProcedureLoader.ProcedureLoadException + { constraintService.refreshConstraintProcedureParameterTypes(constraintId, revision); } @@ -251,7 +255,7 @@ private Fallible, ConstraintsDSLCompilationServ Optional.of(simDatasetId), ((ConstraintType.EDSL) constraint.type()).definition() ); - } catch (MissionModelService.NoSuchMissionModelException | NoSuchPlanException ex) { + } catch (MissionModelService.NoSuchMissionModelException | NoSuchPlanException | MissionModelLoader.MissionModelLoadException ex) { return Fallible.failure( new ConstraintsDSLCompilationService.ConstraintsDSLCompilationResult.Error( List.of( diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintService.java index 52ec4691f5..bce9f41f79 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintService.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintService.java @@ -1,10 +1,12 @@ package gov.nasa.jpl.aerie.merlin.server.services; import gov.nasa.jpl.aerie.constraints.model.ConstraintResult; +import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchConstraintException; import gov.nasa.jpl.aerie.merlin.server.http.Fallible; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintId; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintRecord; import gov.nasa.jpl.aerie.merlin.server.models.DBConstraintResult; +import gov.nasa.jpl.aerie.merlin.server.models.ProcedureLoader; import gov.nasa.jpl.aerie.merlin.server.models.SimulationDatasetId; import java.util.List; @@ -14,6 +16,7 @@ public interface ConstraintService { int createConstraintRuns(final ConstraintRequestConfiguration requestConfiguration, final Map>> constraintToResultsMap); Map getValidConstraintRuns(List constraints, SimulationDatasetId simulationDatasetId); - void refreshConstraintProcedureParameterTypes(long constraintId, long revision); + void refreshConstraintProcedureParameterTypes(long constraintId, long revision) throws NoSuchConstraintException, + ProcedureLoader.ProcedureLoadException; Map getConstraintsById(List constraintIds); } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationService.java index bd7fb48baf..1ff48bc219 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationService.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationService.java @@ -3,9 +3,9 @@ import gov.nasa.jpl.aerie.constraints.model.EDSLConstraintResult; import gov.nasa.jpl.aerie.constraints.tree.Expression; import gov.nasa.jpl.aerie.json.JsonParser; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidEntityException; -import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonException; +import gov.nasa.jpl.aerie.merlin.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintsCompilationError; import gov.nasa.jpl.aerie.constraints.json.ConstraintParsers; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; @@ -60,7 +60,8 @@ synchronized public ConstraintsDSLCompilationResult compileConstraintsDSL( final Optional planId, final Optional simulationDatasetId, final String constraintTypescript - ) throws MissionModelService.NoSuchMissionModelException, NoSuchPlanException + ) throws MissionModelService.NoSuchMissionModelException, NoSuchPlanException, + MissionModelLoader.MissionModelLoadException { final var missionModelGeneratedCode = this.typescriptCodeGenerationService.generateTypescriptTypes(missionModelId, planId, simulationDatasetId); final JsonObject messageJson = Json.createObjectBuilder() @@ -88,7 +89,7 @@ synchronized public ConstraintsDSLCompilationResult compileConstraintsDSL( final var output = outputReader.readLine(); try { yield new ConstraintsDSLCompilationResult.Error(parseJson(output, ConstraintsCompilationError.constraintsErrorJsonP)); - } catch (InvalidJsonException | InvalidEntityException e) { + } catch (JsonParsingException | InvalidJsonEntityException e) { throw new Error("Could not parse error JSON returned from typescript: " + output, e); } } @@ -96,7 +97,7 @@ synchronized public ConstraintsDSLCompilationResult compileConstraintsDSL( final var output = outputReader.readLine(); try { yield new ConstraintsDSLCompilationResult.Success(parseJson(output, ConstraintParsers.constraintP)); - } catch (InvalidJsonException | InvalidEntityException e) { + } catch (JsonParsingException | InvalidJsonEntityException e) { throw new Error("Could not parse success JSON returned from typescript: " + output, e); } } @@ -108,14 +109,12 @@ synchronized public ConstraintsDSLCompilationResult compileConstraintsDSL( } private static T parseJson(final String jsonStr, final JsonParser parser) - throws InvalidJsonException, InvalidEntityException + throws JsonParsingException, InvalidJsonEntityException { try (final var reader = Json.createReader(new StringReader(jsonStr))) { final var requestJson = reader.readValue(); final var result = parser.parse(requestJson); - return result.getSuccessOrThrow(reason -> new InvalidEntityException(List.of(reason))); - } catch (JsonParsingException e) { - throw new InvalidJsonException(e); + return result.getSuccessOrThrow(reason -> new InvalidJsonEntityException(List.of(reason))); } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/GenerateConstraintsLibAction.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/GenerateConstraintsLibAction.java index 0eb357875d..fcce624b60 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/GenerateConstraintsLibAction.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/GenerateConstraintsLibAction.java @@ -1,5 +1,6 @@ package gov.nasa.jpl.aerie.merlin.server.services; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; import gov.nasa.jpl.aerie.types.MissionModelId; @@ -43,7 +44,7 @@ public Response run(final MissionModelId missionModelId, final Optional "constraints-ast.ts", constraintsAst, "TemporalPolyfillTypes.ts", temporalPolyfillTypes )); - } catch (MissionModelService.NoSuchMissionModelException | NoSuchPlanException | IOException e) { + } catch (MissionModelService.NoSuchMissionModelException | NoSuchPlanException | IOException | MissionModelLoader.MissionModelLoadException e) { return new Response.Failure(e.getMessage()); } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalConstraintService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalConstraintService.java index d6d3c2ae09..ca466fb7cb 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalConstraintService.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalConstraintService.java @@ -39,22 +39,16 @@ public Map getValidConstraintRuns(List { /* do nothing */ } case ConstraintType.JAR jar -> { final ProcedureMapper mapper; - try { mapper = ProcedureLoader.loadProcedure(Path.of("/usr/src/app/merlin_file_store", jar.path().toString())); - } catch (ProcedureLoader.ProcedureLoadException e) { - throw new RuntimeException(e); - } final var schema = mapper.valueSchema(); constraintRepository.updateConstraintParameterSchema(constraintId, revision, schema); } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java index 5db6f4e780..77a615578c 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java @@ -3,6 +3,7 @@ import gov.nasa.jpl.aerie.merlin.driver.DirectiveTypeRegistry; import gov.nasa.jpl.aerie.merlin.driver.MissionModel; import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader.MissionModelLoadException; import gov.nasa.jpl.aerie.types.ActivityDirectiveId; import gov.nasa.jpl.aerie.types.MissionModelId; import gov.nasa.jpl.aerie.types.Plan; @@ -69,7 +70,7 @@ public Map getMissionModels() { public MissionModelJar getMissionModelById(final MissionModelId missionModelId) throws NoSuchMissionModelException { try { return this.missionModelRepository.getMissionModel(missionModelId); - } catch (MissionModelRepository.NoSuchMissionModelException ex) { + } catch (NoSuchMissionModelException ex) { throw new NoSuchMissionModelException(missionModelId, ex); } } @@ -101,11 +102,7 @@ public Map getResourceSchemas(final MissionModelId missionM public Map getActivityTypes(final MissionModelId missionModelId) throws NoSuchMissionModelException { - try { - return missionModelRepository.getActivityTypes(missionModelId); - } catch (MissionModelRepository.NoSuchMissionModelException e) { - throw new NoSuchMissionModelException(missionModelId, e); - } + return missionModelRepository.getActivityTypes(missionModelId); } /** @@ -290,7 +287,7 @@ public SimulationResults runSimulation( final Consumer simulationExtentConsumer, final Supplier canceledListener, final SimulationResourceManager resourceManager) - throws NoSuchMissionModelException + throws NoSuchMissionModelException, MissionModelLoadException { final var config = plan.simulationConfiguration(); if (config.isEmpty()) { @@ -316,64 +313,47 @@ public SimulationResults runSimulation( @Override public void refreshModelParameters(final MissionModelId missionModelId) - throws NoSuchMissionModelException + throws NoSuchMissionModelException, MissionModelLoadException { - try { - this.missionModelRepository.updateModelParameters(missionModelId, getModelParameters(missionModelId)); - } catch (final MissionModelRepository.NoSuchMissionModelException ex) { - throw new NoSuchMissionModelException(missionModelId, ex); - } + this.missionModelRepository.updateModelParameters(missionModelId, getModelParameters(missionModelId)); } @Override public void refreshActivityTypes(final MissionModelId missionModelId) - throws NoSuchMissionModelException + throws NoSuchMissionModelException, MissionModelLoadException { - try { - final var modelType = this.loadMissionModelType(missionModelId); - final var registry = DirectiveTypeRegistry.extract(modelType); - final var activityTypes = new HashMap(); - registry.directiveTypes().forEach((name, directiveType) -> { - final var inputType = directiveType.getInputType(); - final var outputType = directiveType.getOutputType(); - activityTypes.put(name, new ActivityType( - name, - inputType.getParameters(), - inputType.getRequiredParameters(), - outputType.getSchema(), - directiveType.getSubsystem(), - directiveType.getDescription() - )); - }); - final var subsystems = modelType.getSubsystems(); - this.missionModelRepository.updateActivityTypes(missionModelId, activityTypes, subsystems); - } catch (final MissionModelRepository.NoSuchMissionModelException ex) { - throw new NoSuchMissionModelException(missionModelId, ex); - } + final var modelType = this.loadMissionModelType(missionModelId); + final var registry = DirectiveTypeRegistry.extract(modelType); + final var activityTypes = new HashMap(); + registry.directiveTypes().forEach((name, directiveType) -> { + final var inputType = directiveType.getInputType(); + final var outputType = directiveType.getOutputType(); + activityTypes.put( + name, new ActivityType( + name, + inputType.getParameters(), + inputType.getRequiredParameters(), + outputType.getSchema(), + directiveType.getSubsystem(), + directiveType.getDescription() + )); + }); + final var subsystems = modelType.getSubsystems(); + this.missionModelRepository.updateActivityTypes(missionModelId, activityTypes, subsystems); } @Override public void refreshResourceTypes(final MissionModelId missionModelId) throws NoSuchMissionModelException, MissionModelLoadException { - try { - final var model = this.loadAndInstantiateMissionModel(missionModelId); - this.missionModelRepository.updateResourceTypes(missionModelId, model.getResources()); - } catch (MissionModelRepository.NoSuchMissionModelException e) { - throw new NoSuchMissionModelException(missionModelId); - } + final var model = this.loadAndInstantiateMissionModel(missionModelId); + this.missionModelRepository.updateResourceTypes(missionModelId, model.getResources()); } private ModelType loadMissionModelType(final MissionModelId missionModelId) throws NoSuchMissionModelException, MissionModelLoadException { - try { - final var missionModelJar = this.missionModelRepository.getMissionModel(missionModelId); - return MissionModelLoader.loadModelType(missionModelDataPath.resolve(missionModelJar.path), missionModelJar.name, missionModelJar.version); - } catch (final MissionModelRepository.NoSuchMissionModelException ex) { - throw new NoSuchMissionModelException(missionModelId, ex); - } catch (final MissionModelLoader.MissionModelLoadException ex) { - throw new MissionModelLoadException(ex); - } + final var missionModelJar = this.missionModelRepository.getMissionModel(missionModelId); + return MissionModelLoader.loadModelType(missionModelDataPath.resolve(missionModelJar.path), missionModelJar.name, missionModelJar.version); } /** @@ -407,22 +387,12 @@ private MissionModel loadAndInstantiateMissionModel( final SerializedValue configuration) throws NoSuchMissionModelException, MissionModelLoadException { - try { - final var missionModelJar = this.missionModelRepository.getMissionModel(missionModelId); - return MissionModelLoader.loadMissionModel( - planStart, - configuration, - missionModelDataPath.resolve(missionModelJar.path), - missionModelJar.name, - missionModelJar.version); - } catch (final MissionModelRepository.NoSuchMissionModelException ex) { - throw new NoSuchMissionModelException(missionModelId, ex); - } catch (final MissionModelLoader.MissionModelLoadException ex) { - throw new MissionModelLoadException(ex); - } - } - - public static class MissionModelLoadException extends RuntimeException { - public MissionModelLoadException(final Throwable cause) { super(cause); } + final var missionModelJar = this.missionModelRepository.getMissionModel(missionModelId); + return MissionModelLoader.loadMissionModel( + planStart, + configuration, + missionModelDataPath.resolve(missionModelJar.path), + missionModelJar.name, + missionModelJar.version); } } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/MissionModelService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/MissionModelService.java index cb3bda30e5..71ab369f59 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/MissionModelService.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/MissionModelService.java @@ -1,6 +1,6 @@ package gov.nasa.jpl.aerie.merlin.server.services; -import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader.MissionModelLoadException; import gov.nasa.jpl.aerie.types.ActivityDirectiveId; import gov.nasa.jpl.aerie.types.MissionModelId; import gov.nasa.jpl.aerie.types.Plan; @@ -28,7 +28,7 @@ MissionModelJar getMissionModelById(MissionModelId missionModelId) throws NoSuchMissionModelException; Map getResourceSchemas(MissionModelId missionModelId) - throws NoSuchMissionModelException; + throws NoSuchMissionModelException, MissionModelLoadException; /** * getActivityTypes uses the cached result of refreshActivityTypes. For this reason, refreshActivityTypes @@ -38,42 +38,41 @@ Map getActivityTypes(MissionModelId missionModelId) throws NoSuchMissionModelException; // TODO: Provide a finer-scoped validation return type. Mere strings make all validations equally severe. List validateActivityArguments(MissionModelId missionModelId, SerializedActivity activity) - throws NoSuchMissionModelException, InstantiationException; + throws NoSuchMissionModelException, MissionModelLoadException, InstantiationException; Map validateActivityInstantiations( MissionModelId missionModelId, Map activities - ) throws NoSuchMissionModelException, LocalMissionModelService.MissionModelLoadException; + ) throws NoSuchMissionModelException, MissionModelLoadException; List getActivityEffectiveArgumentsBulk( MissionModelId missionModelId, List serializedActivities) - throws NoSuchMissionModelException; + throws NoSuchMissionModelException, MissionModelLoadException; List validateModelArguments(MissionModelId missionModelId, Map arguments) - throws NoSuchMissionModelException, - LocalMissionModelService.MissionModelLoadException, - InstantiationException; + throws NoSuchMissionModelException, MissionModelLoadException, InstantiationException; List getModelParameters(MissionModelId missionModelId) - throws NoSuchMissionModelException, MissionModelLoader.MissionModelLoadException; + throws NoSuchMissionModelException, MissionModelLoadException; Map getModelEffectiveArguments(MissionModelId missionModelId, Map arguments) - throws NoSuchMissionModelException, - LocalMissionModelService.MissionModelLoadException, - InstantiationException; + throws NoSuchMissionModelException, MissionModelLoadException, InstantiationException; SimulationResults runSimulation( final Plan plan, final Consumer writer, final Supplier canceledListener, final SimulationResourceManager resourceManager - ) throws NoSuchMissionModelException, MissionModelService.NoSuchActivityTypeException; + ) throws NoSuchMissionModelException, MissionModelService.NoSuchActivityTypeException, MissionModelLoadException; - void refreshModelParameters(MissionModelId missionModelId) throws NoSuchMissionModelException; - void refreshActivityTypes(MissionModelId missionModelId) throws NoSuchMissionModelException; - void refreshResourceTypes(MissionModelId missionModelId) throws NoSuchMissionModelException; + void refreshModelParameters(MissionModelId missionModelId) + throws NoSuchMissionModelException, MissionModelLoadException; + void refreshActivityTypes(MissionModelId missionModelId) throws NoSuchMissionModelException, + MissionModelLoadException; + void refreshResourceTypes(MissionModelId missionModelId) throws NoSuchMissionModelException, + MissionModelLoadException; sealed interface ActivityInstantiationFailure { record NoSuchActivityType(NoSuchActivityTypeException ex) implements ActivityInstantiationFailure { } @@ -95,7 +94,7 @@ final class NoSuchActivityTypeException extends Exception { public final String activityTypeId; public NoSuchActivityTypeException(final String activityTypeId, final Throwable cause) { - super(cause); + super("No such activity type " + activityTypeId, cause); this.activityTypeId = activityTypeId; } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/SimulationAgent.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/SimulationAgent.java index 96556b223b..ba76d073c2 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/SimulationAgent.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/SimulationAgent.java @@ -1,10 +1,12 @@ package gov.nasa.jpl.aerie.merlin.server.services; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; import gov.nasa.jpl.aerie.merlin.driver.SimulationException; import gov.nasa.jpl.aerie.merlin.driver.SimulationResults; import gov.nasa.jpl.aerie.merlin.driver.resources.SimulationResourceManager; import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; import gov.nasa.jpl.aerie.merlin.server.ResultsProtocol; +import gov.nasa.jpl.aerie.merlin.server.exceptions.MerlinFormattedError; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.merlin.server.http.ResponseSerializers; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; @@ -41,10 +43,11 @@ public void simulate( return; } } catch (final NoSuchPlanException ex) { + final var formattedException = new MerlinFormattedError(ex); writer.failWith(b -> b - .type("NO_SUCH_PLAN") - .message(ex.toString()) - .data(ResponseSerializers.serializeNoSuchPlanException(ex)) + .type(formattedException.getType()) + .message(formattedException.getMessage()) + .data(formattedException.toJson()) .trace(ex)); return; } @@ -91,17 +94,27 @@ public void simulate( .trace(ex.cause)); return; } catch (final MissionModelService.NoSuchMissionModelException ex) { + final var formattedError = new MerlinFormattedError(ex); writer.failWith(b -> b - .type("NO_SUCH_MISSION_MODEL") - .message(ex.toString()) - .data(ResponseSerializers.serializeNoSuchMissionModelException(ex)) + .type(formattedError.getType()) + .message(formattedError.getMessage()) + .data(formattedError.toJson()) .trace(ex)); return; } catch (final MissionModelService.NoSuchActivityTypeException ex) { + final var formattedError = new MerlinFormattedError(ex, "Activity of type `%s` could not be instantiated".formatted(ex.activityTypeId)); writer.failWith(b -> b - .type("NO_SUCH_ACTIVITY_TYPE") - .message("Activity of type `%s` could not be instantiated".formatted(ex.activityTypeId)) - .data(ResponseSerializers.serializeNoSuchActivityTypeException(ex)) + .type(formattedError.getType()) + .message(formattedError.getMessage()) + .data(formattedError.toJson()) + .trace(ex)); + return; + } catch (MissionModelLoader.MissionModelLoadException ex) { + final var formattedError = new MerlinFormattedError(ex); + writer.failWith(b -> b + .type(formattedError.getType()) + .message(formattedError.getMessage()) + .data(formattedError.toJson()) .trace(ex)); return; } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/TypescriptCodeGenerationServiceAdapter.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/TypescriptCodeGenerationServiceAdapter.java index 98c02dfb80..125bda5a0c 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/TypescriptCodeGenerationServiceAdapter.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/TypescriptCodeGenerationServiceAdapter.java @@ -2,6 +2,7 @@ package gov.nasa.jpl.aerie.merlin.server.services; import gov.nasa.jpl.aerie.constraints.TypescriptCodeGenerationService; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; import gov.nasa.jpl.aerie.merlin.protocol.types.ValueSchema; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; @@ -23,7 +24,7 @@ public TypescriptCodeGenerationServiceAdapter(final MissionModelService missionM } public String generateTypescriptTypes(final MissionModelId missionModelId, final Optional planId, final Optional simulationDatasetId) - throws MissionModelService.NoSuchMissionModelException, NoSuchPlanException + throws MissionModelService.NoSuchMissionModelException, NoSuchPlanException, MissionModelLoader.MissionModelLoadException { return TypescriptCodeGenerationService .generateTypescriptTypes( @@ -55,7 +56,7 @@ static Map resourceSchemas( final PlanService planService, final Optional planId, final Optional simulationDatasetId - ) throws MissionModelService.NoSuchMissionModelException, NoSuchPlanException { + ) throws MissionModelService.NoSuchMissionModelException, NoSuchPlanException, MissionModelLoader.MissionModelLoadException { final var simulatedResourceSchemas = missionModelService.getResourceSchemas(modelId); final var results = new HashMap(simulatedResourceSchemas); if (planId.isPresent()) { diff --git a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/mocks/StubMissionModelService.java b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/mocks/StubMissionModelService.java index ad699d7d9f..85bde17e19 100644 --- a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/mocks/StubMissionModelService.java +++ b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/mocks/StubMissionModelService.java @@ -13,7 +13,6 @@ import gov.nasa.jpl.aerie.merlin.protocol.types.ValueSchema; import gov.nasa.jpl.aerie.merlin.server.models.ActivityType; import gov.nasa.jpl.aerie.merlin.server.models.MissionModelJar; -import gov.nasa.jpl.aerie.merlin.server.services.LocalMissionModelService; import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService; import java.nio.file.Path; @@ -166,7 +165,6 @@ public List validateActivityArguments(final MissionModelId mis public Map validateActivityInstantiations( final MissionModelId missionModelId, final Map activities) - throws LocalMissionModelService.MissionModelLoadException { return Map.of(); } @@ -180,7 +178,6 @@ public List getActivityEffectiveArgumentsBulk( @Override public List validateModelArguments(final MissionModelId missionModelId, final Map arguments) - throws LocalMissionModelService.MissionModelLoadException { return List.of(); } @@ -194,7 +191,6 @@ public List getModelParameters(final MissionModelId missionModelId) { public Map getModelEffectiveArguments( final MissionModelId missionModelId, final Map arguments) - throws LocalMissionModelService.MissionModelLoadException { return Map.of(); } diff --git a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/services/TypescriptCodeGenerationServiceTest.java b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/services/TypescriptCodeGenerationServiceTest.java index 660d0cf143..3953c18723 100644 --- a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/services/TypescriptCodeGenerationServiceTest.java +++ b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/services/TypescriptCodeGenerationServiceTest.java @@ -1,5 +1,6 @@ package gov.nasa.jpl.aerie.merlin.server.services; +import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.merlin.server.mocks.StubMissionModelService; import gov.nasa.jpl.aerie.merlin.server.mocks.StubPlanService; @@ -14,7 +15,7 @@ class TypescriptCodeGenerationServiceTest { @Test - void testCodeGen() throws MissionModelService.NoSuchMissionModelException, NoSuchPlanException { + void testCodeGen() throws MissionModelService.NoSuchMissionModelException, NoSuchPlanException, MissionModelLoader.MissionModelLoadException { final var codeGenService = new TypescriptCodeGenerationServiceAdapter(new StubMissionModelService(), new StubPlanService()); final var expected = codeGenService.generateTypescriptTypes(new MissionModelId(1L), Optional.of(new PlanId(1L)), Optional.empty()); diff --git a/merlin-worker/src/main/java/gov/nasa/jpl/aerie/merlin/worker/MerlinWorkerAppDriver.java b/merlin-worker/src/main/java/gov/nasa/jpl/aerie/merlin/worker/MerlinWorkerAppDriver.java index bf4ae3163f..37f5f4c2f6 100644 --- a/merlin-worker/src/main/java/gov/nasa/jpl/aerie/merlin/worker/MerlinWorkerAppDriver.java +++ b/merlin-worker/src/main/java/gov/nasa/jpl/aerie/merlin/worker/MerlinWorkerAppDriver.java @@ -2,6 +2,7 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import gov.nasa.jpl.aerie.json.FormattedError; import gov.nasa.jpl.aerie.merlin.driver.resources.StreamingSimulationResourceManager; import gov.nasa.jpl.aerie.merlin.server.ResultsProtocol; import gov.nasa.jpl.aerie.merlin.server.config.PostgresStore; @@ -101,13 +102,18 @@ public static void main(String[] args) throws InterruptedException { canceledListener, new StreamingSimulationResourceManager(streamer)); } catch (final Throwable ex) { - ex.printStackTrace(System.err); + final var formattedError = new FormattedError( + FormattedError.AerieService.SIMULATION_WORKER, + "UNEXPECTED_SIMULATION_EXCEPTION", + "Something went wrong while simulating", + ex); writer.failWith(b -> b - .type("UNEXPECTED_SIMULATION_EXCEPTION") - .message("Something went wrong while simulating") + .type(formattedError.getType()) + .message(formattedError.getMessage()) + .data(formattedError.toJson()) .trace(ex)); - } - finally { + ex.printStackTrace(System.err); + } finally { canceledListener.unregister(); } } diff --git a/parsing-utilities/build.gradle b/parsing-utilities/build.gradle index ac0c7e02af..17a073311b 100644 --- a/parsing-utilities/build.gradle +++ b/parsing-utilities/build.gradle @@ -27,6 +27,8 @@ jacocoTestReport { dependencies { api 'org.glassfish:javax.json:1.1.4' api 'org.apache.commons:commons-lang3:3.13.0' + implementation("com.fasterxml.jackson.core:jackson-databind:2.20.1") + implementation 'io.javalin:javalin:5.6.3' testImplementation 'org.junit.jupiter:junit-jupiter-engine:6.0.1' diff --git a/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/FormattedError.java b/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/FormattedError.java new file mode 100644 index 0000000000..a3e5c8268d --- /dev/null +++ b/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/FormattedError.java @@ -0,0 +1,294 @@ +package gov.nasa.jpl.aerie.json; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.javalin.http.UnauthorizedResponse; +import io.javalin.validation.ValidationException; + +import javax.json.Json; +import javax.json.JsonException; +import javax.json.JsonObject; +import javax.json.JsonValue; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.sql.SQLException; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; + +/** + * Class for formatting exceptions thrown into JSON objects that meet the Aerie HTTP endpoint error message format + * Relevant ticket going over said format: https://github.com/NASA-AMMOS/plandev/issues/1732 + */ +@JsonSerialize(using = FormattedError.FormattedErrorSerializer.class) +public class FormattedError { + public enum AerieService { + MERLIN_SERVER("aerie_merlin"), + SCHEDULER_SERVER("aerie_scheduler"), + WORKSPACE_SERVER("aerie_workspace"), + PERMISSIONS_SERVICE("aerie_permissions"), + SIMULATION_WORKER("aerie_merlin_worker"), + SCHEDULER_WORKER("aerie_scheduler_worker"); + + private final String serviceName; + AerieService(String serviceName) { this.serviceName = serviceName; } + String serviceName() {return serviceName;} + } + + private final String type; + private final String message; + private final String timestamp = ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT); + private final AerieService service; + private Optional cause = Optional.empty(); + private Optional trace = Optional.empty(); + private Optional data = Optional.empty(); + + /** + * For use in the event of an endpoint failing without throwing an exception. + * i.e. Workspace's delete file endpoint failing because java.nio.File#deleteFile returned "false" + */ + public FormattedError(AerieService service, String message) { + this.type = "INTERNAL_ERROR"; + this.message = message; + this.service = service; + } + + /** + * For use in the event of an endpoint failing without throwing an exception, but where there's a more detailed cause. + */ + public FormattedError(AerieService service, String message, String cause) { + this.type = "INTERNAL_ERROR"; + this.message = message; + this.service = service; + this.cause = Optional.ofNullable(cause); + } + + /** + * For use in the event of an endpoint failing without throwing an exception, + * but "INTERNAL_ERROR" does not make sense as the error type (i.e. the request is malformed) + */ + public FormattedError(AerieService service, String type, String message, Optional cause) { + this.type = type; + this.message = message; + this.service = service; + this.cause = cause; + } + + /** + * Create a FormattedException from a generic Exception object. + * @param service the service that throw the exception. + * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE. + * @param ex the exception to be formatted. + */ + public FormattedError(AerieService service, String type, Exception ex) { + this.type = type; + message = ex.getMessage() == null ? "No exception message provided." : ex.getMessage(); + this.service = service; + trace = Optional.of(generateTrace(ex)); + } + + /** + * Create a FormattedException from a generic Exception object with a custom error message. + * The exception's built-in error message, if included, will be put into the 'cause' field. + * @param service the service that throw the exception. + * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE. + * @param message the custom error message explaining the cause of the error. + * Should be human-readable and between 1-2 sentences. + * @param ex the exception to be formatted. + */ + public FormattedError(AerieService service, String type, String message, Throwable ex) { + this.type = type; + this.message = message; + this.service = service; + cause = Optional.ofNullable(ex.getMessage()); + trace = Optional.of(generateTrace(ex)); + } + + /** + * Create a FormattedException from a generic Exception object with a custom error message and cause. + * @param service the service that throw the exception. + * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE. + * @param message the custom error message explaining the cause of the error. + * Should be human-readable and between 1-2 sentences. + * @param cause the root cause of the exception. + * @param ex the exception to be formatted. + */ + public FormattedError(AerieService service, String type, String message, String cause, Exception ex) { + this.type = type; + this.message = message; + this.service = service; + this.cause = Optional.ofNullable(cause); + trace = Optional.of(generateTrace(ex)); + } + + /** + * Create a FormattedException with additional data from a generic Exception object. + * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE + * @param ex the exception to be formatted. + * @param data the additional data to be included + */ + public FormattedError(AerieService service, String type, Exception ex, JsonValue data) { + this.type = type; + message = ex.getMessage() == null ? "No exception message provided." : ex.getMessage(); + this.service = service; + trace = Optional.of(generateTrace(ex)); + this.data = Optional.of(data); + } + + /** + * Create a FormattedException with a custom message and additional data from a generic Exception object. + * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE + * @param message the custom error message explaining the cause of the error. + * Should be human-readable and between 1-2 sentences. + * @param ex the exception to be formatted. + * @param data the additional data to be included + */ + public FormattedError(AerieService service, String type, String message, Exception ex, JsonValue data) { + this.type = type; + this.message = message; + this.service = service; + trace = Optional.of(generateTrace(ex)); + this.data = Optional.of(data); + } + + + // region Constructors for specific exceptions + // This helps `type` be consistent every time the exception is thrown. + + // IOException + public FormattedError(AerieService service, IOException nse) { + this(service, "IO_EXCEPTION", nse); + } + public FormattedError(AerieService service, IOException nse, String message) { + this(service, message, nse); + } + + // SQLException + public FormattedError(AerieService service, SQLException se) { + this(service, "SQL_EXCEPTION", se); + } + public FormattedError(AerieService service, SQLException se, String message) { + this(service, "SQL_EXCEPTION", message, se); + } + + // Unauthorized + public FormattedError(AerieService service, UnauthorizedResponse ue) { + this.service = service; + this.type = "UNAUTHORIZED"; + this.message = ue.getMessage() != null ? ue.getMessage() : "Unauthorized"; + // Include additional details, if present + if(!ue.getDetails().isEmpty()) { + final var dataBuilder = Json.createObjectBuilder(); + ue.getDetails().forEach(dataBuilder::add); + this.data = Optional.of(dataBuilder.build()); + } + } + + // NumberFormatException + public FormattedError(AerieService service, NumberFormatException nfe) { + this(service, "NUMBER_PARSING_EXCEPTION", nfe); + } + + // IllegalArgumentException + public FormattedError(AerieService service, IllegalArgumentException iae) { + this(service, "ILLEGAL_ARGUMENT", iae); + } + + // JSONException + public FormattedError(AerieService service, JsonException je){ + this(service, "JSON_PARSING_EXCEPTION", je); + } + + public FormattedError(AerieService service, JsonException je, String message){ + this(service, "JSON_PARSING_EXCEPTION", message, je); + } + + // ValidationException + public FormattedError(final AerieService service, ValidationException ve) { + this.service = service; + this.type = "ENDPOINT_VALIDATION_EXCEPTION"; + this.message = ve.getMessage() != null ? ve.getMessage() : "Invalid request"; + trace = Optional.of(generateTrace(ve)); + } + + // Null Pointer Exception + public FormattedError(AerieService service, NullPointerException ne, String message) { + this(service, "NULL_POINTER_EXCEPTION", message, ne); + } + + // Security Exception + public FormattedError(AerieService service, SecurityException se) { + this(service, "SECURITY_EXCEPTION", se.getMessage(), se); + } + //endregion + + /** + * Generate a stack trace string from an Exception. + */ + private String generateTrace(Throwable ex) { + final var sw = new StringWriter(); + try(final var pw = new PrintWriter(sw)) { + ex.printStackTrace(pw); + } + return sw.toString(); + } + + public String getType() { return type; } + public String getMessage() { return message; } + + /** + * Export this object to a JsonObject. + */ + public JsonObject toJson() { + // Include all mandatory fields + final var builder = Json.createObjectBuilder() + .add("type", type) + .add("message", message) + .add("timestamp", timestamp) + .add("service", service.serviceName()); // Not mandatory on spec, but always known + + // Include optional fields, if present + cause.ifPresent((c) -> builder.add("cause", c)); + trace.ifPresent((t) -> builder.add("trace", t)); + data.ifPresent((d) -> builder.add("data", d)); + + return builder.build(); + } + + @Override + public String toString() { + return this.toJson().toString(); + } + + /** + * Internal class so that Javalin serializes the FormattedError class using its `toJson` method. + * This avoids needing to call `toJson` every time the FormattedError class is used as an endpoint return. + * The class implements Jackson's JsonSerializer in specific because Javalin uses Jackson as its default JSON Mapper. + */ + public static final class FormattedErrorSerializer extends JsonSerializer { + public static boolean USE_HASURA_FORMATTING = false; + + @Override + public void serialize( + final FormattedError formattedError, + final JsonGenerator jsonGenerator, + final SerializerProvider serializerProvider) throws IOException + { + // Hasura only acknowledges the "message" and "extensions" keys, so rewrap the error in that format + if(USE_HASURA_FORMATTING) { + final var respString = Json.createObjectBuilder() + .add("message", formattedError.message) + .add("extensions", formattedError.toJson()) + .build() + .toString(); + jsonGenerator.writeRaw(respString); + } else { + jsonGenerator.writeRaw(formattedError.toString()); + } + } + } +} diff --git a/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/JsonParseResult.java b/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/JsonParseResult.java index 751f997eef..a7581a4b6e 100644 --- a/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/JsonParseResult.java +++ b/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/JsonParseResult.java @@ -1,7 +1,9 @@ package gov.nasa.jpl.aerie.json; import org.apache.commons.lang3.tuple.Pair; +import org.jetbrains.annotations.NotNull; +import javax.json.Json; import java.util.ArrayList; import java.util.List; import java.util.function.BiFunction; @@ -104,5 +106,15 @@ public FailureReason prependBreadcrumb(Breadcrumb breadcrumb) { this.breadcrumbs.add(0, breadcrumb); return this; } + + @Override + public @NotNull String toString() { + final var breadcrumbsArray = Json.createArrayBuilder(); + breadcrumbs.forEach(b -> breadcrumbsArray.add(b.toString())); + return Json.createObjectBuilder() + .add("breadcrumbs", breadcrumbsArray) + .add("message", reason()) + .build().toString(); + } } } diff --git a/permissions/build.gradle b/permissions/build.gradle index ca012d4e81..92d01c940b 100644 --- a/permissions/build.gradle +++ b/permissions/build.gradle @@ -11,6 +11,8 @@ java { dependencies { implementation 'org.glassfish:javax.json:1.1.4' + implementation("com.fasterxml.jackson.core:jackson-databind:2.20.1") + implementation project(':parsing-utilities') } // NOTE: This module is published ONLY to satisfy a transitive dependency in orchestration-utils. diff --git a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/PermissionsService.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/PermissionsService.java index f8f7a3bb51..9ca73748fd 100644 --- a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/PermissionsService.java +++ b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/PermissionsService.java @@ -1,10 +1,12 @@ package gov.nasa.jpl.aerie.permissions; import gov.nasa.jpl.aerie.permissions.exceptions.Forbidden; +import gov.nasa.jpl.aerie.permissions.exceptions.GraphQLServiceException; import gov.nasa.jpl.aerie.permissions.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.permissions.exceptions.NoSuchSchedulingSpecificationException; import gov.nasa.jpl.aerie.permissions.exceptions.NoSuchWorkspaceException; -import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; +import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsException; +import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsFormattedError; import gov.nasa.jpl.aerie.permissions.gql.GraphQLPermissionsService; import gov.nasa.jpl.aerie.permissions.gql.PlanId; import gov.nasa.jpl.aerie.permissions.gql.SchedulingSpecificationId; @@ -20,49 +22,81 @@ public PermissionsService(final GraphQLPermissionsService gqlService) { } public void check(final HasuraAction action, final String role, final String username, final PlanId planId) - throws Forbidden, IOException, PermissionsServiceException, NoSuchPlanException { - final var permissionType = getActionPermission(action, role); - final var authorized = canPerformAction(permissionType, username, planId); - if (!authorized) throw new Forbidden(action, role, username, permissionType, planId); + throws PermissionsException { + try { + final var permissionType = getActionPermission(action, role); + final var authorized = canPerformAction(permissionType, username, planId); + if (!authorized) throw new Forbidden(action, role, username, permissionType, planId); + } catch (Forbidden f) { + throw new PermissionsException(403, new PermissionsFormattedError(f)); + } catch (NoSuchPlanException nsp) { + throw new PermissionsException(404, new PermissionsFormattedError(nsp)); + } catch (GraphQLServiceException ex) { + throw new PermissionsException(500, new PermissionsFormattedError(ex)); + } catch (IOException io) { + throw new PermissionsException(500, new PermissionsFormattedError(io)); + } } public void check( final HasuraAction action, final String role, final String username, - final SchedulingSpecificationId specificationId) - throws Forbidden, IOException, PermissionsServiceException, NoSuchSchedulingSpecificationException, - NoSuchPlanException - { - final var planId = gqlService.getPlanIdFromSchedulingSpecificationId(specificationId); - check(action, role, username, planId); + final SchedulingSpecificationId specificationId + ) throws PermissionsException { + try { + final var planId = gqlService.getPlanIdFromSchedulingSpecificationId(specificationId); + check(action, role, username, planId); + } catch (NoSuchSchedulingSpecificationException nss) { + throw new PermissionsException(404, new PermissionsFormattedError(nss)); + } catch (GraphQLServiceException ex) { + throw new PermissionsException(500, new PermissionsFormattedError(ex)); + } catch (IOException io) { + throw new PermissionsException(500, new PermissionsFormattedError(io)); + } } public void check( final WorkspaceAction action, final String role, final String username, - final WorkspaceId workspaceId) - throws Forbidden, IOException, PermissionsServiceException, NoSuchWorkspaceException - { - final var permissionType = getWorkspaceActionPermission(action, role); - final var authorized = canPerformWorkspaceAction(permissionType, username, workspaceId); - if (!authorized) throw new Forbidden(action, role, username, permissionType, workspaceId); + final WorkspaceId workspaceId + ) throws PermissionsException { + try { + final var permissionType = getWorkspaceActionPermission(action, role); + final var authorized = canPerformWorkspaceAction(permissionType, username, workspaceId); + if (!authorized) throw new Forbidden(action, role, username, permissionType, workspaceId); + } catch (Forbidden f) { + throw new PermissionsException(403, new PermissionsFormattedError(f)); + } catch (NoSuchWorkspaceException nsp) { + throw new PermissionsException(404, new PermissionsFormattedError(nsp)); + } catch (GraphQLServiceException ex) { + throw new PermissionsException(500, new PermissionsFormattedError(ex)); + } catch (IOException io) { + throw new PermissionsException(500, new PermissionsFormattedError(io)); + } } public void checkCoarseGrained(final Action action, final String role) - throws PermissionsServiceException, Forbidden, IOException + throws PermissionsException { - if(action instanceof WorkspaceAction workspaceAction) { - getWorkspaceActionPermission(workspaceAction, role); - } - else { - throw new IllegalArgumentException("Unsupported action subtype: "+action.getClass()); + try { + if (action instanceof WorkspaceAction workspaceAction) { + getWorkspaceActionPermission(workspaceAction, role); + } else { + throw new IllegalArgumentException("Unsupported action subtype: " + action.getClass()); + } + } catch (Forbidden f) { + throw new PermissionsException(403, new PermissionsFormattedError(f)); + } catch (GraphQLServiceException ex) { + throw new PermissionsException(500, new PermissionsFormattedError(ex)); + } catch (IOException io) { + throw new PermissionsException(500, new PermissionsFormattedError(io)); } } private PlanPermissionType getActionPermission(final HasuraAction action, final String role) - throws Forbidden, IOException, PermissionsServiceException + throws Forbidden, IOException, GraphQLServiceException { if (role.equals("aerie_admin")) { return PlanPermissionType.NO_CHECK; @@ -74,7 +108,7 @@ private boolean canPerformAction( final PlanPermissionType permissionType, final String username, final PlanId planId) - throws IOException, PermissionsServiceException, NoSuchPlanException { + throws IOException, GraphQLServiceException, NoSuchPlanException { return switch (permissionType) { case NO_CHECK -> true; case MISSION_MODEL_OWNER -> gqlService.checkMissionModelOwner(planId, username); @@ -85,13 +119,13 @@ private boolean canPerformAction( } private OwnerOrCollaborator getPlanPermissions(final String username, final PlanId planId) - throws IOException, PermissionsServiceException, NoSuchPlanException + throws IOException, GraphQLServiceException, NoSuchPlanException { return gqlService.checkPlanOwnerCollaborator(planId, username); } private WorkspacePermissionType getWorkspaceActionPermission(final WorkspaceAction action, final String role) - throws Forbidden, IOException, PermissionsServiceException + throws Forbidden, IOException, GraphQLServiceException { if (role.equals("aerie_admin")) { return WorkspacePermissionType.NO_CHECK; @@ -103,7 +137,7 @@ private boolean canPerformWorkspaceAction( final WorkspacePermissionType permissionType, final String username, final WorkspaceId workspaceId) - throws IOException, PermissionsServiceException, NoSuchWorkspaceException { + throws IOException, GraphQLServiceException, NoSuchWorkspaceException { return switch (permissionType) { case NO_CHECK -> true; case OWNER -> getWorkspacePermissions(username, workspaceId).isOwner(); @@ -113,7 +147,7 @@ private boolean canPerformWorkspaceAction( } private OwnerOrCollaborator getWorkspacePermissions(final String username, final WorkspaceId workspaceId) - throws IOException, PermissionsServiceException, NoSuchWorkspaceException + throws IOException, GraphQLServiceException, NoSuchWorkspaceException { return gqlService.checkWorkspaceOwnerCollaborator(workspaceId, username); } diff --git a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/ExceptionSerializers.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/ExceptionSerializers.java deleted file mode 100644 index ac4dd647dd..0000000000 --- a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/ExceptionSerializers.java +++ /dev/null @@ -1,40 +0,0 @@ -package gov.nasa.jpl.aerie.permissions.exceptions; - -import javax.json.Json; -import javax.json.JsonValue; -import java.io.IOException; - - -public class ExceptionSerializers { - public static JsonValue serializeNoSuchPlanException(final NoSuchPlanException ex) { - return Json.createObjectBuilder() - .add("message", "no such plan") - .add("plan_id", ex.id.id()) - .build(); - } - - public static JsonValue serializeNoSuchSchedulingSpecificationException(final NoSuchSchedulingSpecificationException ex) { - return Json.createObjectBuilder() - .add("message", "no such scheduling specification") - .add("plan_id", ex.id.id()) - .build(); - } - - public static JsonValue serializePermissionsServiceException(final PermissionsServiceException ex) { - return Json.createObjectBuilder() - .add("message", "error in response") - .add("errors", ex.errors) - .build(); - } - - public static JsonValue serializeForbiddenException(final Forbidden ex) { - return Json.createObjectBuilder().add("message", ex.getMessage()).build(); - } - - public static JsonValue serializeIOException(final IOException ex) { - return Json.createObjectBuilder() - .add("message", "error fetching permissions data") - .add("cause", ex.getMessage()) - .build(); - } -} diff --git a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsServiceException.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/GraphQLServiceException.java similarity index 53% rename from permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsServiceException.java rename to permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/GraphQLServiceException.java index 85388009c5..f2e65609f0 100644 --- a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsServiceException.java +++ b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/GraphQLServiceException.java @@ -2,9 +2,9 @@ import javax.json.JsonValue; -public class PermissionsServiceException extends Exception { +public class GraphQLServiceException extends Exception { public final JsonValue errors; - public PermissionsServiceException(final String message, final JsonValue errors) { + public GraphQLServiceException(final String message, final JsonValue errors) { super(message); this.errors = errors; } diff --git a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/NoSuchPlanException.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/NoSuchPlanException.java index 88dc02b700..b31f1e61d7 100644 --- a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/NoSuchPlanException.java +++ b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/NoSuchPlanException.java @@ -5,7 +5,7 @@ public final class NoSuchPlanException extends Exception { public final PlanId id; public NoSuchPlanException(final PlanId id) { - super("No plan exists with id '%s'".formatted(id)); + super("No plan exists with id '%s'".formatted(id.id())); this.id = id; } } diff --git a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsException.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsException.java new file mode 100644 index 0000000000..224266449d --- /dev/null +++ b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsException.java @@ -0,0 +1,23 @@ +package gov.nasa.jpl.aerie.permissions.exceptions; + +import gov.nasa.jpl.aerie.json.FormattedError; + +/** + * Wrapper Exception for all thrown exceptions in the Permissions Service. + * This exception contains the root exception thrown alongside the recommended + * HTTP Status code that should be returned based on the exception. + */ +public class PermissionsException extends Exception { + private final int httpStatus; + private final FormattedError formattedError; + + public PermissionsException(int httpStatus, FormattedError formattedError) { + this.httpStatus = httpStatus; + this.formattedError = formattedError; + } + + public int httpStatusCode() { + return httpStatus; + } + public FormattedError formattedError() {return formattedError;} +} diff --git a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsFormattedError.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsFormattedError.java new file mode 100644 index 0000000000..d7041fb3e9 --- /dev/null +++ b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsFormattedError.java @@ -0,0 +1,64 @@ +package gov.nasa.jpl.aerie.permissions.exceptions; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import gov.nasa.jpl.aerie.json.FormattedError; + +import javax.json.Json; +import java.io.IOException; + +/** + * Class for formatting exceptions thrown while checking permissions into JSON objects + * that meet the Aerie HTTP endpoint error message format + * Relevant ticket going over said format: https://github.com/NASA-AMMOS/aerie/issues/1732 + */ +@JsonSerialize(using = FormattedError.FormattedErrorSerializer.class) +public final class PermissionsFormattedError extends FormattedError{ + // NoSuchX + public PermissionsFormattedError(NoSuchPlanException npe) { + super(AerieService.PERMISSIONS_SERVICE, + "NO_SUCH_PLAN", + "Could not check permissions on plan %d: plan does not exist.".formatted(npe.id.id()), + npe, + Json.createObjectBuilder() + .add("plan_id", npe.id.id()) + .build() + ); + } + + public PermissionsFormattedError(NoSuchSchedulingSpecificationException nsse) { + super(AerieService.PERMISSIONS_SERVICE, + "NO_SUCH_SCHEDULING_SPECIFICATION", + "Could not check permissions on scheduling specification %d: specification does not exist.".formatted(nsse.id.id()), + nsse, + Json.createObjectBuilder() + .add("specification_id", nsse.id.id()) + .build() + ); + } + + public PermissionsFormattedError(NoSuchWorkspaceException nse) { + super(AerieService.PERMISSIONS_SERVICE, + "NO_SUCH_WORKSPACE", + "Could not check permissions on workspace %d: workspace does not exist.".formatted(nse.id.id()), + nse, + Json.createObjectBuilder() + .add("workspace_id", nse.id.id()) + .build() + ); + } + + // IOException + public PermissionsFormattedError(IOException ioe) { + super(AerieService.PERMISSIONS_SERVICE, "PERMISSIONS_SERVICE_EXCEPTION", "Could not check permissions.", ioe); + } + + // Forbidden + public PermissionsFormattedError(Forbidden f) { + super(AerieService.PERMISSIONS_SERVICE, "FORBIDDEN", f); + } + + // PermissionsServiceException + public PermissionsFormattedError(GraphQLServiceException pse) { + super(AerieService.PERMISSIONS_SERVICE, "GRAPHQL_SERVICE_EXCEPTION", "Could not check permissions.", pse); + } +} diff --git a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/gql/GraphQLPermissionsService.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/gql/GraphQLPermissionsService.java index 25fce69d75..7d1d62ad01 100644 --- a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/gql/GraphQLPermissionsService.java +++ b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/gql/GraphQLPermissionsService.java @@ -6,10 +6,10 @@ import gov.nasa.jpl.aerie.permissions.WorkspaceAction; import gov.nasa.jpl.aerie.permissions.WorkspacePermissionType; import gov.nasa.jpl.aerie.permissions.exceptions.Forbidden; +import gov.nasa.jpl.aerie.permissions.exceptions.GraphQLServiceException; import gov.nasa.jpl.aerie.permissions.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.permissions.exceptions.NoSuchSchedulingSpecificationException; import gov.nasa.jpl.aerie.permissions.exceptions.NoSuchWorkspaceException; -import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; import javax.json.Json; import javax.json.JsonException; @@ -45,9 +45,9 @@ public record GraphQLPermissionsService( * @param query the graphQL query or mutation to send to aerie * @return the json response returned by aerie, or an empty optional in case of io errors */ - private Optional postRequest(final String query, final JsonObject variables) throws IOException, PermissionsServiceException + private Optional postRequest(final String query, final JsonObject variables) throws IOException, GraphQLServiceException { - try { + try(final var httpClient = HttpClient.newHttpClient()) { //TODO: (mem optimization) use streams here to avoid several copies of strings final var reqBody = Json .createObjectBuilder() @@ -62,14 +62,13 @@ private Optional postRequest(final String query, final JsonObject va .header("x-hasura-admin-secret", hasuraGraphQlAdminSecret) .POST(HttpRequest.BodyPublishers.ofString(reqBody.toString())) .build(); - final var httpResp = HttpClient - .newHttpClient().send(httpReq, HttpResponse.BodyHandlers.ofInputStream()); + final var httpResp = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofInputStream()); if (httpResp.statusCode() != 200) { throw new IOException("Unexpected " + httpResp.statusCode() + " status when connecting to hasura"); } final var respBody = Json.createReader(httpResp.body()).readObject(); if (respBody.containsKey("errors")) { - throw new PermissionsServiceException(respBody.toString(), respBody.get("errors")); + throw new GraphQLServiceException(respBody.toString(), respBody.get("errors")); } return Optional.of(respBody); } catch (final InterruptedException e) { @@ -80,7 +79,7 @@ private Optional postRequest(final String query, final JsonObject va } public PlanPermissionType getActionPermission(final HasuraAction action, final String role) - throws IOException, Forbidden, PermissionsServiceException { + throws IOException, Forbidden, GraphQLServiceException { final var query = """ query getActionPermission($role: user_roles_enum!, $action: String!) { check: user_role_permission_by_pk(role: $role) { @@ -100,7 +99,7 @@ query getActionPermission($role: user_roles_enum!, $action: String!) { } public WorkspacePermissionType getWorkspaceActionPermission(final WorkspaceAction action, final String role) - throws IOException, Forbidden, PermissionsServiceException { + throws IOException, Forbidden, GraphQLServiceException { final var query = """ query getWorkspaceActionPermission($role: user_roles_enum!, $action: String!) { check: user_role_permission_by_pk(role: $role) { @@ -119,7 +118,8 @@ query getWorkspaceActionPermission($role: user_roles_enum!, $action: String!) { return WorkspacePermissionType.valueOf(check.getString("permission")); } - public OwnerOrCollaborator checkPlanOwnerCollaborator(final PlanId planId, final String username) throws IOException, NoSuchPlanException, PermissionsServiceException { + public OwnerOrCollaborator checkPlanOwnerCollaborator(final PlanId planId, final String username) + throws IOException, NoSuchPlanException, GraphQLServiceException { final var query = """ query getPlanOwnerCollaborators($id: Int!, $username: String!) { plan: plan_by_pk(id: $id) { @@ -145,7 +145,7 @@ query getPlanOwnerCollaborators($id: Int!, $username: String!) { } public OwnerOrCollaborator checkWorkspaceOwnerCollaborator(final WorkspaceId workspaceId, final String username) - throws IOException, NoSuchWorkspaceException, PermissionsServiceException { + throws IOException, NoSuchWorkspaceException, GraphQLServiceException { final var query = """ query getWorkspaceOwnerCollaborators($id: Int!, $username: String!) { workspace: workspace_by_pk(id: $id) { @@ -185,7 +185,7 @@ private OwnerOrCollaborator getOwnerCollaboratorStatus(JsonObject ownerCollabora } public boolean checkMissionModelOwner(final PlanId planId, final String username) - throws PermissionsServiceException, IOException, NoSuchPlanException + throws GraphQLServiceException, IOException, NoSuchPlanException { final var query = """ query getModelOwner($id: Int!) { @@ -212,7 +212,7 @@ query getModelOwner($id: Int!) { } public PlanId getPlanIdFromSchedulingSpecificationId(final SchedulingSpecificationId specificationId) - throws PermissionsServiceException, IOException, NoSuchSchedulingSpecificationException + throws GraphQLServiceException, IOException, NoSuchSchedulingSpecificationException { final var query = """ query planIdFromSpecId($id: Int!) { diff --git a/scheduler-server/build.gradle b/scheduler-server/build.gradle index a46807cee1..dc7176086f 100644 --- a/scheduler-server/build.gradle +++ b/scheduler-server/build.gradle @@ -30,6 +30,8 @@ dependencies { implementation 'io.javalin:javalin:5.6.3' implementation 'org.eclipse:yasson:3.0.3' + implementation("com.fasterxml.jackson.core:jackson-databind:2.20.1") + implementation 'org.postgresql:postgresql:42.6.1' implementation 'com.zaxxer:HikariCP:5.0.1' diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/SchedulerAppDriver.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/SchedulerAppDriver.java index d5f3ea0ea0..63c081b7be 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/SchedulerAppDriver.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/SchedulerAppDriver.java @@ -84,7 +84,6 @@ public static void main(final String[] args) { javalinConfig.plugins.enableCors(cors -> cors.add(it -> it.anyHost())); //TODO: probably don't want literally any cross-origin request... javalinConfig.plugins.register(bindings); javalinConfig.jetty.server(() -> server); - //TODO: exception handling (shxould elevate/reuse from MerlinApp for consistency?) }); //start the http server and handle requests as configured above diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/NoSuchPlanException.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/NoSuchPlanException.java index 4ad2c70557..0966afdcad 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/NoSuchPlanException.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/NoSuchPlanException.java @@ -6,7 +6,7 @@ public class NoSuchPlanException extends Exception { private final PlanId id; public NoSuchPlanException(final PlanId id) { - super("No plan exists with id `" + id + "`"); + super("No plan exists with id `" + id.id() + "`"); this.id = id; } diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/SchedulerFormattedError.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/SchedulerFormattedError.java new file mode 100644 index 0000000000..d5aaf29d07 --- /dev/null +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/SchedulerFormattedError.java @@ -0,0 +1,57 @@ +package gov.nasa.jpl.aerie.scheduler.server.exceptions; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import gov.nasa.jpl.aerie.json.FormattedError; +import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonEntityException; +import gov.nasa.jpl.aerie.scheduler.server.models.SchedulingCompilationError; +import gov.nasa.jpl.aerie.scheduler.server.remotes.postgres.DatabaseException; +import gov.nasa.jpl.aerie.scheduler.server.services.MerlinServiceException; + +import javax.json.Json; + +@JsonSerialize(using = FormattedError.FormattedErrorSerializer.class) +public class SchedulerFormattedError extends FormattedError { + //region NO SUCH X + public SchedulerFormattedError(NoSuchSpecificationException ex) { + super( + AerieService.SCHEDULER_SERVER, + "NO_SUCH_SCHEDULING_SPECIFICATION", + ex, + Json.createObjectBuilder() + .add("specification_id", ex.specificationId.id()) + .build() + ); + } + + public SchedulerFormattedError(NoSuchPlanException npe) { + super( + AerieService.SCHEDULER_SERVER, + "NO_SUCH_PLAN", + npe, + Json.createObjectBuilder() + .add("plan_id", npe.getInvalidPlanId().id()) + .build() + ); + } + //endregion + + public SchedulerFormattedError(InvalidJsonEntityException ex) { + super(AerieService.SCHEDULER_SERVER, "JSON_PARSING_EXCEPTION", ex); + } + + public SchedulerFormattedError(MerlinServiceException ex) { + super(AerieService.SCHEDULER_SERVER, "PLAN_SERVICE_EXCEPTION", ex); + } + + public SchedulerFormattedError(SpecificationLoadException ex) { + super( + AerieService.SCHEDULER_SERVER, + "SPECIFICATION_LOAD_EXCEPTION", + ex, + SchedulingCompilationError.schedulingErrorJsonP.unparse(ex.errors)); + } + + public SchedulerFormattedError(DatabaseException ex) { + super(AerieService.SCHEDULER_SERVER, "DATABASE_EXCEPTION", ex); + } +} diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/BulkEffectiveArgumentResponse.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/BulkEffectiveArgumentResponse.java index 248364f010..e818d43094 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/BulkEffectiveArgumentResponse.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/BulkEffectiveArgumentResponse.java @@ -9,6 +9,7 @@ import java.util.Map; public sealed interface BulkEffectiveArgumentResponse { + GoalId goalId(); record Success(GoalId goalId, Map effectiveArguments) implements BulkEffectiveArgumentResponse { } record NoGoalFailure(GoalId goalId, NoSuchSchedulingGoalException ex) implements BulkEffectiveArgumentResponse { } record InstantiationFailure(GoalId goalId, InstantiationException ex) implements BulkEffectiveArgumentResponse { } diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidEntityException.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidEntityException.java deleted file mode 100644 index 54285b930e..0000000000 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidEntityException.java +++ /dev/null @@ -1,14 +0,0 @@ -package gov.nasa.jpl.aerie.scheduler.server.http; - -import java.util.List; - -import static gov.nasa.jpl.aerie.json.JsonParseResult.FailureReason; - -public class InvalidEntityException extends Exception { - - public final List failures; - - public InvalidEntityException(List failures) { - this.failures = List.copyOf(failures); - } -} diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidJsonEntityException.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidJsonEntityException.java new file mode 100644 index 0000000000..097281b208 --- /dev/null +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidJsonEntityException.java @@ -0,0 +1,16 @@ +package gov.nasa.jpl.aerie.scheduler.server.http; + +import java.util.List; + +import static gov.nasa.jpl.aerie.json.JsonParseResult.FailureReason; + +public class InvalidJsonEntityException extends Exception { + + public final List failures; + + public InvalidJsonEntityException(List failures) { + super("JSON Parsing Exception was caused by the following failures:\n\t"+ + String.join(",\n\t", failures.stream().map(FailureReason::toString).toList())); + this.failures = List.copyOf(failures); + } +} diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidJsonException.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidJsonException.java deleted file mode 100644 index 7ef43454f1..0000000000 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidJsonException.java +++ /dev/null @@ -1,7 +0,0 @@ -package gov.nasa.jpl.aerie.scheduler.server.http; - -public class InvalidJsonException extends Exception { - public InvalidJsonException(Throwable cause) { - super(cause); - } -} diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/ResponseSerializers.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/ResponseSerializers.java index 2655060489..d88ec36164 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/ResponseSerializers.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/ResponseSerializers.java @@ -6,20 +6,13 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; -import gov.nasa.jpl.aerie.json.JsonParseResult; -import gov.nasa.jpl.aerie.merlin.driver.json.ValueSchemaJsonParser; + import gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException; import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; -import gov.nasa.jpl.aerie.merlin.protocol.types.ValueSchema; -import gov.nasa.jpl.aerie.scheduler.ProcedureLoader; -import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchPlanException; -import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchSchedulingGoalException; -import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchSpecificationException; import gov.nasa.jpl.aerie.scheduler.model.GoalId; import gov.nasa.jpl.aerie.scheduler.server.models.SchedulingCompilationError; import gov.nasa.jpl.aerie.scheduler.server.services.ScheduleAction; import gov.nasa.jpl.aerie.scheduler.server.services.ScheduleResults; -import gov.nasa.jpl.aerie.scheduler.server.services.UnexpectedSubtypeError; import org.apache.commons.lang3.tuple.Pair; import static gov.nasa.jpl.aerie.merlin.driver.json.SerializedValueJsonParser.serializedValueP; @@ -53,38 +46,35 @@ public static JsonValue serializeMap(final Function fieldSeria * @return a json serialization of the scheduling run result */ public static JsonValue serializeScheduleResultsResponse(final ScheduleAction.Response response) { - if (response instanceof final ScheduleAction.Response.Pending r) { - return Json - .createObjectBuilder() - .add("status", "pending") - .add("analysisId", r.analysisId()) - .build(); - } else if (response instanceof ScheduleAction.Response.Incomplete r) { - return Json - .createObjectBuilder() - .add("status", "incomplete") - .add("analysisId", r.analysisId()) - .build(); - } else if (response instanceof ScheduleAction.Response.Failed r) { - return Json - .createObjectBuilder() - .add("status", "failed") - .add("reason", SchedulerParsers.scheduleFailureP.unparse(r.reason())) - .add("analysisId", r.analysisId()) - .build(); - } else if (response instanceof ScheduleAction.Response.Complete r) { - final var responseJson = Json - .createObjectBuilder() - .add("status", "complete") - .add("results", serializeScheduleResults(r.results())) - .add("analysisId", r.analysisId()); - if(r.datasetId().isPresent()){ - responseJson.add("datasetId", r.datasetId().get()); + return switch (response) { + case ScheduleAction.Response.Pending p -> + Json.createObjectBuilder() + .add("status", "pending") + .add("analysisId", p.analysisId()) + .build(); + case ScheduleAction.Response.Incomplete i -> + Json.createObjectBuilder() + .add("status", "incomplete") + .add("analysisId", i.analysisId()) + .build(); + case ScheduleAction.Response.Failed f -> + Json.createObjectBuilder() + .add("status", "failed") + .add("reason", SchedulerParsers.scheduleFailureP.unparse(f.reason())) + .add("analysisId", f.analysisId()) + .build(); + case ScheduleAction.Response.Complete c -> { + final var responseJson = Json + .createObjectBuilder() + .add("status", "complete") + .add("results", serializeScheduleResults(c.results())) + .add("analysisId", c.analysisId()); + if(c.datasetId().isPresent()){ + responseJson.add("datasetId", c.datasetId().get()); + } + yield responseJson.build(); } - return responseJson.build(); - } else { - throw new UnexpectedSubtypeError(ScheduleAction.Response.class, response); - } + }; } public static JsonValue serializeArgument(final SerializedValue parameter) { @@ -97,57 +87,44 @@ public static JsonValue serializeBulkEffectiveArgumentResponseList(final List effectiveArguments)) { - return Json.createObjectBuilder() - .add("id", goalId.id()) - .add("revision", goalId.revision()) - .add("success", JsonValue.TRUE) - .add("arguments", - serializeMap( - ResponseSerializers::serializeArgument, - effectiveArguments)) - .build(); - } else if (response instanceof BulkEffectiveArgumentResponse.TypeFailure(GoalId goalId)) { - return Json.createObjectBuilder() - .add("id", goalId.id()) - .add("revision", goalId.revision()) - .add("success", JsonValue.FALSE) - .add("errors", "Goal is not procedural") - .build(); - } else if (response instanceof BulkEffectiveArgumentResponse.InstantiationFailure( - GoalId goalId, - InstantiationException ex)) { - return Json.createObjectBuilder(serializeInstantiationException(ex).asJsonObject()) - .add("id", goalId.id()) - .add("revision", goalId.revision()) - .build(); - } - else if (response instanceof BulkEffectiveArgumentResponse.NoGoalFailure( - GoalId goalId, - NoSuchSchedulingGoalException ex)) { - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("id", goalId.id()) - .add("revision", goalId.revision()) - .add("errors", "There is no goal with this id") - .build(); - } - else if (response instanceof BulkEffectiveArgumentResponse.ProcedureLoadFailure( - GoalId goalId, - ProcedureLoader.ProcedureLoadException ex)) { - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("id", goalId.id()) - .add("revision", goalId.revision()) - .add("errors", "Error when loading the procedure jar") - .build(); - } - return Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("errors", String.format("Internal error: %s", response)) - .build(); + return switch (response) { + case BulkEffectiveArgumentResponse.Success s -> + Json.createObjectBuilder() + .add("id", s.goalId().id()) + .add("revision", s.goalId().revision()) + .add("success", JsonValue.TRUE) + .add("arguments", + serializeMap( + ResponseSerializers::serializeArgument, + s.effectiveArguments())) + .build(); + case BulkEffectiveArgumentResponse.TypeFailure tf -> + Json.createObjectBuilder() + .add("id", tf.goalId().id()) + .add("revision", tf.goalId().revision()) + .add("success", JsonValue.FALSE) + .add("errors", "Goal is not procedural") + .build(); + case BulkEffectiveArgumentResponse.InstantiationFailure inf -> + Json.createObjectBuilder(serializeInstantiationException(inf.ex()).asJsonObject()) + .add("id", inf.goalId().id()) + .add("revision", inf.goalId().revision()) + .build(); + case BulkEffectiveArgumentResponse.NoGoalFailure ngf -> + Json.createObjectBuilder() + .add("success", JsonValue.FALSE) + .add("id", ngf.goalId().id()) + .add("revision", ngf.goalId().revision()) + .add("errors", "There is no goal with this id") + .build(); + case BulkEffectiveArgumentResponse.ProcedureLoadFailure plf -> + Json.createObjectBuilder() + .add("success", JsonValue.FALSE) + .add("id", plf.goalId().id()) + .add("revision", plf.goalId().revision()) + .add("errors", "Error when loading the procedure jar") + .build(); + }; } public static JsonValue serializeInstantiationException(final gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException ex) { @@ -238,66 +215,4 @@ public static JsonValue serializeFailedGoals(final List() { - @Override - public JsonValue onString(final String s) { - return Json.createValue(s); - } - - @Override - public JsonValue onInteger(final Integer i) { - return Json.createValue(i); - } - }); - } - - public static JsonValue serializeInvalidJsonException(final InvalidJsonException ex) { - return Json.createObjectBuilder() - .add("kind", "invalid-entity") - .add("message", "invalid json") - .build(); - } - - public static JsonValue serializeInvalidEntityException(final InvalidEntityException ex) { - return Json.createObjectBuilder() - .add("kind", "invalid-entity") - .add("failures", serializeIterable(ResponseSerializers::serializeFailureReason, ex.failures)) - .build(); - } - - public static JsonValue serializeValueSchema(final ValueSchema schema) { - if (schema == null) return JsonValue.NULL; - - return new ValueSchemaJsonParser().unparse(schema); - } } diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/SchedulerBindings.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/SchedulerBindings.java index c217255a8a..e835f647ee 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/SchedulerBindings.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/SchedulerBindings.java @@ -1,9 +1,11 @@ package gov.nasa.jpl.aerie.scheduler.server.http; import javax.json.Json; +import javax.json.JsonException; import javax.json.stream.JsonParsingException; import java.io.IOException; import java.io.StringReader; +import java.sql.SQLException; import java.util.List; import java.util.Objects; @@ -13,22 +15,25 @@ import static gov.nasa.jpl.aerie.scheduler.server.http.SchedulerParsers.hasuraSchedulingGoalEventTriggerP; import static gov.nasa.jpl.aerie.scheduler.server.http.SchedulerParsers.hasuraSpecificationActionP; import static io.javalin.apibuilder.ApiBuilder.*; + +import gov.nasa.jpl.aerie.json.FormattedError; +import gov.nasa.jpl.aerie.json.FormattedError.AerieService; import gov.nasa.jpl.aerie.json.JsonParser; import gov.nasa.jpl.aerie.permissions.HasuraAction; import gov.nasa.jpl.aerie.permissions.PermissionsService; -import gov.nasa.jpl.aerie.permissions.exceptions.ExceptionSerializers; -import gov.nasa.jpl.aerie.permissions.exceptions.NoSuchPlanException; -import gov.nasa.jpl.aerie.permissions.exceptions.NoSuchSchedulingSpecificationException; -import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; -import gov.nasa.jpl.aerie.permissions.exceptions.Forbidden; +import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsException; import gov.nasa.jpl.aerie.permissions.gql.SchedulingSpecificationId; import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchSpecificationException; +import gov.nasa.jpl.aerie.scheduler.server.exceptions.SchedulerFormattedError; +import gov.nasa.jpl.aerie.scheduler.server.remotes.postgres.DatabaseException; import gov.nasa.jpl.aerie.scheduler.server.services.GenerateSchedulingLibAction; import gov.nasa.jpl.aerie.scheduler.server.services.ScheduleAction; import gov.nasa.jpl.aerie.scheduler.server.services.SchedulerService; import gov.nasa.jpl.aerie.scheduler.server.services.SpecificationService; import io.javalin.Javalin; import io.javalin.http.Context; +import io.javalin.http.HttpResponseException; +import io.javalin.http.UnauthorizedResponse; import io.javalin.plugin.Plugin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,7 +60,7 @@ public record SchedulerBindings( Objects.requireNonNull(permissionsService); } - private static final Logger log = LoggerFactory.getLogger(SchedulerBindings.class); + private static final Logger logger = LoggerFactory.getLogger(SchedulerBindings.class); /** * apply all scheduler http bindings to the provided javalin server @@ -64,6 +69,9 @@ public record SchedulerBindings( */ @Override public void apply(final Javalin javalin) { + // Since all of these endpoints are Hasura Actions, toggle Formatted Error writing to Hasura style + FormattedError.FormattedErrorSerializer.USE_HASURA_FORMATTING = true; + javalin.routes(() -> { before(ctx -> ctx.contentType("application/json")); @@ -73,6 +81,55 @@ public void apply(final Javalin javalin) { path("refreshSchedulingProcedureParameterTypes", () -> post(this::refreshSchedulingProcedureParameterTypes)); path("getSchedulingProcedureEffectiveArgumentsBulk", () -> post(this::getSchedulingProcedureEffectiveArgumentsBulk)); }); + + // Default exception handlers for common endpoint exceptions + javalin.exception( + JsonException.class, + (ex, ctx) -> ctx.status(400) + .json(new FormattedError(AerieService.SCHEDULER_SERVER, ex))); + javalin.exception(IOException.class, (ex, ctx) -> { + final var fe = new FormattedError(AerieService.SCHEDULER_SERVER, ex); + logger.warn("IO Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception( + SQLException.class, (ex, ctx) -> { + final var fe = new FormattedError(AerieService.SCHEDULER_SERVER, ex); + logger.warn("SQL Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception( + DatabaseException.class, (ex, ctx) -> { + final var fe = new SchedulerFormattedError(ex); + logger.warn("Database Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception( + UnauthorizedResponse.class, (ex, ctx) -> { + final var message = ex.getMessage() != null ? ex.getMessage() : "Unauthorized"; + logger.warn("401 Unauthorized: {}", message); + ctx.status(401).json(new FormattedError(AerieService.SCHEDULER_SERVER, ex)); + }); + javalin.exception(NumberFormatException.class, (ex, ctx) -> + ctx.status(400).json(new FormattedError(AerieService.SCHEDULER_SERVER, ex))); + javalin.exception(SecurityException.class, (ex, ctx) -> { + final var fe = new FormattedError(AerieService.SCHEDULER_SERVER, ex); + logger.warn("Security Exception: {}", fe); + ctx.status(500).json(fe); + }); + //javalin.exception( + // MissionModelLoader.MissionModelLoadException.class, (ex, ctx) -> + // ctx.status(500).json(new MerlinFormattedError(ex))); + javalin.exception( + HttpResponseException.class, (ex, ctx) -> + ctx.status(ex.getStatus()).json(new FormattedError(AerieService.SCHEDULER_SERVER, "HTTP_RESPONSE_EXCEPTION", ex))); + javalin.exception(Exception.class, (ex, ctx) -> { + // Catch-all for unexpected issues + final var message = ex.getMessage() != null ? ex.getMessage() : "Unknown error."; + final var fe = new FormattedError(AerieService.SCHEDULER_SERVER, "UNKNOWN_ERROR", message, ex); + logger.error("Unexpected error processing request: {}", fe); + ctx.status(500).json(fe); + }); } /** @@ -88,32 +145,23 @@ private void schedule(final Context ctx) { final var session = body.session(); final var permissionsSpecId = new SchedulingSpecificationId(specificationId.id()); - try { - permissionsService.check(HasuraAction.schedule, session.hasuraRole(), session.hasuraUserId(), permissionsSpecId); - } catch (final IOException ex) { - // this IOException is caught here so that it isn't mistaken for an IOException during scheduling - ctx.status(500).result(ExceptionSerializers.serializeIOException(ex).toString()); - } + + permissionsService.check(HasuraAction.schedule, session.hasuraRole(), session.hasuraUserId(), permissionsSpecId); final var response = this.scheduleAction.run(specificationId, session); ctx.result(serializeScheduleResultsResponse(response).toString()); + } catch (final PermissionsException pe) { + if (pe.httpStatusCode() == 500) { + logger.warn("Permissions Service Exception: {}", pe.formattedError()); + } + ctx.status(pe.httpStatusCode()).json(pe.formattedError()); } catch (final IOException e) { - log.error("low level input/output problem during scheduling", e); - ctx.status(500).result(serializeException(e).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(serializeInvalidEntityException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(serializeInvalidJsonException(ex).toString()); + logger.error("IO Exception: ", e); + ctx.status(500).json(new FormattedError(AerieService.SCHEDULER_SERVER, e)); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new SchedulerFormattedError(ex)); } catch (final NoSuchSpecificationException ex) { - ctx.status(404).result(serializeException(ex).toString()); - } catch (final NoSuchPlanException ex) { - ctx.status(404).result(ExceptionSerializers.serializeNoSuchPlanException(ex).toString()); - } catch (final NoSuchSchedulingSpecificationException ex) { - ctx.status(404).result(ExceptionSerializers.serializeNoSuchSchedulingSpecificationException(ex).toString()); - } catch (final PermissionsServiceException ex) { - ctx.status(503).result(ExceptionSerializers.serializePermissionsServiceException(ex).toString()); - } catch (final Forbidden ex) { - ctx.status(403).result(ExceptionSerializers.serializeForbiddenException(ex).toString()); + ctx.status(404).json(new SchedulerFormattedError(ex)); } } @@ -153,10 +201,8 @@ private void getSchedulingDslTypescript(final Context ctx) { throw new Error("Unhandled variant of Response: " + response); } ctx.result(resultString); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(serializeInvalidEntityException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(serializeInvalidJsonException(ex).toString()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new SchedulerFormattedError(ex)); } } @@ -174,10 +220,8 @@ private void refreshSchedulingProcedureParameterTypes(final Context ctx) { final var revision = body.revision(); this.specificationService.refreshSchedulingProcedureParameterTypes(goalId, revision); ctx.status(200); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(serializeInvalidEntityException(ex).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(serializeInvalidJsonException(ex).toString()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new SchedulerFormattedError(ex)); } } @@ -187,10 +231,8 @@ private void getSchedulingProcedureEffectiveArgumentsBulk(final Context ctx) { final var responses = this.specificationService.getSchedulingProcedureEffectiveArguments(input.input().items()); ctx.result(ResponseSerializers.serializeBulkEffectiveArgumentResponseList(responses).toString()); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); + } catch (final InvalidJsonEntityException ex) { + ctx.status(400).json(new SchedulerFormattedError(ex)); } } @@ -201,20 +243,15 @@ private void getSchedulingProcedureEffectiveArgumentsBulk(final Context ctx) { * @param parser the parser to use to convert it to an object * @param the data type of the returned object * @return the object represented by the input json string - * @throws InvalidEntityException if the parser rejects the input json - * @throws InvalidJsonException if the json structure itself is malformed + * @throws InvalidJsonEntityException if the parser rejects the input json */ //TODO: unify these little parser utility methods nearby parser code itself (copied from MerlinBindings) //TODO: elevate these exceptions to json utility itself private T parseJson(final String jsonStr, final JsonParser parser) - throws InvalidJsonException, InvalidEntityException + throws JsonParsingException, InvalidJsonEntityException { - try { - final var requestJson = Json.createReader(new StringReader(jsonStr)).readValue(); - final var result = parser.parse(requestJson); - return result.getSuccessOrThrow(reason -> new InvalidEntityException(List.of(reason))); - } catch (JsonParsingException e) { - throw new InvalidJsonException(e); - } + final var requestJson = Json.createReader(new StringReader(jsonStr)).readValue(); + final var result = parser.parse(requestJson); + return result.getSuccessOrThrow(reason -> new InvalidJsonEntityException(List.of(reason))); } } diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/SchedulerParsers.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/SchedulerParsers.java index ea007836e9..bdd81bc788 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/SchedulerParsers.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/SchedulerParsers.java @@ -150,14 +150,12 @@ private static JsonParser> hasura $ -> tuple($.goalId(), $.revision())); public static T parseJson(final String jsonStr, final JsonParser parser) - throws InvalidJsonException, InvalidEntityException + throws JsonParsingException, InvalidJsonEntityException { try (final var reader = Json.createReader(new StringReader(jsonStr))) { final var requestJson = reader.readValue(); final var result = parser.parse(requestJson); - return result.getSuccessOrThrow(reason -> new InvalidEntityException(List.of(reason))); - } catch (JsonParsingException e) { - throw new InvalidJsonException(e); + return result.getSuccessOrThrow(reason -> new InvalidJsonEntityException(List.of(reason))); } } } diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/remotes/postgres/GetSpecificationGoalsAction.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/remotes/postgres/GetSpecificationGoalsAction.java index 557774fb94..2bb6916777 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/remotes/postgres/GetSpecificationGoalsAction.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/remotes/postgres/GetSpecificationGoalsAction.java @@ -1,8 +1,7 @@ package gov.nasa.jpl.aerie.scheduler.server.remotes.postgres; import gov.nasa.jpl.aerie.merlin.driver.json.SerializedValueJsonParser; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidEntityException; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonException; +import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.scheduler.model.GoalId; import gov.nasa.jpl.aerie.scheduler.server.models.GoalInvocationRecord; import gov.nasa.jpl.aerie.scheduler.server.models.GoalSource; @@ -68,7 +67,7 @@ public List get(final long specificationId) throws SQLExce )); } return goals; - } catch (InvalidJsonException | InvalidEntityException e) { + } catch (InvalidJsonEntityException e) { throw new SQLException(e); } } diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/GraphQLMerlinDatabaseService.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/GraphQLMerlinDatabaseService.java index 1f1a152480..1020e10da6 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/GraphQLMerlinDatabaseService.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/GraphQLMerlinDatabaseService.java @@ -31,8 +31,7 @@ import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.scheduler.server.graphql.GraphQLParsers; import gov.nasa.jpl.aerie.scheduler.server.http.EventGraphFlattener; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidEntityException; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonException; +import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.scheduler.server.models.ActivityAttributesRecord; import gov.nasa.jpl.aerie.scheduler.server.models.ActivityType; import gov.nasa.jpl.aerie.scheduler.server.models.DatasetId; @@ -278,7 +277,7 @@ public PlanMetadata getPlanMetadata(final PlanId planId) final var args = sims.getJsonObject(0).getJsonObject("arguments"); modelConfiguration = BasicParsers .mapP(serializedValueP).parse(args) - .getSuccessOrThrow((reason) -> new InvalidJsonException(new InvalidEntityException(List.of(reason)))); + .getSuccessOrThrow((reason) -> new InvalidJsonEntityException(List.of(reason))); } final var endTime = startTime.toInstant().plusNanos(1000L * duration.in(MICROSECOND)); @@ -293,7 +292,7 @@ public PlanMetadata getPlanMetadata(final PlanId planId) modelName, modelVersion, modelConfiguration); - } catch (ClassCastException | ArithmeticException | InvalidJsonException e) { + } catch (ClassCastException | ArithmeticException | InvalidJsonEntityException e) { //TODO: better error reporting upward to service response (NSPEx doesn't allow passing e as cause) throw new NoSuchPlanException(planId); } @@ -305,7 +304,7 @@ public PlanMetadata getPlanMetadata(final PlanId planId) */ @Override public MerlinPlan getPlanActivityDirectives(final PlanMetadata planMetadata, final Problem problem) - throws IOException, NoSuchPlanException, MerlinServiceException, InvalidJsonException, InstantiationException + throws IOException, NoSuchPlanException, MerlinServiceException, InstantiationException, InvalidJsonEntityException { final var merlinPlan = new MerlinPlan(); final var request = @@ -324,7 +323,7 @@ public MerlinPlan getPlanActivityDirectives(final PlanMetadata planMetadata, fin final var deserializedArguments = BasicParsers .mapP(serializedValueP) .parse(arguments) - .getSuccessOrThrow((reason) -> new InvalidJsonException(new InvalidEntityException(List.of(reason)))); + .getSuccessOrThrow((reason) -> new InvalidJsonEntityException(List.of(reason))); final var effectiveArguments = problem .getActivityType(type) .getSpecType() @@ -964,7 +963,7 @@ public DatasetId storeSimulationResults( } private Map getSimulatedActivities(SimulationDatasetId datasetId, Instant startSimulation) - throws MerlinServiceException, IOException, InvalidJsonException + throws MerlinServiceException, IOException, InvalidJsonEntityException { final var request = """ query{ @@ -1143,7 +1142,7 @@ public ExternalProfiles getExternalProfiles(final PlanId planId) @Override public Map> getExternalEvents(final PlanId planId, final Instant horizonStart) - throws MerlinServiceException, IOException, InvalidEntityException + throws MerlinServiceException, IOException, InvalidJsonEntityException { final var derivationGroupsRequest = """ query DerivationGroupsForPlan($planId: Int!) { @@ -1321,7 +1320,7 @@ private ResourceProfile> parseProfile(JsonObject p } private List parseExternalEvents(final JsonArray eventsJson, final Instant horizonStart) - throws InvalidEntityException + throws InvalidJsonEntityException { final var result = new ArrayList(); for (final var eventJson : eventsJson) { @@ -1333,13 +1332,13 @@ private List parseExternalEvents(final JsonArray eventsJson, fina final var eventAttributes = new SerializedValueJsonParser() .parse(e.getJsonObject("attributes")) - .getSuccessOrThrow(reason -> new InvalidEntityException(List.of(reason))) + .getSuccessOrThrow(reason -> new InvalidJsonEntityException(List.of(reason))) .asMap() .get(); final var sourceAttributes = new SerializedValueJsonParser() .parse(e.getJsonObject("external_source").getJsonObject("attributes")) - .getSuccessOrThrow(reason -> new InvalidEntityException(List.of(reason))) + .getSuccessOrThrow(reason -> new InvalidJsonEntityException(List.of(reason))) .asMap() .get(); @@ -1359,7 +1358,7 @@ private List parseExternalEvents(final JsonArray eventsJson, fina } private Map parseSimulatedActivities(JsonArray simulatedActivitiesArray, Instant simulationStart) - throws InvalidJsonException + throws InvalidJsonEntityException { final var simulatedActivities = new HashMap(); for(final var simulatedActivityJson: simulatedActivitiesArray) { @@ -1379,7 +1378,7 @@ private Map parseSimulatedActivities(JsonA final var deserializedArguments = BasicParsers .mapP(serializedValueP) .parse(activityDirectiveArguments) - .getSuccessOrThrow((reason) -> new InvalidJsonException(new InvalidEntityException(List.of(reason)))); + .getSuccessOrThrow((reason) -> new InvalidJsonEntityException(List.of(reason))); final var activityType = activityDirective.getString("type"); final var simulatedActivity = new ActivityInstance( activityType, diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/MerlinDatabaseService.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/MerlinDatabaseService.java index 65afa79014..8d53bb6580 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/MerlinDatabaseService.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/MerlinDatabaseService.java @@ -11,8 +11,7 @@ import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchActivityInstanceException; import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchMissionModelException; import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchPlanException; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidEntityException; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonException; +import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.scheduler.server.models.ActivityType; import gov.nasa.jpl.aerie.scheduler.server.models.DatasetId; import gov.nasa.jpl.aerie.scheduler.server.models.ExternalProfiles; @@ -72,7 +71,7 @@ PlanMetadata getPlanMetadata(final PlanId planId) * @throws NoSuchPlanException when the plan container does not exist in aerie */ MerlinPlan getPlanActivityDirectives(final PlanMetadata planMetadata, final Problem mission) - throws IOException, NoSuchPlanException, MerlinServiceException, InvalidJsonException, InstantiationException; + throws IOException, NoSuchPlanException, MerlinServiceException, InvalidJsonEntityException, InstantiationException; /** * confirms that the specified plan exists in the aerie database, throwing exception if not @@ -91,7 +90,7 @@ void ensurePlanExists(final PlanId planId) * @param planMetadata the plan metadata * @return optionally: simulation results and its dataset id */ - Optional> getSimulationResults(PlanMetadata planMetadata) throws MerlinServiceException, IOException, InvalidJsonException; + Optional> getSimulationResults(PlanMetadata planMetadata) throws MerlinServiceException, IOException; /** @@ -104,7 +103,7 @@ ExternalProfiles getExternalProfiles(final PlanId planId) throws MerlinServiceException, IOException; Map> getExternalEvents(final PlanId planId, final Instant horizonStart) - throws MerlinServiceException, IOException, InvalidEntityException; + throws MerlinServiceException, IOException, InvalidJsonEntityException; /** * Gets resource types associated to a plan, those coming from the mission model as well as those coming from external dataset resources diff --git a/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/SchedulerWorkerAppDriver.java b/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/SchedulerWorkerAppDriver.java index 274d0c297e..8ce2b65e79 100644 --- a/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/SchedulerWorkerAppDriver.java +++ b/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/SchedulerWorkerAppDriver.java @@ -9,6 +9,7 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import gov.nasa.jpl.aerie.json.FormattedError; import gov.nasa.jpl.aerie.scheduler.server.ResultsProtocol; import gov.nasa.jpl.aerie.scheduler.server.config.PlanOutputMode; import gov.nasa.jpl.aerie.scheduler.server.config.PostgresStore; @@ -108,11 +109,17 @@ public static void main(String[] args) throws Exception { canceledListener, config.maxCachedSimulationEngines()); } catch (final Throwable ex) { - ex.printStackTrace(System.err); + final var formattedError = new FormattedError( + FormattedError.AerieService.SCHEDULER_WORKER, + "UNEXPECTED_SCHEDULER_EXCEPTION", + "Something went wrong while scheduling", + ex); writer.failWith(b -> b - .type("UNEXPECTED_SCHEDULER_EXCEPTION") - .message("Something went wrong while scheduling") + .type(formattedError.getType()) + .message(formattedError.getMessage()) + .data(formattedError.toJson()) .trace(ex)); + ex.printStackTrace(System.err); } finally { canceledListener.unregister(); diff --git a/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationService.java b/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationService.java index b53a6a9703..0a338333ee 100644 --- a/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationService.java +++ b/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationService.java @@ -1,8 +1,7 @@ package gov.nasa.jpl.aerie.scheduler.worker.services; import gov.nasa.jpl.aerie.json.JsonParser; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidEntityException; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonException; +import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.scheduler.server.models.PlanId; import gov.nasa.jpl.aerie.scheduler.server.models.ResourceType; import gov.nasa.jpl.aerie.scheduler.server.models.SchedulingCompilationError; @@ -130,9 +129,7 @@ private SchedulingDSLCompilationResult compile( yield new SchedulingDSLCompilationResult.Error<>(parseJson( output, SchedulingCompilationError.schedulingErrorJsonP)); - } catch (InvalidJsonException e) { - throw new Error("Could not parse JSON returned from typescript: ", e); - } catch (InvalidEntityException e) { + } catch (InvalidJsonEntityException e) { throw new Error("Could not parse JSON returned from typescript: " + e.failures + "\n" + output); } } @@ -140,9 +137,7 @@ private SchedulingDSLCompilationResult compile( final var output = outputReader.readLine(); try { yield new SchedulingDSLCompilationResult.Success<>(parseJson(output, parser)); - } catch (InvalidJsonException e) { - throw new Error("Could not parse JSON returned from typescript: " + output, e); - } catch (InvalidEntityException e) { + } catch (InvalidJsonEntityException e) { throw new Error("Could not parse JSON returned from typescript: " + e.failures + "\n" + output, e); } } @@ -154,14 +149,12 @@ private SchedulingDSLCompilationResult compile( } private static T parseJson(final String jsonStr, final JsonParser parser) - throws InvalidJsonException, InvalidEntityException + throws JsonParsingException, InvalidJsonEntityException { try (final var reader = Json.createReader(new StringReader(jsonStr))) { final var requestJson = reader.readValue(); final var result = parser.parse(requestJson); - return result.getSuccessOrThrow(reason -> new InvalidEntityException(List.of(reason))); - } catch (JsonParsingException e) { - throw new InvalidJsonException(e); + return result.getSuccessOrThrow(reason -> new InvalidJsonEntityException(List.of(reason))); } } diff --git a/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/services/SynchronousSchedulerAgent.java b/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/services/SynchronousSchedulerAgent.java index a180d46741..f9e9af1d74 100644 --- a/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/services/SynchronousSchedulerAgent.java +++ b/scheduler-worker/src/main/java/gov/nasa/jpl/aerie/scheduler/worker/services/SynchronousSchedulerAgent.java @@ -22,6 +22,8 @@ import java.util.stream.Collectors; import gov.nasa.ammos.aerie.procedural.timeline.payloads.ExternalEvent; +import gov.nasa.jpl.aerie.json.FormattedError; +import gov.nasa.jpl.aerie.json.FormattedError.AerieService; import gov.nasa.jpl.aerie.merlin.driver.MissionModel; import gov.nasa.jpl.aerie.merlin.driver.MissionModelLoader; import gov.nasa.jpl.aerie.merlin.driver.SimulationEngineConfiguration; @@ -45,9 +47,9 @@ import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchPlanException; import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchSpecificationException; import gov.nasa.jpl.aerie.scheduler.server.exceptions.ResultsProtocolFailure; +import gov.nasa.jpl.aerie.scheduler.server.exceptions.SchedulerFormattedError; import gov.nasa.jpl.aerie.scheduler.server.exceptions.SpecificationLoadException; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidEntityException; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonException; +import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonEntityException; import gov.nasa.jpl.aerie.scheduler.server.http.ResponseSerializers; import gov.nasa.jpl.aerie.scheduler.server.models.DatasetId; import gov.nasa.jpl.aerie.scheduler.server.models.ExternalProfiles; @@ -291,10 +293,11 @@ public void schedule( LOGGER.info("Simulation cache saved " + cachedEngineStore.getTotalSavedSimulationTime() + " in simulation time"); writer.succeedWith(results, datasetId); } catch (final SpecificationLoadException e) { + final var fe = new SchedulerFormattedError(e); writer.failWith(b -> b - .type("SPECIFICATION_LOAD_EXCEPTION") - .message(e.toString()) - .data(SchedulingCompilationError.schedulingErrorJsonP.unparse(e.errors)) + .type(fe.getType()) + .message(fe.getMessage()) + .data(fe.toJson()) .trace(e)); } catch (final ResultsProtocolFailure e) { writer.failWith(b -> b @@ -302,33 +305,41 @@ public void schedule( .message(e.toString()) .trace(e)); } catch (final NoSuchSpecificationException e) { + final var fe = new SchedulerFormattedError(e); writer.failWith(b -> b - .type("NO_SUCH_SPECIFICATION") - .message(e.toString()) - .data(ResponseSerializers.serializeNoSuchSpecificationException(e)) + .type(fe.getType()) + .message(fe.getMessage()) + .data(fe.toJson()) .trace(e)); } catch (final NoSuchPlanException e) { + final var fe = new SchedulerFormattedError(e); writer.failWith(b -> b - .type("NO_SUCH_PLAN") - .message(e.toString()) - .data(ResponseSerializers.serializeNoSuchPlanException(e)) + .type(fe.getType()) + .message(fe.getMessage()) + .data(fe.toJson()) .trace(e)); } catch (final MerlinServiceException e) { + final var fe = new SchedulerFormattedError(e); writer.failWith(b -> b - .type("PLAN_SERVICE_EXCEPTION") - .message(e.toString()) + .type(fe.getType()) + .message(fe.getMessage()) + .data(fe.toJson()) .trace(e)); } catch (final IOException e) { + final var fe = new FormattedError(AerieService.SCHEDULER_SERVER, e); writer.failWith(b -> b - .type("IO_EXCEPTION") - .message(e.toString()) + .type(fe.getType()) + .message(fe.getMessage()) + .data(fe.toJson()) .trace(e)); } catch (SchedulingInterruptedException e) { writer.reportCanceled(e); } catch (Exception e) { + final var fe = new FormattedError(AerieService.SCHEDULER_SERVER, "INTERNAL_ERROR", e); writer.failWith(b -> b - .type("OTHER_EXCEPTION") - .message(e.toString()) + .type(fe.getType()) + .message(fe.getMessage()) + .data(fe.toJson()) .trace(e)); } } @@ -336,7 +347,7 @@ public void schedule( private Optional> loadSimulationResults(final PlanMetadata planMetadata){ try { return merlinDatabaseService.getSimulationResults(planMetadata); - } catch (MerlinServiceException | IOException | InvalidJsonException e) { + } catch (MerlinServiceException | IOException e) { throw new ResultsProtocolFailure(e); } } @@ -348,7 +359,7 @@ private ExternalProfiles loadExternalProfiles(final PlanId planId) } private Map> loadExternalEvents(final PlanId planId, final Instant horizonStart) - throws MerlinServiceException, IOException, InvalidJsonException, InvalidEntityException + throws MerlinServiceException, IOException, InvalidJsonEntityException { return merlinDatabaseService.getExternalEvents(planId, horizonStart); } diff --git a/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationServiceTests.java b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationServiceTests.java index 8198d1058d..d56217d4f9 100644 --- a/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationServiceTests.java +++ b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationServiceTests.java @@ -25,15 +25,12 @@ import gov.nasa.jpl.aerie.constraints.tree.WindowsFromSpans; import gov.nasa.jpl.aerie.merlin.driver.SimulationResults; import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; -import gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException; import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; import gov.nasa.jpl.aerie.scheduler.TimeUtility; import gov.nasa.jpl.aerie.scheduler.constraints.timeexpressions.TimeAnchor; import gov.nasa.jpl.aerie.scheduler.model.GoalId; import gov.nasa.jpl.aerie.scheduler.model.PersistentTimeAnchor; import gov.nasa.jpl.aerie.scheduler.model.Problem; -import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchPlanException; -import gov.nasa.jpl.aerie.scheduler.server.http.InvalidJsonException; import gov.nasa.jpl.aerie.scheduler.server.models.DatasetId; import gov.nasa.jpl.aerie.scheduler.server.models.ExternalProfiles; import gov.nasa.jpl.aerie.scheduler.server.models.MerlinPlan; @@ -42,7 +39,6 @@ import gov.nasa.jpl.aerie.scheduler.server.models.ResourceType; import gov.nasa.jpl.aerie.scheduler.server.models.SchedulingDSL; import gov.nasa.jpl.aerie.scheduler.server.services.MerlinDatabaseService; -import gov.nasa.jpl.aerie.scheduler.server.services.MerlinServiceException; import gov.nasa.jpl.aerie.types.ActivityDirectiveId; import gov.nasa.jpl.aerie.types.MissionModelId; import org.apache.commons.lang3.tuple.Pair; @@ -115,7 +111,6 @@ public ExternalProfiles getExternalProfiles(final PlanId planId) { @Override public Map> getExternalEvents(final PlanId planId, final Instant horizonStart) - throws MerlinServiceException, IOException { return Map.of(); } @@ -127,7 +122,6 @@ public Collection getResourceTypes(final PlanId planId) { @Override public Map getActivityIdToGoalIdMap(final PlanId planId) - throws MerlinServiceException, IOException { return Map.of(); } diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java deleted file mode 100644 index b005c7f3ec..0000000000 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java +++ /dev/null @@ -1,266 +0,0 @@ -package gov.nasa.jpl.aerie.workspace.server; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import gov.nasa.jpl.aerie.permissions.exceptions.Forbidden; -import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; -import gov.nasa.jpl.aerie.workspace.server.exceptions.FileLockedException; -import gov.nasa.jpl.aerie.workspace.server.exceptions.MalformedRequest; -import gov.nasa.jpl.aerie.workspace.server.exceptions.NoSuchFileException; -import gov.nasa.jpl.aerie.workspace.server.exceptions.WorkspaceFileOpException; -import gov.nasa.jpl.aerie.workspace.server.postgres.NoSuchWorkspaceException; -import io.javalin.http.UnauthorizedResponse; -import io.javalin.validation.ValidationException; - -import javax.json.Json; -import javax.json.JsonException; -import javax.json.JsonObject; -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.sql.SQLException; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Optional; - -/** - * Class for formatting exceptions thrown during in Workspaces into JSON objects - * that meet the Aerie HTTP endpoint error message format - * Relevant ticket going over said format: https://github.com/NASA-AMMOS/plandev/issues/1732 - */ -@JsonSerialize(using = FormattedError.FormattedErrorSerializer.class) -public final class FormattedError { - private final String type; - private final String message; - private final String timestamp = ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT); - private final static String service = "aerie_workspace"; - private Optional cause = Optional.empty(); - private Optional trace = Optional.empty(); - private Optional data = Optional.empty(); - - /** - * For use in the event of an endpoint failing without throwing an exception. - * i.e. Workspace's delete file endpoint failing because java.nio.File#deleteFile returned "false" - */ - public FormattedError(String message) { - this.type = "INTERNAL_ERROR"; - this.message = message; - } - - /** - * For use in the event of an endpoint failing without throwing an exception, but where there's a more detailed cause. - */ - public FormattedError(String message, String cause) { - this.type = "INTERNAL_ERROR"; - this.message = message; - this.cause = Optional.ofNullable(cause); - } - - /** - * For use in the event of an endpoint failing without throwing an exception, - * but "INTERNAL_ERROR" does not make sense as the error type (i.e. the request is malformed) - */ - public FormattedError(String type, String message, Optional cause) { - this.type = type; - this.message = message; - this.cause = cause; - } - - /** - * Create a FormattedException from a generic Exception object. - * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE - * @param ex the exception to be formatted. - */ - public FormattedError(String type, Exception ex) { - this.type = type; - message = ex.getMessage() == null ? "No exception message provided." : ex.getMessage(); - trace = Optional.of(generateTrace(ex)); - } - - /** - * Create a FormattedException from a generic Exception object with a custom error message. - * The exception's built-in error message, if included, will be put into the 'cause' field. - * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE - * @param message the custom error message explaining the cause of the error. - * Should be human-readable and between 1-2 sentences. - * @param ex the exception to be formatted. - */ - public FormattedError(String type, String message, Exception ex) { - this.type = type; - this.message = message; - cause = Optional.ofNullable(ex.getMessage()); - trace = Optional.of(generateTrace(ex)); - } - - // region Constructors for specific exceptions - // This helps `type` be consistent every time the exception is thrown. - // NoSuchWorkspace - public FormattedError(NoSuchWorkspaceException nse) { - this("NO_SUCH_WORKSPACE", nse); - } - public FormattedError(NoSuchWorkspaceException nse, String message) { - this("NO_SUCH_WORKSPACE", message, nse); - } - - public FormattedError(gov.nasa.jpl.aerie.permissions.exceptions.NoSuchWorkspaceException nse, String message) { - this("NO_SUCH_WORKSPACE", message, nse); - } - - // NoSuchFile - public FormattedError(NoSuchFileException nsf) { - this("NO_SUCH_FILE", nsf.getMessage(), nsf); - } - public FormattedError(NoSuchFileException nsf, String message) { - this("NO_SUCH_FILE", message, nsf); - } - - // IOException - public FormattedError(IOException nse) { - this("IO_EXCEPTION", nse); - } - public FormattedError(IOException nse, String message) { - this("IO_EXCEPTION", message, nse); - } - - // SQLException - public FormattedError(SQLException se) { - this("SQL_EXCEPTION", se); - } - public FormattedError(SQLException se, String message) { - this("SQL_EXCEPTION", message, se); - } - - // WorkspaceFileOpException - public FormattedError(WorkspaceFileOpException wfe) { - this("FILE_OPERATION_EXCEPTION", wfe); - } - public FormattedError(WorkspaceFileOpException wfe, String message) { - this("FILE_OPERATION_EXCEPTION", message, wfe); - } - - // Forbidden - public FormattedError(Forbidden ue) { - this("FORBIDDEN", ue); - } - - // Unauthorized - public FormattedError(UnauthorizedResponse ue) { - this.type = "UNAUTHORIZED"; - this.message = ue.getMessage() != null ? ue.getMessage() : "Unauthorized"; - // Include additional details, if present - if(!ue.getDetails().isEmpty()) { - final var dataBuilder = Json.createObjectBuilder(); - ue.getDetails().forEach(dataBuilder::add); - this.data = Optional.of(dataBuilder.build()); - } - } - - // PermissionsServiceException - public FormattedError(PermissionsServiceException pse, String message) { - this("PERMISSIONS_SERVICE_EXCEPTION", message, pse); - } - - // NumberFormatException - public FormattedError(NumberFormatException nfe) { - this("NUMBER_PARSING_EXCEPTION", nfe); - } - - // IllegalArgumentException - public FormattedError(IllegalArgumentException iae) { - this("ILLEGAL_ARGUMENT", iae); - } - - // JSONException - public FormattedError(JsonException je, String message){ - this("JSON_PARSING_EXCEPTION", message, je); - } - - // ValidationException - public FormattedError(ValidationException ve) { - this.type = "ENDPOINT_VALIDATION_EXCEPTION"; - this.message = ve.getMessage() != null ? ve.getMessage() : "Invalid request"; - trace = Optional.of(generateTrace(ve)); - } - - // Null Pointer Exception - public FormattedError(NullPointerException ne, String message) { - this("NULL_POINTER_EXCEPTION", message, ne); - } - - // Security Exception - public FormattedError(SecurityException se) { - this("SECURITY_EXCEPTION", se.getMessage(), se); - } - - // Malformed Request - public FormattedError(MalformedRequest mr) { - this.type = "MALFORMED_REQUEST"; - this.message = mr.getMessage(); - this.cause = mr.getDetails(); - this.trace = Optional.of(generateTrace(mr)); - } - - // Locked File - public FormattedError(FileLockedException fle) { - this("FILE_LOCKED", fle); - } - - public FormattedError(FileLockedException fle, String message) { - this("FILE_LOCKED", message, fle); - } - //endregion - - /** - * Generate a stack trace string from an Exception. - */ - private String generateTrace(Exception ex) { - final var sw = new StringWriter(); - try(final var pw = new PrintWriter(sw)) { - ex.printStackTrace(pw); - } - return sw.toString(); - } - - /** - * Export this object to a JsonObject. - */ - public JsonObject toJson() { - // Include all mandatory fields - final var builder = Json.createObjectBuilder() - .add("type", type) - .add("message", message) - .add("timestamp", timestamp) - .add("service", service); // Not mandatory on spec, but always known - - // Include optional fields, if present - cause.ifPresent((c) -> builder.add("cause", c)); - trace.ifPresent((t) -> builder.add("trace", t)); - data.ifPresent((d) -> builder.add("data", d)); - - return builder.build(); - } - - @Override - public String toString() { - return this.toJson().toString(); - } - - /** - * Internal class so that Javalin serializes the FormattedError class using its `toJson` method. - * This avoids needing to call `toJson` every time the FormattedError class is used as an endpoint return. - * The class implements Jackson's JsonSerializer in specific because Javalin uses Jackson as its default JSON Mapper. - */ - static final class FormattedErrorSerializer extends JsonSerializer { - @Override - public void serialize( - final FormattedError formattedError, - final JsonGenerator jsonGenerator, - final SerializerProvider serializerProvider) throws IOException - { - jsonGenerator.writeRaw(formattedError.toString()); - } - } -} diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java index 583006163a..dbf0355115 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java @@ -1,10 +1,11 @@ package gov.nasa.jpl.aerie.workspace.server; import com.auth0.jwt.exceptions.JWTVerificationException; +import gov.nasa.jpl.aerie.json.FormattedError; +import gov.nasa.jpl.aerie.json.FormattedError.AerieService; import gov.nasa.jpl.aerie.permissions.PermissionsService; import gov.nasa.jpl.aerie.permissions.WorkspaceAction; -import gov.nasa.jpl.aerie.permissions.exceptions.Forbidden; -import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; +import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsException; import gov.nasa.jpl.aerie.permissions.gql.WorkspaceId; import gov.nasa.jpl.aerie.workspace.server.exceptions.FileLockedException; import gov.nasa.jpl.aerie.workspace.server.exceptions.MalformedRequest; @@ -91,6 +92,9 @@ String metadataFileName() { @Override public void apply(final Javalin javalin) { + // Since none of these endpoints are Hasura Actions, ensure that Formatted Errors are not using the Hasura style + FormattedError.FormattedErrorSerializer.USE_HASURA_FORMATTING = false; + javalin.routes(() -> { before("/ws/*", ctx -> { // don't force auth on health check @@ -144,38 +148,39 @@ public void apply(final Javalin javalin) { // Default exception handlers for common endpoint exceptions javalin.exception(NoSuchWorkspaceException.class, - (ex, ctx) -> ctx.status(404).json(new FormattedError(ex))); - javalin.exception(NoSuchFileException.class, (ex, ctx) -> ctx.status(404).json(new FormattedError(ex))); - javalin.exception(MalformedRequest.class, (ex, ctx) -> ctx.status(400).json(new FormattedError(ex))); - javalin.exception(FileLockedException.class, (ex, ctx) -> ctx.status(423).json(new FormattedError(ex))); + (ex, ctx) -> ctx.status(404).json(new WorkspaceFormattedError(ex))); + javalin.exception(NoSuchFileException.class, (ex, ctx) -> ctx.status(404).json(new WorkspaceFormattedError(ex))); + javalin.exception(MalformedRequest.class, (ex, ctx) -> ctx.status(400).json(new WorkspaceFormattedError(ex))); + javalin.exception(FileLockedException.class, (ex, ctx) -> ctx.status(423).json(new WorkspaceFormattedError(ex))); javalin.exception(IOException.class, (ex, ctx) -> { - final var fe = new FormattedError(ex); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ex); logger.warn("IO Exception: {}", fe); ctx.status(500).json(fe); }); javalin.exception(SQLException.class, (ex, ctx) -> { - final var fe = new FormattedError(ex); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ex); logger.warn("SQL Exception: {}", fe); ctx.status(500).json(fe); }); javalin.exception(UnauthorizedResponse.class, (ex, ctx) -> { final var message = ex.getMessage() != null ? ex.getMessage() : "Unauthorized"; logger.warn("401 Unauthorized: {}", message); - ctx.status(401).json(new FormattedError(ex)); + ctx.status(401).json(new FormattedError(AerieService.WORKSPACE_SERVER, ex)); }); - javalin.exception(NumberFormatException.class, - (ex, ctx) -> ctx.status(400).json(new FormattedError(ex))); + javalin.exception(NumberFormatException.class, (ex, ctx) -> + ctx.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, ex))); javalin.exception(SecurityException.class, (ex, ctx) -> { - final var fe = new FormattedError(ex); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ex); logger.warn("Security Exception: {}", fe); ctx.status(500).json(fe); }); - javalin.exception(HttpResponseException.class, (ex, ctx) -> ctx.status(ex.getStatus()).json(new FormattedError("HTTP_RESPONSE_EXCEPTION", ex))); + javalin.exception(HttpResponseException.class, (ex, ctx) -> + ctx.status(ex.getStatus()).json(new FormattedError(AerieService.WORKSPACE_SERVER, "HTTP_RESPONSE_EXCEPTION", ex))); javalin.exception(Exception.class, (ex, ctx) -> { // Catch-all for unexpected issues final var message = ex.getMessage() != null ? ex.getMessage() : "Unknown error."; - final var fe = new FormattedError("UNKNOWN_ERROR", message, ex); - logger.error("Unexpected error processing workspace request {}", fe); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, "UNKNOWN_ERROR", message, ex); + logger.error("Unexpected error processing request: {}", fe); ctx.status(500).json(fe); }); } @@ -229,21 +234,11 @@ private boolean checkPermissions(Context context, int workspaceId, WorkspaceActi user.userId(), new WorkspaceId(workspaceId)); return true; - } catch (Forbidden ue) { - context.status(403).json(new FormattedError(ue)); - return false; - } catch (IOException ioe) { - final var fe = new FormattedError(ioe, "Could not check permissions."); - logger.warn("PERMISSIONS SERVICE: IO Exception: {}", fe); - context.status(500).json(fe); - return false; - } catch (PermissionsServiceException pse) { - final var fe = new FormattedError(pse, "Could not check permissions."); - logger.warn("PERMISSIONS SERVICE: Permissions Service Exception: {}", fe); - context.status(500).json(new FormattedError(pse, "Could not check permissions.")); - return false; - } catch (gov.nasa.jpl.aerie.permissions.exceptions.NoSuchWorkspaceException nsw) { - context.status(404).json(new FormattedError(nsw, "Could not check permissions on Workspace %d.".formatted(nsw.id.id()))); + } catch (PermissionsException pe) { + if (pe.httpStatusCode() == 500) { + logger.warn("PERMISSIONS SERVICE: Permissions Service Exception: {}", pe.formattedError()); + } + context.status(pe.httpStatusCode()).json(pe.formattedError()); return false; } } @@ -255,18 +250,11 @@ private void createWorkspace(Context context) { try { final var user = authorize(context); permissionsService.checkCoarseGrained(WorkspaceAction.create_workspace, user.activeRole()); - } catch (Forbidden ue) { - context.status(403).json(new FormattedError(ue)); - return; - } catch (IOException ioe) { - final var fe = new FormattedError(ioe, "Could not create workspace."); - logger.warn("CREATE WORKSPACE: IO Exception: {}", fe); - context.status(500).json(fe); - return; - } catch (PermissionsServiceException pse) { - final var fe = new FormattedError(pse, "Could not create workspace."); - logger.warn("Permissions Service Exception: {}", fe); - context.status(500).json(fe); + } catch (PermissionsException pe) { + if (pe.httpStatusCode() == 500) { + logger.warn("CREATE WORKSPACE: Permissions Service Exception: {}", pe.formattedError()); + } + context.status(pe.httpStatusCode()).json(pe.formattedError()); return; } @@ -289,23 +277,23 @@ private void createWorkspace(Context context) { // Parcel Id if (!bodyJson.containsKey("parcelId") || bodyJson.isNull("parcelId")) { - context.status(400).json(new FormattedError(errorMsg.formatted("parcelId"))); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg.formatted("parcelId"))); return; } parcelId = bodyJson.getInt("parcelId"); // Workspace Location if (!bodyJson.containsKey("workspaceLocation") || bodyJson.isNull("workspaceLocation")) { - context.status(400).json(new FormattedError(errorMsg.formatted("workspaceLocation"))); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg.formatted("workspaceLocation"))); return; } final var workspaceString = bodyJson.getString("workspaceLocation"); if(workspaceString.contains("/") || workspaceString.contains(".") || workspaceString.contains("~")){ - context.status(400).json(new FormattedError("Workspace location may not contain '/' or '.' or '~'")); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Workspace location may not contain '/' or '.' or '~'")); return; } if(workspaceString.isBlank()) { - context.status(400).json(new FormattedError("Workspace location may not be blank.")); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Workspace location may not be blank.")); return; } workspaceLocation = Path.of(workspaceString); @@ -313,17 +301,17 @@ private void createWorkspace(Context context) { // Workspace Name if(bodyJson.containsKey("workspaceName")) { if(bodyJson.isNull("workspaceName")) { - context.status(400).json(new FormattedError("Workspace name may not be null.")); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Workspace name may not be null.")); } workspaceName = bodyJson.getString("workspaceName"); if(workspaceName.isBlank()) { - context.status(400).json(new FormattedError("Workspace name may not be blank")); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Workspace name may not be blank")); } } else { workspaceName = workspaceString; } } catch (JsonException je) { - context.status(400).json(new FormattedError(je, "Request body is malformed. Request body format is:\n" + helpText)); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, je, "Request body is malformed. Request body format is:\n" + helpText)); return; } @@ -343,7 +331,7 @@ private void createWorkspace(Context context) { \tParcel ID: {}, \tUser: {} (active role: {}) """, workspaceLocation, workspaceName, parcelId, user.userId(), user.activeRole()); - context.status(500).json(new FormattedError("Unable to create workspace.")); + context.status(500).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Unable to create workspace.")); } } @@ -360,12 +348,12 @@ private void deleteWorkspace(Context context) { context.status(200).result("Workspace deleted."); } else { logger.warn(errorMsg); - context.status(500).json(new FormattedError(errorMsg)); + context.status(500).json(new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg)); } } catch (NoSuchWorkspaceException ex) { - context.status(404).json(new FormattedError(ex, errorMsg)); + context.status(404).json(new WorkspaceFormattedError(ex, errorMsg)); } catch (SQLException e) { - final var fe = new FormattedError(e, errorMsg); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, e, errorMsg); logger.warn("DELETE WORKSPACE: SQL Exception: {}", fe); context.status(500).json(fe); } @@ -398,20 +386,20 @@ private void listContents(Context context) { try { final var fileTree = workspaceService.listFiles(workspaceId, directoryPath, depth, withMetadata); if (fileTree == null) { - context.status(404).json(new FormattedError("No such directory.")); + context.status(404).json(new FormattedError(AerieService.WORKSPACE_SERVER, "No such directory.")); return; } context.status(200).json(fileTree.toJson().toString()); } catch (IOException ioe) { - final var fe = new FormattedError(ioe); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ioe); logger.warn("LIST CONTENTS: IO Exception: {}", fe); context.status(500).json(fe); } catch (SQLException se) { - final var fe = new FormattedError(se); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, se); logger.warn("LIST CONTENTS: SQL Exception: {}", fe); context.status(500).json(fe); } catch (NoSuchWorkspaceException ex) { - context.status(404).json(new FormattedError(ex)); + context.status(404).json(new WorkspaceFormattedError(ex)); } } @@ -426,7 +414,7 @@ private void getFileDirectory(Context context) throws NoSuchWorkspaceException { listContents(context); } else { if (!workspaceService.checkFileExists(pathInfo.workspaceId, pathInfo.filePath)) { - context.status(404).json(new FormattedError(new NoSuchFileException(pathInfo.workspaceId, pathInfo.filePath))); + context.status(404).json(new WorkspaceFormattedError(new NoSuchFileException(pathInfo.workspaceId, pathInfo.filePath))); return; } @@ -438,11 +426,11 @@ private void getFileDirectory(Context context) throws NoSuchWorkspaceException { context.header("Content-Disposition", "attachment; filename=\"" + pathInfo.fileName() + "\""); context.status(200).result(inputStream); } catch (IOException ioe) { - final var fe = new FormattedError(ioe, "Could not load file " + pathInfo.fileName()); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ioe, "Could not load file " + pathInfo.fileName()); logger.warn("GET FILE: IO Exception: {}", fe); context.status(500).json(fe); } catch (SQLException se) { - final var fe = new FormattedError(se, "Could not load file " + pathInfo.fileName()); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, se, "Could not load file " + pathInfo.fileName()); logger.warn("GET FILE: SQL Exception: {}", fe); context.status(500).json(fe); } @@ -469,10 +457,10 @@ private void createFileDirectory(Context context) { final var overwriteValidator = context.queryParamAsClass("overwrite", Boolean.class); overwrite = overwriteValidator.hasValue() ? Optional.of(overwriteValidator.get()) : Optional.empty(); } catch (ValidationException ve) { - context.status(400).json(new FormattedError(ve)); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, ve)); return; } catch (IllegalArgumentException iae) { - context.status(400).json(new FormattedError(iae)); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, iae)); return; } @@ -481,7 +469,7 @@ private void createFileDirectory(Context context) { final var file = context.uploadedFile("file"); // Reject the request if the file isn't provided. if (file == null || !pathInfo.fileName().equals(file.filename())) { - context.status(400).json(new FormattedError("No file provided with the name " + pathInfo.fileName())); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, "No file provided with the name " + pathInfo.fileName())); return; } @@ -495,12 +483,12 @@ private void createFileDirectory(Context context) { } else if (type == ItemType.directory) { // Reject the request if the "overwrite" flag is supplied if(overwrite.isPresent()) { - context.status(400).json(new FormattedError("Query parameter 'overwrite' is not permitted when creating a directory.")); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Query parameter 'overwrite' is not permitted when creating a directory.")); return; } uploadResults = handleCreateDirectory(pathInfo.workspaceId(), pathInfo.filePath()); } else { - context.status(400).json(new FormattedError("Query param 'type' has invalid value "+type)); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Query param 'type' has invalid value "+type)); return; } @@ -541,12 +529,12 @@ private void post(Context context) throws NoSuchWorkspaceException { // Get body if(!ContentType.JSON.equals(context.contentType())) { - context.status(400).json(new FormattedError("Body must be type "+ContentType.JSON)); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Body must be type "+ContentType.JSON)); } try(final var bodyReader = Json.createReader(new StringReader(context.body()))){ body = PostBody.fromJson(bodyReader.readObject(), sourceWorkspace); } catch (JsonException je) { - context.status(400).json(new FormattedError( + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, je, "Invalid body format. Expected body format is an array of JSON objects with the form:\n\n"+helpText)); return; @@ -594,7 +582,7 @@ && checkPermissions(context, body.destinationWorkspaceId(), WorkspaceAction.writ case HandlerResult.Failure failure -> context.status(failure.status()).json(failure.error()); } } - default -> context.status(501).json(new FormattedError("Unsupported post action: " + body.action().name()).toJson()); + default -> context.status(501).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Unsupported post action: " + body.action().name()).toJson()); } } @@ -626,7 +614,7 @@ private HandlerResult handleFileUpload( if(RenderType.isAerieMetadataFile(uploadPath.getFileName().toString())) { return new HandlerResult.Failure( 405, - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest("Could not save file.", "Metadata files may not be uploaded via the file API." + " Use the metadata API (located at /metadata/{workspaceId}/) instead."))); @@ -635,12 +623,12 @@ private HandlerResult handleFileUpload( // Report a "Conflict" status if the file already exists and "overwrite" is false // "overwrite" defaults to "false" if unspecified if (workspaceService.checkFileExists(workspaceId, uploadPath) && !overwrite) { - return new HandlerResult.Failure(409, new FormattedError(uploadPath + " already exists.")); + return new HandlerResult.Failure(409, new FormattedError(AerieService.WORKSPACE_SERVER, uploadPath + " already exists.")); } // Report a "Locked" status if the file is currently marked as "readOnly" if(workspaceService.isReadOnly(workspaceId, uploadPath)) { - return new HandlerResult.Failure(423, new FormattedError(new FileLockedException(uploadPath), "Cannot update file at " + uploadPath)); + return new HandlerResult.Failure(423, new WorkspaceFormattedError(new FileLockedException(uploadPath), "Cannot update file at " + uploadPath)); } if (workspaceService.saveFile(workspaceId, uploadPath, file, userId)) { @@ -649,18 +637,18 @@ private HandlerResult handleFileUpload( "File " + uploadPath.getFileName() + " uploaded to " + uploadPath); } else { logger.warn("UPLOAD FILE: Save File failed for path {}", uploadPath); - return new HandlerResult.Failure(500, new FormattedError("Could not save file.")); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, "Could not save file.")); } } catch (IOException ioe) { - final var fe = new FormattedError(ioe, "Could not save file."); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ioe, "Could not save file."); logger.warn("UPLOAD FILE: IOException: {}", fe); return new HandlerResult.Failure(500, fe); } catch (WorkspaceFileOpException wfe) { - final var fe = new FormattedError(wfe, "Could not save file."); + final var fe = new WorkspaceFormattedError(wfe, "Could not save file."); logger.warn("UPLOAD FILE: WorkspaceFileOpException: {}", fe); return new HandlerResult.Failure(500, fe); } catch (NoSuchWorkspaceException nsw) { - return new HandlerResult.Failure(404, new FormattedError(nsw, "Could not create directory.")); + return new HandlerResult.Failure(404, new WorkspaceFormattedError(nsw, "Could not create directory.")); } } @@ -670,16 +658,16 @@ private HandlerResult handleCreateDirectory(int workspaceId, Path destinationPat return new HandlerResult.Success(200, "Directory created."); } else { logger.warn("CREATE DIRECTORY: Create Directory failed for path {}", destinationPath); - return new HandlerResult.Failure(500, new FormattedError("Could not create directory.")); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, "Could not create directory.")); } } catch (IOException ioe) { logger.warn("CREATE DIRECTORY: IOException: {}", destinationPath); - return new HandlerResult.Failure(500, new FormattedError(ioe, "Could not create directory.")); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, ioe, "Could not create directory.")); } catch (WorkspaceFileOpException wfe) { logger.warn("CREATE DIRECTORY: WorkspaceFileOpException: {}", destinationPath); - return new HandlerResult.Failure(500, new FormattedError(wfe, "Could not create directory.")); + return new HandlerResult.Failure(500, new WorkspaceFormattedError(wfe, "Could not create directory.")); } catch (NoSuchWorkspaceException nsw) { - return new HandlerResult.Failure(404, new FormattedError(nsw, "Could not create directory.")); + return new HandlerResult.Failure(404, new WorkspaceFormattedError(nsw, "Could not create directory.")); } } @@ -720,7 +708,7 @@ private HandlerResult handleMove( if(RenderType.isAerieMetadataFile(toMove.getFileName().toString())) { return new HandlerResult.Failure( 405, - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest( errorMsg, "Metadata files may not be directly moved via the file API. Move the main file instead."))); @@ -730,21 +718,21 @@ private HandlerResult handleMove( if(RenderType.isAerieMetadataFile(destinationPath.getFileName().toString())) { return new HandlerResult.Failure( 405, - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest(errorMsg, "Normal files may not be renamed to metadata files."))); } if (!workspaceService.checkFileExists(sourceWorkspaceId, toMove)) { return new HandlerResult.Failure( 404, - new FormattedError( + new WorkspaceFormattedError( new NoSuchFileException(sourceWorkspaceId, toMove), errorMsg)); } final var destinationFileExists = workspaceService.checkFileExists(destinationWorkspaceId, destinationPath); if (destinationFileExists && !overwrite) { - return new HandlerResult.Failure(409, new FormattedError(errorMsg, destinationPath + " already exists.")); + return new HandlerResult.Failure(409, new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg, destinationPath + " already exists.")); } try { @@ -756,40 +744,40 @@ private HandlerResult handleMove( // at the destination, the source will only overwrite that folder if it is empty (meaning it cannot contain a locked file) final var readOnlyFiles = workspaceService.getReadOnlyFiles(sourceWorkspaceId, toMove); if(!readOnlyFiles.isEmpty()){ - return new HandlerResult.Failure(423, new FormattedError(new FileLockedException(toMove, readOnlyFiles), errorMsg)); + return new HandlerResult.Failure(423, new WorkspaceFormattedError(new FileLockedException(toMove, readOnlyFiles), errorMsg)); } } if (workspaceService.moveDirectory(sourceWorkspaceId, toMove, destinationWorkspaceId, destinationPath)) { return new HandlerResult.Success(200, successMsg); } else { - return new HandlerResult.Failure(500, new FormattedError(errorMsg)); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg)); } } else { // Report a "Locked" status if either file is currently marked as "readOnly" if(workspaceService.isReadOnly(sourceWorkspaceId, toMove)) { - return new HandlerResult.Failure(423, new FormattedError(new FileLockedException(toMove), errorMsg)); + return new HandlerResult.Failure(423, new WorkspaceFormattedError(new FileLockedException(toMove), errorMsg)); } if (destinationFileExists && workspaceService.isReadOnly(destinationWorkspaceId, destinationPath)) { - return new HandlerResult.Failure(423, new FormattedError(new FileLockedException(destinationPath), errorMsg)); + return new HandlerResult.Failure(423, new WorkspaceFormattedError(new FileLockedException(destinationPath), errorMsg)); } if (workspaceService.moveFile(sourceWorkspaceId, toMove, destinationWorkspaceId, destinationPath, userId)) { return new HandlerResult.Success(200, successMsg); } else { - return new HandlerResult.Failure(500, new FormattedError(errorMsg)); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg)); } } } catch (IOException ioe) { - final var fe = new FormattedError(ioe, errorMsg); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ioe, errorMsg); logger.warn("MOVE: IO EXCEPTION: {}", fe); return new HandlerResult.Failure(500, fe); } catch (WorkspaceFileOpException wfe) { - final var fe = new FormattedError(wfe, errorMsg); + final var fe = new WorkspaceFormattedError(wfe, errorMsg); logger.warn("MOVE: WORKSPACE FILE OP EXCEPTION: {}", fe); return new HandlerResult.Failure(500, fe); } catch (SQLException se) { - final var fe = new FormattedError(se); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, se, errorMsg); logger.warn("MOVE: SQL EXCEPTION: {}", fe); return new HandlerResult.Failure(500, fe); } @@ -813,7 +801,7 @@ private HandlerResult handleCopy( if(RenderType.isAerieMetadataFile(toCopy.getFileName().toString())) { return new HandlerResult.Failure( 405, - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest( errorMsg, "Metadata files may not be directly copied via the file API. Copy the main file instead."))); @@ -823,19 +811,19 @@ private HandlerResult handleCopy( if(RenderType.isAerieMetadataFile(destinationPath.getFileName().toString())) { return new HandlerResult.Failure( 405, - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest(errorMsg, "Normal files may not be renamed to metadata files."))); } if (!workspaceService.checkFileExists(sourceWorkspaceId, toCopy)) { return new HandlerResult.Failure( 404, - new FormattedError(new NoSuchFileException(sourceWorkspaceId, toCopy), errorMsg)); + new WorkspaceFormattedError(new NoSuchFileException(sourceWorkspaceId, toCopy), errorMsg)); } final var destinationFileExists = workspaceService.checkFileExists(destinationWorkspaceId, destinationPath); if (destinationFileExists && !overwrite) { - return new HandlerResult.Failure(409, new FormattedError(errorMsg, destinationPath + " already exists.")); + return new HandlerResult.Failure(409, new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg, destinationPath + " already exists.")); } try { @@ -843,26 +831,26 @@ private HandlerResult handleCopy( if (workspaceService.copyDirectory(sourceWorkspaceId, toCopy, destinationWorkspaceId, destinationPath)) { return new HandlerResult.Success(200, successMsg); } else { - return new HandlerResult.Failure(500, new FormattedError(errorMsg)); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg)); } } else { // Report a "Locked" status if the destination file is currently marked as "readOnly" if(destinationFileExists && workspaceService.isReadOnly(destinationWorkspaceId, destinationPath)) { - return new HandlerResult.Failure(423, new FormattedError(new FileLockedException(destinationPath), errorMsg)); + return new HandlerResult.Failure(423, new WorkspaceFormattedError(new FileLockedException(destinationPath), errorMsg)); } if (workspaceService.copyFile(sourceWorkspaceId, toCopy, destinationWorkspaceId, destinationPath, userId)) { return new HandlerResult.Success(200, successMsg); } else { - return new HandlerResult.Failure(500, new FormattedError(errorMsg)); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg)); } } } catch (WorkspaceFileOpException wfe) { - final var fe = new FormattedError(wfe, errorMsg); + final var fe = new WorkspaceFormattedError(wfe, errorMsg); logger.warn("COPY: WORKSPACE FILE OP EXCEPTION: {}", fe); return new HandlerResult.Failure(500, fe); } catch (IOException ioe) { - final var fe = new FormattedError(ioe, errorMsg); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ioe, errorMsg); logger.warn("COPY: IO EXCEPTION: {}", fe); return new HandlerResult.Failure(500, fe); } @@ -876,7 +864,7 @@ private HandlerResult handleDelete(int workspaceId, Path filePath) { if(RenderType.isAerieMetadataFile(filePath.getFileName().toString())) { return new HandlerResult.Failure( 405, - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest( errorMsg, "Metadata files may not be directly deleted via the file API. " @@ -884,47 +872,47 @@ private HandlerResult handleDelete(int workspaceId, Path filePath) { } if (!workspaceService.checkFileExists(workspaceId, filePath)) { - return new HandlerResult.Failure(404, new FormattedError(new NoSuchFileException(workspaceId, filePath))); + return new HandlerResult.Failure(404, new WorkspaceFormattedError(new NoSuchFileException(workspaceId, filePath))); } if (workspaceService.isDirectory(workspaceId, filePath)) { // Report a "Locked" status if there is a locked file within the directory final var readOnlyFiles = workspaceService.getReadOnlyFiles(workspaceId, filePath); if(!readOnlyFiles.isEmpty()){ - return new HandlerResult.Failure(423, new FormattedError(new FileLockedException(filePath, readOnlyFiles), errorMsg)); + return new HandlerResult.Failure(423, new WorkspaceFormattedError(new FileLockedException(filePath, readOnlyFiles), errorMsg)); } if (workspaceService.deleteDirectory(workspaceId, filePath)) { return new HandlerResult.Success(200, "Directory deleted."); } else { logger.warn("DELETE: Delete Directory failed for path {}", filePath); - return new HandlerResult.Failure(500, new FormattedError(errorMsg)); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg)); } } else { // Report a "Locked" status if the file is currently marked as "readOnly" if(workspaceService.isReadOnly(workspaceId, filePath)) { - return new HandlerResult.Failure(423, new FormattedError(new FileLockedException(filePath), errorMsg)); + return new HandlerResult.Failure(423, new WorkspaceFormattedError(new FileLockedException(filePath), errorMsg)); } if (workspaceService.deleteFile(workspaceId, filePath)) { return new HandlerResult.Success(200, "File deleted."); } else { logger.warn("DELETE: Delete File failed for path {}", filePath); - return new HandlerResult.Failure(500, new FormattedError(errorMsg)); + return new HandlerResult.Failure(500, new FormattedError(AerieService.WORKSPACE_SERVER, errorMsg)); } } } catch (NoSuchWorkspaceException nsw) { - return new HandlerResult.Failure(404, new FormattedError(nsw)); + return new HandlerResult.Failure(404, new WorkspaceFormattedError(nsw)); } catch (WorkspaceFileOpException wfe) { - final var fe = new FormattedError(wfe); + final var fe = new WorkspaceFormattedError(wfe); logger.warn("DELETE: WORKSPACE FILE OP EXCEPTION: {}", fe); return new HandlerResult.Failure(500, fe); } catch (IOException ioe) { - final var fe = new FormattedError(ioe); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ioe); logger.warn("DELETE: IO EXCEPTION: {}", fe); return new HandlerResult.Failure(500, fe); } catch (SQLException se) { - final var fe = new FormattedError(se); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, se); logger.warn("DELETE: SQL EXCEPTION: {}", fe); return new HandlerResult.Failure(500, fe); } @@ -978,7 +966,7 @@ public void bulkUpload(Context context) { // Get body if(!context.isMultipartFormData() || context.formParam("body") == null) { - context.status(400).json(new FormattedError( + context.status(400).json(new WorkspaceFormattedError( new MalformedRequest( "Invalid body format.", """ @@ -991,6 +979,7 @@ public void bulkUpload(Context context) { toUpload = bodyReader.readArray().getValuesAs(obj -> BulkPutItem.fromJson(obj.asJsonObject())); } catch (JsonException je) { context.status(400).json(new FormattedError( + AerieService.WORKSPACE_SERVER, je, "Invalid body format. Expected body format is an array of JSON objects with the form:\n\n"+helpText)); return; @@ -999,7 +988,7 @@ public void bulkUpload(Context context) { // Ensure that the user has specified at least one file or directory to upload if(toUpload.isEmpty()) { context.status(400).json( - new FormattedError(new MalformedRequest("Cannot process request: at least one item must be specified."))); + new WorkspaceFormattedError(new MalformedRequest("Cannot process request: at least one item must be specified."))); return; } @@ -1015,7 +1004,7 @@ public void bulkUpload(Context context) { // Check that files all had unique upload names: if(fileList.size() != fileMap.size()) { - context.status(400).json(new FormattedError( + context.status(400).json(new WorkspaceFormattedError( new MalformedRequest( "Cannot process request: multiple files are attached under the same name.", "Attach file contents under unique names.\n\n" + helpText))); @@ -1026,7 +1015,7 @@ public void bulkUpload(Context context) { final var destinationSet = toUpload.stream().map(BulkPutItem::path).collect(Collectors.toSet()); if(destinationSet.size() != toUpload.size()) { context.status(409).json( - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest( "Multiple items are attempting to be uploaded to the same location. Please give all items unique names."))); return; @@ -1056,7 +1045,7 @@ private JsonArray handleBulkUpload( if(file == null) { response.add("status", 400) .add("response", - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest( "No file provided with the name " + uploadedFileName, "Attach file contents under the 'files' part of the request.")) @@ -1082,7 +1071,10 @@ else if (item.uploadType() == ItemType.directory) { } else { logger.debug("BULK UPLOAD: Unsupported item upload type: {}", item.uploadType()); response.add("status", 501) - .add("response", new FormattedError("Unsupported item upload type: "+item.uploadType().name()).toJson()); + .add("response", new FormattedError( + AerieService.WORKSPACE_SERVER, + "Unsupported item upload type: "+item.uploadType().name() + ).toJson()); } // Add response to array responseArray.add(response); @@ -1152,7 +1144,7 @@ public void bulkPost(Context context) throws NoSuchWorkspaceException { // Get body if(!ContentType.JSON.equals(context.contentType())) { - context.status(400).json(new FormattedError(new MalformedRequest("Body must be type "+ContentType.JSON))); + context.status(400).json(new WorkspaceFormattedError(new MalformedRequest("Body must be type "+ContentType.JSON))); return; } @@ -1162,7 +1154,9 @@ public void bulkPost(Context context) throws NoSuchWorkspaceException { items = jsonBody.getJsonArray("items") .getValuesAs(o -> BulkPostItem.fromJson(o.asJsonObject(), body.destinationPath())); } catch (JsonException je) { - context.status(400).json(new FormattedError( + context.status(400).json( + new FormattedError( + AerieService.WORKSPACE_SERVER, je, "Invalid body format. Expected body format is a JSON object with the form:\n\n"+helpText)); return; @@ -1170,7 +1164,7 @@ public void bulkPost(Context context) throws NoSuchWorkspaceException { // Ensure that the user has specified at least one item to alter if(items.isEmpty()) { - context.status(400).json(new FormattedError(new MalformedRequest( + context.status(400).json(new WorkspaceFormattedError(new MalformedRequest( "Cannot process request: at least one item must be specified."))); return; } @@ -1178,7 +1172,7 @@ public void bulkPost(Context context) throws NoSuchWorkspaceException { // Ensure that no two inputs will try to write to the same location final var destinationSet = items.stream().map(BulkPostItem::newPath).collect(Collectors.toSet()); if(destinationSet.size() != items.size()) { - context.status(409).json(new FormattedError(new MalformedRequest( + context.status(409).json(new WorkspaceFormattedError(new MalformedRequest( "Multiple entries in 'item' have the same destination location. Use \"renameTo\" to resolve conflicts."))); return; } @@ -1217,7 +1211,9 @@ && checkPermissions(context, body.destinationWorkspaceId(), WorkspaceAction.writ ); context.status(207).json(copyResults.toString()); } - default -> context.status(501).json(new FormattedError("Unsupported post action: " + body.action().name()).toJson()); + default -> context.status(501).json( + new FormattedError(AerieService.WORKSPACE_SERVER, + "Unsupported post action: " + body.action().name()).toJson()); } } @@ -1292,18 +1288,21 @@ public void bulkDelete(Context context) { // Get body if(!ContentType.JSON.equals(context.contentType())) { - context.status(400).json(new FormattedError(new MalformedRequest("Body must be type "+ContentType.JSON))); + context.status(400).json(new WorkspaceFormattedError(new MalformedRequest("Body must be type "+ContentType.JSON))); return; } try(final var bodyReader = Json.createReader(new StringReader(context.body()))){ toDelete = bodyReader.readArray().getValuesAs(JsonString::getString); } catch (JsonException je) { - context.status(400).json(new FormattedError(je, "Invalid body format. Expected body format is an array of paths.")); + context.status(400).json(new FormattedError( + AerieService.WORKSPACE_SERVER, + je, + "Invalid body format. Expected body format is an array of paths.")); return; } // Ensure that the user has specified at least one file or directory to get the contents of if(toDelete.isEmpty()) { - context.status(400).json(new FormattedError(new MalformedRequest( + context.status(400).json(new WorkspaceFormattedError(new MalformedRequest( "Cannot process request: at least one item must be specified."))); return; } @@ -1350,7 +1349,7 @@ public void getMetadataFile(final Context context) throws NoSuchWorkspaceExcepti // Check that the underlying file exists if (!workspaceService.checkFileExists(pathInfo.workspaceId, pathInfo.filePath)) { - context.status(404).json(new FormattedError(new NoSuchFileException(pathInfo.workspaceId, pathInfo.filePath))); + context.status(404).json(new WorkspaceFormattedError(new NoSuchFileException(pathInfo.workspaceId, pathInfo.filePath))); return; } @@ -1364,11 +1363,14 @@ public void getMetadataFile(final Context context) throws NoSuchWorkspaceExcepti context.header("Content-Disposition", "attachment; filename=\"" + pathInfo.metadataFileName() + "\""); context.status(200).result(inputStream); } catch (WorkspaceFileOpException wfe) { - final var fe = new FormattedError(wfe, "Could not retrieve metadata file for file "+pathInfo.fileName()); + final var fe = new WorkspaceFormattedError(wfe, "Could not retrieve metadata file for file "+pathInfo.fileName()); context.status(400).json(fe); } catch (IOException ioe) { - final var fe = new FormattedError(ioe, "Could not retrieve metadata file for file " + pathInfo.fileName()); + final var fe = new FormattedError( + AerieService.WORKSPACE_SERVER, + ioe, + "Could not retrieve metadata file for file " + pathInfo.fileName()); logger.warn("GET METADATA: IO Exception: {}", fe); context.status(500).json(fe); } @@ -1408,19 +1410,16 @@ public void setMetadataKeys(final Context context) throws NoSuchWorkspaceExcepti final var mergeBehaviorParam = context.queryParamAsClass("mergeBehavior", String.class).getOrDefault("shallow"); mergeBehavior = MetadataMergeBehavior.of(mergeBehaviorParam); } catch (ValidationException ve) { - context.status(400).json(new FormattedError(ve)); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, ve)); return; } catch (IllegalArgumentException iae) { - context.status(400).json(new FormattedError(iae)); + context.status(400).json(new FormattedError(AerieService.WORKSPACE_SERVER, iae)); return; } // Get body if(!ContentType.JSON.equals(context.contentType())) { - context.status(400).json(new FormattedError( - "MALFORMED_REQUEST", - "Body must be type "+ContentType.JSON, - Optional.empty())); + context.status(400).json(new WorkspaceFormattedError(new MalformedRequest("Body must be type "+ContentType.JSON))); return; } @@ -1430,23 +1429,24 @@ public void setMetadataKeys(final Context context) throws NoSuchWorkspaceExcepti updates = MetadataUpdates.fromEndpointBodyJson(authorize(context).userId(), jsonBody); } catch (JsonException je) { context.status(400).json(new FormattedError( + AerieService.WORKSPACE_SERVER, je, "Invalid body format. Expected body format is a JSON object with the set of keys to be updated.")); return; } catch (MalformedRequest mr) { - context.status(400).json(new FormattedError(mr)); + context.status(400).json(new WorkspaceFormattedError(mr)); return; } // Ensure that the user has specified at least one key to alter if(updates.noUserUpdates()) { - context.status(400).json(new FormattedError(new MalformedRequest("Cannot process request: at least one key must be specified."))); + context.status(400).json(new WorkspaceFormattedError(new MalformedRequest("Cannot process request: at least one key must be specified."))); return; } // Check that the underlying file exists if (!workspaceService.checkFileExists(pathInfo.workspaceId, pathInfo.filePath)) { - context.status(404).json(new FormattedError(new NoSuchFileException(pathInfo.workspaceId, pathInfo.filePath))); + context.status(404).json(new WorkspaceFormattedError(new NoSuchFileException(pathInfo.workspaceId, pathInfo.filePath))); return; } @@ -1455,20 +1455,23 @@ public void setMetadataKeys(final Context context) throws NoSuchWorkspaceExcepti if(workspaceService.updateMetadataKeys(pathInfo.workspaceId, pathInfo.filePath, updates, mergeBehavior)) { context.status(200).result("Metadata for file %s updated successfully.".formatted(pathInfo.filePath)); } else { - context.status(500).json(new FormattedError("Unable to update metadata for file %s".formatted(pathInfo.filePath))); + context.status(500).json(new FormattedError(AerieService.WORKSPACE_SERVER, "Unable to update metadata for file %s".formatted(pathInfo.filePath))); } } catch (NoSuchWorkspaceException nsw) { - context.status(404).json(new FormattedError(nsw)); + context.status(404).json(new WorkspaceFormattedError(nsw)); } catch (IOException ioe) { - final var fe = new FormattedError(ioe); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ioe); logger.warn("SET METADATA: IO Exception: {}", fe); context.status(500).json(fe); } catch (WorkspaceFileOpException wfe) { - final var fe = new FormattedError(wfe, "Could not update metadata."); + final var fe = new WorkspaceFormattedError(wfe, "Could not update metadata."); logger.warn("SET METADATA: WorkspaceFileOpException: {}", fe); context.status(500).json(fe); } catch (JsonException je) { - final var fe = new FormattedError(je, "Metadata for file %s is malformed.".formatted(pathInfo.filePath)); + final var fe = new FormattedError( + AerieService.WORKSPACE_SERVER, + je, + "Metadata for file %s is malformed.".formatted(pathInfo.filePath)); logger.warn("SET METADATA: JsonException: {}", fe); context.status(500).json(fe); } @@ -1490,7 +1493,7 @@ public void unsetMetadataKeys(final Context context) throws NoSuchWorkspaceExcep // Get body if(!ContentType.JSON.equals(context.contentType())) { - context.status(400).json(new FormattedError(new MalformedRequest("Body must be type "+ContentType.JSON))); + context.status(400).json(new WorkspaceFormattedError(new MalformedRequest("Body must be type "+ContentType.JSON))); return; } @@ -1502,7 +1505,7 @@ public void unsetMetadataKeys(final Context context) throws NoSuchWorkspaceExcep if(!key.startsWith("user.")) { if(!MetadataKeys.whitelist.contains(key)) { context.status(400).json( - new FormattedError( + new WorkspaceFormattedError( new MalformedRequest("Request body contains unpermitted keys. " + "Only the following keys may be updated: " + String.join(", ", MetadataKeys.whitelist)))); @@ -1514,6 +1517,7 @@ public void unsetMetadataKeys(final Context context) throws NoSuchWorkspaceExcep toUnset = new HashSet<>(unsetList); } catch (JsonException je) { context.status(400).json(new FormattedError( + AerieService.WORKSPACE_SERVER, je, "Invalid body format. Expected body format is a JSON array with the set of keys to be removed.")); return; @@ -1521,13 +1525,13 @@ public void unsetMetadataKeys(final Context context) throws NoSuchWorkspaceExcep // Ensure that the user has specified at least one key to alter if(toUnset.isEmpty()) { - context.status(400).json(new FormattedError(new MalformedRequest("Cannot process request: at least one key must be specified."))); + context.status(400).json(new WorkspaceFormattedError(new MalformedRequest("Cannot process request: at least one key must be specified."))); return; } // Check that the underlying file exists if (!workspaceService.checkFileExists(pathInfo.workspaceId, pathInfo.filePath)) { - context.status(404).json(new FormattedError(new NoSuchFileException(pathInfo.workspaceId, pathInfo.filePath))); + context.status(404).json(new WorkspaceFormattedError(new NoSuchFileException(pathInfo.workspaceId, pathInfo.filePath))); return; } @@ -1536,20 +1540,25 @@ public void unsetMetadataKeys(final Context context) throws NoSuchWorkspaceExcep if(workspaceService.unsetMetadataKeys(pathInfo.workspaceId, pathInfo.filePath, toUnset, authorize(context).userId())) { context.status(200).result("Metadata for file %s updated successfully.".formatted(pathInfo.filePath)); } else { - context.status(500).json(new FormattedError("Unable to update metadata for file %s".formatted(pathInfo.filePath))); + context.status(500).json(new FormattedError( + AerieService.WORKSPACE_SERVER, + "Unable to update metadata for file %s".formatted(pathInfo.filePath))); } } catch (NoSuchWorkspaceException nsw) { - context.status(404).json(new FormattedError(nsw)); + context.status(404).json(new WorkspaceFormattedError(nsw)); } catch (IOException ioe) { - final var fe = new FormattedError(ioe); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, ioe); logger.warn("UNSET METADATA: IO Exception: {}", fe); context.status(500).json(fe); } catch (WorkspaceFileOpException wfe) { - final var fe = new FormattedError(wfe, "Could not update metadata."); + final var fe = new WorkspaceFormattedError(wfe, "Could not update metadata."); logger.warn("UNSET METADATA: WorkspaceFileOpException: {}", fe); context.status(500).json(fe); } catch (JsonException je) { - final var fe = new FormattedError(je, "Metadata for file %s is malformed.".formatted(pathInfo.filePath)); + final var fe = new FormattedError( + AerieService.WORKSPACE_SERVER, + je, + "Metadata for file %s is malformed.".formatted(pathInfo.filePath)); logger.warn("UNSET METADATA: JsonException: {}", fe); context.status(500).json(fe); } @@ -1571,12 +1580,14 @@ public void deleteMetadata(final Context context) { if(workspaceService.deleteMetadataFile(pathInfo.workspaceId, pathInfo.filePath)) { context.status(200).result("Metadata for file %s deleted.".formatted(pathInfo.filePath)); } else { - context.status(500).json(new FormattedError("Unable to delete metadata for file %s".formatted(pathInfo.filePath))); + context.status(500).json(new FormattedError( + AerieService.WORKSPACE_SERVER, + "Unable to delete metadata for file %s".formatted(pathInfo.filePath))); } } catch (NoSuchWorkspaceException nsw) { - context.status(404).json(new FormattedError(nsw)); + context.status(404).json(new WorkspaceFormattedError(nsw)); } catch (WorkspaceFileOpException wfe) { - final var fe = new FormattedError(wfe, "Could not delete metadata."); + final var fe = new WorkspaceFormattedError(wfe, "Could not delete metadata."); logger.warn("DELETE METADATA: WorkspaceFileOpException: {}", fe); context.status(500).json(fe); } diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFormattedError.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFormattedError.java new file mode 100644 index 0000000000..aefee24660 --- /dev/null +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFormattedError.java @@ -0,0 +1,77 @@ +package gov.nasa.jpl.aerie.workspace.server; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import gov.nasa.jpl.aerie.json.FormattedError; +import gov.nasa.jpl.aerie.workspace.server.exceptions.FileLockedException; +import gov.nasa.jpl.aerie.workspace.server.exceptions.MalformedRequest; +import gov.nasa.jpl.aerie.workspace.server.exceptions.NoSuchFileException; +import gov.nasa.jpl.aerie.workspace.server.exceptions.WorkspaceFileOpException; +import gov.nasa.jpl.aerie.workspace.server.postgres.NoSuchWorkspaceException; + +import javax.json.Json; + +/** + * Class for formatting Workspace-specific exceptions into JSON objects + * that meet the Aerie HTTP endpoint error message format + */ +@JsonSerialize(using = FormattedError.FormattedErrorSerializer.class) +final class WorkspaceFormattedError extends FormattedError { + // NoSuchWorkspace + public WorkspaceFormattedError(NoSuchWorkspaceException nse) { + super( + AerieService.WORKSPACE_SERVER, + "NO_SUCH_WORKSPACE", + nse, + Json.createObjectBuilder() + .add("workspace_id", nse.getWorkspaceId()) + .build() + ); + } + public WorkspaceFormattedError(NoSuchWorkspaceException nse, String message) { + super( + AerieService.WORKSPACE_SERVER, + "NO_SUCH_WORKSPACE", + message, + nse, + Json.createObjectBuilder() + .add("workspace_id", nse.getWorkspaceId()) + .build() + ); + } + + // NoSuchFile + public WorkspaceFormattedError(NoSuchFileException nsf) { + super(AerieService.WORKSPACE_SERVER, "NO_SUCH_FILE", nsf.getMessage(), nsf); + } + public WorkspaceFormattedError(NoSuchFileException nsf, String message) { + super(AerieService.WORKSPACE_SERVER, "NO_SUCH_FILE", message, nsf); + } + + + // WorkspaceFileOpException + public WorkspaceFormattedError(WorkspaceFileOpException wfe) { + super(AerieService.WORKSPACE_SERVER, "FILE_OPERATION_EXCEPTION", wfe); + } + public WorkspaceFormattedError(WorkspaceFileOpException wfe, String message) { + super(AerieService.WORKSPACE_SERVER, "FILE_OPERATION_EXCEPTION", message, wfe); + } + + // Malformed Request + public WorkspaceFormattedError(MalformedRequest mr) { + super(AerieService.WORKSPACE_SERVER, "MALFORMED_REQUEST", mr.getMessage(), mr.getDetails().orElse(null), mr); + } + + // Locked File + public WorkspaceFormattedError(FileLockedException fle) { + super(AerieService.WORKSPACE_SERVER, "FILE_LOCKED", fle); + } + + public WorkspaceFormattedError(FileLockedException fle, String message) { + super(AerieService.WORKSPACE_SERVER, "FILE_LOCKED", message, fle); + } + + @Override + public String toString() { + return super.toString(); + } +} diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/postgres/NoSuchWorkspaceException.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/postgres/NoSuchWorkspaceException.java index 6f24abae62..b08de9ae4d 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/postgres/NoSuchWorkspaceException.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/postgres/NoSuchWorkspaceException.java @@ -1,7 +1,14 @@ package gov.nasa.jpl.aerie.workspace.server.postgres; public class NoSuchWorkspaceException extends Exception { + private final int workspaceId; + public NoSuchWorkspaceException(final int workspaceId) { super("No such workspace exists with id "+workspaceId+"."); + this.workspaceId = workspaceId; + } + + public int getWorkspaceId() { + return workspaceId; } } diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/HandlerResult.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/HandlerResult.java index 8bf9cbbb5a..f31f1f68fc 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/HandlerResult.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/HandlerResult.java @@ -1,6 +1,6 @@ package gov.nasa.jpl.aerie.workspace.server.types; -import gov.nasa.jpl.aerie.workspace.server.FormattedError; +import gov.nasa.jpl.aerie.json.FormattedError; import javax.json.Json; import javax.json.JsonValue;