From 9f3e018c784d43c84b3ee1fbc3bbf85516a0b8e6 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Thu, 8 Jan 2026 13:57:59 -0500 Subject: [PATCH 01/27] Return singular PermissionsException from public Permissions Service interface --- .../aerie/permissions/PermissionsService.java | 69 +++++++++++++------ .../exceptions/PermissionsException.java | 23 +++++++ 2 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsException.java 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..e9b024aee9 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 @@ -4,6 +4,7 @@ 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.PermissionsException; import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; import gov.nasa.jpl.aerie.permissions.gql.GraphQLPermissionsService; import gov.nasa.jpl.aerie.permissions.gql.PlanId; @@ -20,44 +21,68 @@ 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, f); + } catch (NoSuchPlanException nsp) { + throw new PermissionsException(404, nsp); + } catch (PermissionsServiceException | IOException ex) { + throw new PermissionsException(500, ex); + } } 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, nss); + } catch (PermissionsServiceException | IOException ex) { + throw new PermissionsException(500, ex); + } } 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, f); + } catch (NoSuchWorkspaceException nsw) { + throw new PermissionsException(404, nsw); + } catch (PermissionsServiceException | IOException ex) { + throw new PermissionsException(500, ex); + } } 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, f); + } catch (PermissionsServiceException | IOException ex) { + throw new PermissionsException(500, ex); } } 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..103cc4b282 --- /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; + +/** + * 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 Exception rootException; + + public PermissionsException(int httpStatus, Exception rootException) { + super(rootException); + this.httpStatus = httpStatus; + this.rootException = rootException; + } + + public int httpStatusCode() { + return httpStatus; + } + + public Exception rootException() { return rootException; } +} From e180318241e6f72bf378873dd4847cce9a64333f Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 20 Jan 2026 17:19:58 -0500 Subject: [PATCH 02/27] Minor: close httpClient created while checking permissions --- .../jpl/aerie/permissions/gql/GraphQLPermissionsService.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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..2868e97c69 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 @@ -47,7 +47,7 @@ public record GraphQLPermissionsService( */ private Optional postRequest(final String query, final JsonObject variables) throws IOException, PermissionsServiceException { - 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,8 +62,7 @@ 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"); } From 5f78f51c5371d867d46a944096b69abfcc899c76 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 23 Jan 2026 12:37:56 -0500 Subject: [PATCH 03/27] Move FormattedError to parsing-utilities --- parsing-utilities/build.gradle | 2 + .../nasa/jpl/aerie/json}/FormattedError.java | 149 +++++++----------- 2 files changed, 58 insertions(+), 93 deletions(-) rename {workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server => parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json}/FormattedError.java (58%) 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/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java b/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/FormattedError.java similarity index 58% rename from workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java rename to parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/FormattedError.java index b005c7f3ec..4440b457cb 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java +++ b/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/FormattedError.java @@ -1,16 +1,9 @@ -package gov.nasa.jpl.aerie.workspace.server; +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 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; @@ -27,16 +20,26 @@ import java.util.Optional; /** - * Class for formatting exceptions thrown during in Workspaces into JSON objects - * that meet the Aerie HTTP endpoint error message format + * 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 final class FormattedError { +public class FormattedError { + public enum AerieService { + MERLIN_SERVER("aerie_merlin"), + SCHEDULER_SERVER("aerie_scheduler"), + WORKSPACE_SERVER("aerie_workspace"), + PERMISSIONS_SERVICE("aerie_permissions"); + + 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 static String service = "aerie_workspace"; + private final AerieService service; private Optional cause = Optional.empty(); private Optional trace = Optional.empty(); private Optional data = Optional.empty(); @@ -45,17 +48,19 @@ public final class FormattedError { * 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) { + 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(String message, String cause) { + public FormattedError(AerieService service, String message, String cause) { this.type = "INTERNAL_ERROR"; this.message = message; + this.service = service; this.cause = Optional.ofNullable(cause); } @@ -63,9 +68,10 @@ public FormattedError(String message, String 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) { + public FormattedError(AerieService service, String type, String message, Optional cause) { this.type = type; this.message = message; + this.service = service; this.cause = cause; } @@ -74,9 +80,10 @@ public FormattedError(String type, String message, Optional cause) { * @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) { + 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)); } @@ -88,66 +95,36 @@ public FormattedError(String type, Exception ex) { * Should be human-readable and between 1-2 sentences. * @param ex the exception to be formatted. */ - public FormattedError(String type, String message, Exception ex) { + public FormattedError(AerieService service, String type, String message, Exception ex) { this.type = type; this.message = message; + this.service = service; 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(AerieService service, IOException nse) { + this(service, "IO_EXCEPTION", nse); } - public FormattedError(IOException nse, String message) { - this("IO_EXCEPTION", message, nse); + public FormattedError(AerieService service, IOException nse, String message) { + this(service, 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(AerieService service, SQLException se) { + this(service, "SQL_EXCEPTION", se); } - public FormattedError(WorkspaceFileOpException wfe, String message) { - this("FILE_OPERATION_EXCEPTION", message, wfe); - } - - // Forbidden - public FormattedError(Forbidden ue) { - this("FORBIDDEN", ue); + public FormattedError(AerieService service, SQLException se, String message) { + this(service, "SQL_EXCEPTION", message, se); } // Unauthorized - public FormattedError(UnauthorizedResponse ue) { + 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 @@ -158,58 +135,41 @@ public FormattedError(UnauthorizedResponse ue) { } } - // PermissionsServiceException - public FormattedError(PermissionsServiceException pse, String message) { - this("PERMISSIONS_SERVICE_EXCEPTION", message, pse); - } - // NumberFormatException - public FormattedError(NumberFormatException nfe) { - this("NUMBER_PARSING_EXCEPTION", nfe); + public FormattedError(AerieService service, NumberFormatException nfe) { + this(service, "NUMBER_PARSING_EXCEPTION", nfe); } // IllegalArgumentException - public FormattedError(IllegalArgumentException iae) { - this("ILLEGAL_ARGUMENT", iae); + public FormattedError(AerieService service, IllegalArgumentException iae) { + this(service, "ILLEGAL_ARGUMENT", iae); } // JSONException - public FormattedError(JsonException je, String message){ - this("JSON_PARSING_EXCEPTION", message, je); + 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(ValidationException ve) { + 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(NullPointerException ne, String message) { - this("NULL_POINTER_EXCEPTION", message, ne); + public FormattedError(AerieService service, NullPointerException ne, String message) { + this(service, "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); + public FormattedError(AerieService service, SecurityException se) { + this(service, "SECURITY_EXCEPTION", se.getMessage(), se); } //endregion @@ -224,6 +184,9 @@ private String generateTrace(Exception ex) { return sw.toString(); } + public String getType() { return type; } + public String getMessage() { return message; } + /** * Export this object to a JsonObject. */ @@ -233,7 +196,7 @@ public JsonObject toJson() { .add("type", type) .add("message", message) .add("timestamp", timestamp) - .add("service", service); // Not mandatory on spec, but always known + .add("service", service.serviceName()); // Not mandatory on spec, but always known // Include optional fields, if present cause.ifPresent((c) -> builder.add("cause", c)); @@ -253,7 +216,7 @@ public String toString() { * 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 { + public static final class FormattedErrorSerializer extends JsonSerializer { @Override public void serialize( final FormattedError formattedError, From 5bfe9eb0bdbb97e5fa07dba413b9e17bc3e46409 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 23 Jan 2026 12:38:18 -0500 Subject: [PATCH 04/27] Create PermissionsFormattedError to format Permissions Exceptions - Replaces ExceptionSerializers --- permissions/build.gradle | 2 + .../exceptions/ExceptionSerializers.java | 40 --------------- .../exceptions/PermissionsFormattedError.java | 51 +++++++++++++++++++ 3 files changed, 53 insertions(+), 40 deletions(-) delete mode 100644 permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/ExceptionSerializers.java create mode 100644 permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsFormattedError.java 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/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/PermissionsFormattedError.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsFormattedError.java new file mode 100644 index 0000000000..f2b0fafc1e --- /dev/null +++ b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsFormattedError.java @@ -0,0 +1,51 @@ +package gov.nasa.jpl.aerie.permissions.exceptions; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import gov.nasa.jpl.aerie.json.FormattedError; + +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); + } + + 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); + } + + 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); + } + + // 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); + } +} From 7bb89dc99430dcb881843e615c62f71e71ff61c9 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 20 Jan 2026 17:17:27 -0500 Subject: [PATCH 05/27] Collect all exceptions thrown while checking permissions into single PermissionsException - rename "PermissionsServiceException" to "GraphQLServiceException" for clarity --- .../aerie/permissions/PermissionsService.java | 55 +++++++++++-------- ...tion.java => GraphQLServiceException.java} | 4 +- .../exceptions/PermissionsException.java | 12 ++-- .../gql/GraphQLPermissionsService.java | 19 ++++--- 4 files changed, 50 insertions(+), 40 deletions(-) rename permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/{PermissionsServiceException.java => GraphQLServiceException.java} (53%) 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 e9b024aee9..867ebc4c98 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,11 +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.PermissionsException; -import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; +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; @@ -27,11 +28,13 @@ public void check(final HasuraAction action, final String role, final String use final var authorized = canPerformAction(permissionType, username, planId); if (!authorized) throw new Forbidden(action, role, username, permissionType, planId); } catch (Forbidden f) { - throw new PermissionsException(403, f); + throw new PermissionsException(403, new PermissionsFormattedError(f)); } catch (NoSuchPlanException nsp) { - throw new PermissionsException(404, nsp); - } catch (PermissionsServiceException | IOException ex) { - throw new PermissionsException(500, ex); + 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)); } } @@ -44,10 +47,12 @@ public void check( try { final var planId = gqlService.getPlanIdFromSchedulingSpecificationId(specificationId); check(action, role, username, planId); - } catch (NoSuchSchedulingSpecificationException nss) { - throw new PermissionsException(404, nss); - } catch (PermissionsServiceException | IOException ex) { - throw new PermissionsException(500, ex); + } catch (NoSuchSchedulingSpecificationException 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)); } } @@ -62,11 +67,13 @@ public void check( final var authorized = canPerformWorkspaceAction(permissionType, username, workspaceId); if (!authorized) throw new Forbidden(action, role, username, permissionType, workspaceId); } catch (Forbidden f) { - throw new PermissionsException(403, f); - } catch (NoSuchWorkspaceException nsw) { - throw new PermissionsException(404, nsw); - } catch (PermissionsServiceException | IOException ex) { - throw new PermissionsException(500, ex); + 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)); } } @@ -80,14 +87,16 @@ public void checkCoarseGrained(final Action action, final String role) throw new IllegalArgumentException("Unsupported action subtype: " + action.getClass()); } } catch (Forbidden f) { - throw new PermissionsException(403, f); - } catch (PermissionsServiceException | IOException ex) { - throw new PermissionsException(500, ex); + 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; @@ -99,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); @@ -110,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; @@ -128,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(); @@ -138,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/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/PermissionsException.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/PermissionsException.java index 103cc4b282..224266449d 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -7,17 +9,15 @@ */ public class PermissionsException extends Exception { private final int httpStatus; - private final Exception rootException; + private final FormattedError formattedError; - public PermissionsException(int httpStatus, Exception rootException) { - super(rootException); + public PermissionsException(int httpStatus, FormattedError formattedError) { this.httpStatus = httpStatus; - this.rootException = rootException; + this.formattedError = formattedError; } public int httpStatusCode() { return httpStatus; } - - public Exception rootException() { return rootException; } + public FormattedError formattedError() {return formattedError;} } 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 2868e97c69..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,7 +45,7 @@ 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(final var httpClient = HttpClient.newHttpClient()) { //TODO: (mem optimization) use streams here to avoid several copies of strings @@ -68,7 +68,7 @@ private Optional postRequest(final String query, final JsonObject va } 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) { @@ -79,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) { @@ -99,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) { @@ -118,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) { @@ -144,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) { @@ -184,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!) { @@ -211,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!) { From a30c127ca138e957f470d59fc58cb8878c6fbc62 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 23 Jan 2026 12:38:39 -0500 Subject: [PATCH 06/27] Create WorkspaceFormattedError to format workspace-specific errors - Update Workspaces to process single PermissionsException --- .../nasa/jpl/aerie/json/FormattedError.java | 25 +- .../workspace/server/WorkspaceBindings.java | 312 +++++++++--------- .../server/WorkspaceFormattedError.java | 60 ++++ .../workspace/server/types/HandlerResult.java | 2 +- 4 files changed, 243 insertions(+), 156 deletions(-) create mode 100644 workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFormattedError.java 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 index 4440b457cb..70f09ee588 100644 --- 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 @@ -77,7 +77,8 @@ public FormattedError(AerieService service, String type, String message, Optiona /** * Create a FormattedException from a generic Exception object. - * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE + * @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) { @@ -90,7 +91,8 @@ public FormattedError(AerieService service, String type, Exception 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 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. @@ -103,6 +105,23 @@ public FormattedError(AerieService service, String type, String message, Excepti 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)); + } + // region Constructors for specific exceptions // This helps `type` be consistent every time the exception is thrown. @@ -176,7 +195,7 @@ public FormattedError(AerieService service, SecurityException se) { /** * Generate a stack trace string from an Exception. */ - private String generateTrace(Exception ex) { + protected static String generateTrace(Exception ex) { final var sw = new StringWriter(); try(final var pw = new PrintWriter(sw)) { ex.printStackTrace(pw); 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..b37d2e58ae 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; @@ -144,37 +145,38 @@ 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); + final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, "UNKNOWN_ERROR", message, ex); logger.error("Unexpected error processing workspace request {}", fe); ctx.status(500).json(fe); }); @@ -229,21 +231,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 +247,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 +274,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 +298,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 +328,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 +345,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 +383,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 +411,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 +423,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 +454,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 +466,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 +480,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 +526,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 +579,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 +611,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 +620,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 +634,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 +655,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 +705,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 +715,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 +741,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 +798,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 +808,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 +828,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 +861,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 +869,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 +963,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 +976,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 +985,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 +1001,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 +1012,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 +1042,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 +1068,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 +1141,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 +1151,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 +1161,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 +1169,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 +1208,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 +1285,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 +1346,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 +1360,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 +1407,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 +1426,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 +1452,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 +1490,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 +1502,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 +1514,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 +1522,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 +1537,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 +1577,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..2181e6521b --- /dev/null +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFormattedError.java @@ -0,0 +1,60 @@ +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; + +/** + * 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); + } + public WorkspaceFormattedError(NoSuchWorkspaceException nse, String message) { + super(AerieService.WORKSPACE_SERVER, "NO_SUCH_WORKSPACE", message, nse); + } + + // 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/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; From 1e8988990423395c807f91ecb5d4ca82e3f20e02 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 20 Jan 2026 17:30:14 -0500 Subject: [PATCH 07/27] Create MerlinFormattedError --- merlin-server/build.gradle | 1 + .../exceptions/MerlinFormattedError.java | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/MerlinFormattedError.java 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/exceptions/MerlinFormattedError.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/MerlinFormattedError.java new file mode 100644 index 0000000000..fe0944237e --- /dev/null +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/exceptions/MerlinFormattedError.java @@ -0,0 +1,54 @@ +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.services.MissionModelService.NoSuchMissionModelException; +import gov.nasa.jpl.aerie.merlin.server.services.MissionModelService.NoSuchActivityTypeException; + +/** + * 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); + } + + public MerlinFormattedError(NoSuchPlanDatasetException npe) { + super(AerieService.MERLIN_SERVER, "NO_SUCH_PLAN_DATASET", npe); + } + + public MerlinFormattedError(NoSuchMissionModelException nme) { + super(AerieService.MERLIN_SERVER, "NO_SUCH_MISSION_MODEL", nme); + } + + public MerlinFormattedError(NoSuchActivityTypeException nae) { + super(AerieService.MERLIN_SERVER, "NO_SUCH_ACTIVITY_TYPE", nae); + } + + public MerlinFormattedError(NoSuchActivityTypeException nae, String message) { + super(AerieService.MERLIN_SERVER, "NO_SUCH_ACTIVITY_TYPE", message, nae); + } + // 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); + } +} From d022d57c5b5289c94da4bf3c6a66f94663d1f5ed Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Wed, 21 Jan 2026 11:09:27 -0500 Subject: [PATCH 08/27] Remove InvalidJsonException and rename InvalidEntityException to InvalidJsonEntityException InvalidJsonException was only a wrapper around JsonParsingException --- .../server/http/InvalidEntityException.java | 14 -------------- .../server/http/InvalidJsonEntityException.java | 15 +++++++++++++++ .../merlin/server/http/InvalidJsonException.java | 7 ------- .../aerie/merlin/server/http/MerlinParsers.java | 8 ++------ .../postgres/GetPlanConstraintsAction.java | 6 +++--- .../ConstraintsDSLCompilationService.java | 13 +++++-------- 6 files changed, 25 insertions(+), 38 deletions(-) delete mode 100644 merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidEntityException.java create mode 100644 merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidJsonEntityException.java delete mode 100644 merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/InvalidJsonException.java 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/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/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/services/ConstraintsDSLCompilationService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationService.java index bd7fb48baf..33f186e0b5 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 @@ -4,8 +4,7 @@ import gov.nasa.jpl.aerie.constraints.tree.Expression; import gov.nasa.jpl.aerie.json.JsonParser; 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; @@ -88,7 +87,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 +95,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 +107,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))); } } From cee88589e4acc190e209ac854d513f32dcaa699d Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Wed, 21 Jan 2026 11:21:50 -0500 Subject: [PATCH 09/27] Remove MissionModelRepository's Copy of NoSuchMissionModelException --- .../remotes/MissionModelRepository.java | 3 +- .../PostgresMissionModelRepository.java | 11 ++-- .../services/LocalMissionModelService.java | 65 +++++++------------ 3 files changed, 30 insertions(+), 49 deletions(-) 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/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/services/LocalMissionModelService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java index 5db6f4e780..68b987b97c 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 @@ -69,7 +69,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 +101,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); } /** @@ -318,49 +314,38 @@ public SimulationResults runSimulation( public void refreshModelParameters(final MissionModelId missionModelId) throws NoSuchMissionModelException { - 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 { - 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) @@ -369,8 +354,6 @@ public void refreshResourceTypes(final MissionModelId missionModelId) 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); } @@ -415,8 +398,6 @@ private MissionModel loadAndInstantiateMissionModel( 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); } From 6c29993d7e196f0f29b8bb8d2e35ec48d31cf649 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Wed, 21 Jan 2026 12:19:50 -0500 Subject: [PATCH 10/27] Remove LocalMissionModelService's Copy of MissionModelLoadException - Was just a wrapper around the `MissionModelLoader` version of the exception - Removing it means the Interface MissionModelService does not have to import its implementation --- .../http/LocalAppExceptionBindings.java | 16 -------- .../services/LocalMissionModelService.java | 37 +++++++------------ .../server/services/MissionModelService.java | 33 ++++++++--------- .../server/mocks/StubMissionModelService.java | 4 -- 4 files changed, 29 insertions(+), 61 deletions(-) delete mode 100644 merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/LocalAppExceptionBindings.java 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/services/LocalMissionModelService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java index 68b987b97c..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; @@ -286,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()) { @@ -312,14 +313,14 @@ public SimulationResults runSimulation( @Override public void refreshModelParameters(final MissionModelId missionModelId) - throws NoSuchMissionModelException + throws NoSuchMissionModelException, MissionModelLoadException { this.missionModelRepository.updateModelParameters(missionModelId, getModelParameters(missionModelId)); } @Override public void refreshActivityTypes(final MissionModelId missionModelId) - throws NoSuchMissionModelException + throws NoSuchMissionModelException, MissionModelLoadException { final var modelType = this.loadMissionModelType(missionModelId); final var registry = DirectiveTypeRegistry.extract(modelType); @@ -351,12 +352,8 @@ public void refreshResourceTypes(final MissionModelId missionModelId) 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 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); } /** @@ -390,20 +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 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..8419889566 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; - - void refreshModelParameters(MissionModelId missionModelId) throws NoSuchMissionModelException; - void refreshActivityTypes(MissionModelId missionModelId) throws NoSuchMissionModelException; - void refreshResourceTypes(MissionModelId missionModelId) throws NoSuchMissionModelException; + ) throws NoSuchMissionModelException, MissionModelService.NoSuchActivityTypeException, MissionModelLoadException; + + 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 { } 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(); } From cde2795cecc50deecfbeac29d42be59a306e2d57 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 23 Jan 2026 12:18:08 -0500 Subject: [PATCH 11/27] Remove Unused MissionModelAccessException --- .../jpl/aerie/merlin/server/AerieAppDriver.java | 4 ---- .../MissionModelRepositoryExceptionBindings.java | 15 --------------- .../remotes/MissionModelAccessException.java | 16 ---------------- 3 files changed, 35 deletions(-) delete mode 100644 merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MissionModelRepositoryExceptionBindings.java delete mode 100644 merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/remotes/MissionModelAccessException.java 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/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/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; - } -} From 2e634313cfe2ba31b50b38d84de9df5fca69bf19 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 13 Jan 2026 11:52:38 -0500 Subject: [PATCH 12/27] Use FormattedErrors instead of Error ResponseSerializers - InstantiationException was not included as it's used for a 200 response in the "getEffectiveArguments" endpoints - Updates MerlinBindings to reflect Permissions Changes --- .../merlin/server/http/MerlinBindings.java | 338 ++++++++++-------- .../server/http/ResponseSerializers.java | 221 +++--------- .../server/services/MissionModelService.java | 2 +- .../server/services/SimulationAgent.java | 31 +- .../nasa/jpl/aerie/json/JsonParseResult.java | 12 + 5 files changed, 267 insertions(+), 337 deletions(-) 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..2c5a07957d 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,10 @@ 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.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 +14,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 +75,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, @@ -112,11 +119,48 @@ 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(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 workspace request {}", fe); + ctx.status(500).json(fe); + }); } private void postRefreshModelParameters(final Context ctx) { @@ -124,14 +168,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 +184,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 +200,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 +219,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 +244,10 @@ 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)); } } @@ -217,22 +261,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 +285,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 +313,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 +350,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 +370,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 +392,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 +416,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 +443,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 +463,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)); } } @@ -469,20 +502,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 +530,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 +572,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 +587,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/ResponseSerializers.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/ResponseSerializers.java index 0b65c0c82a..00b7a19a87 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,26 +1,20 @@ 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.protocol.types.InstantiationException; +import gov.nasa.jpl.aerie.merlin.server.exceptions.MerlinFormattedError; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintId; 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; @@ -31,7 +25,6 @@ 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; @@ -201,46 +194,44 @@ public static JsonValue serializeBulkEffectiveArgumentResponse(BulkEffectiveArgu } 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(); + default -> Json.createObjectBuilder() + .add("success", JsonValue.FALSE) + .add("errors", String.format("Internal error: %s", response)) + .build(); + }; } public static JsonValue serializeCreatedDatasetId(final long datasetId) { @@ -250,15 +241,14 @@ 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(); + default -> throw new UnexpectedSubtypeError(MissionModelService.ActivityInstantiationFailure.class, reason); + }; } public static JsonValue serializeUnconstructableActivityFailures(final Map failures) { @@ -425,29 +415,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 +444,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/services/MissionModelService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/MissionModelService.java index 8419889566..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 @@ -94,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/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(); + } } } From e809b88d178693b896ba1d6035713524329fff83 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 3 Feb 2026 16:44:13 -0500 Subject: [PATCH 13/27] Update SchedulerBindings Repeat of changes done to Merlin. Specifically - Rename InvalidEntityException - Remove InvalidJsonException - Create and use FormattedError instead of ResponseSerializers --- .../nasa/jpl/aerie/json/FormattedError.java | 17 ++- .../aerie/permissions/PermissionsService.java | 4 +- scheduler-server/build.gradle | 2 + .../scheduler/server/SchedulerAppDriver.java | 1 - .../exceptions/SchedulerFormattedError.java | 36 +++++ .../server/http/InvalidEntityException.java | 14 -- .../http/InvalidJsonEntityException.java | 16 +++ .../server/http/InvalidJsonException.java | 7 - .../server/http/ResponseSerializers.java | 68 +--------- .../server/http/SchedulerBindings.java | 125 +++++++++++------- .../server/http/SchedulerParsers.java | 6 +- .../postgres/GetSpecificationGoalsAction.java | 5 +- .../GraphQLMerlinDatabaseService.java | 25 ++-- .../services/MerlinDatabaseService.java | 9 +- .../SchedulingDSLCompilationService.java | 17 +-- .../services/SynchronousSchedulerAgent.java | 49 ++++--- .../SchedulingDSLCompilationServiceTests.java | 6 - 17 files changed, 204 insertions(+), 203 deletions(-) create mode 100644 scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/SchedulerFormattedError.java delete mode 100644 scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidEntityException.java create mode 100644 scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidJsonEntityException.java delete mode 100644 scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/InvalidJsonException.java 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 index 70f09ee588..d2168bfe62 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -42,7 +43,7 @@ public enum AerieService { private final AerieService service; private Optional cause = Optional.empty(); private Optional trace = Optional.empty(); - private Optional data = Optional.empty(); + private Optional data = Optional.empty(); /** * For use in the event of an endpoint failing without throwing an exception. @@ -122,6 +123,20 @@ public FormattedError(AerieService service, String type, String message, String 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. + */ + 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); + } + + // region Constructors for specific exceptions // This helps `type` be consistent every time the exception is thrown. 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 867ebc4c98..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 @@ -47,8 +47,8 @@ public void check( try { final var planId = gqlService.getPlanIdFromSchedulingSpecificationId(specificationId); check(action, role, username, planId); - } catch (NoSuchSchedulingSpecificationException nsp) { - throw new PermissionsException(404, new PermissionsFormattedError(nsp)); + } catch (NoSuchSchedulingSpecificationException nss) { + throw new PermissionsException(404, new PermissionsFormattedError(nss)); } catch (GraphQLServiceException ex) { throw new PermissionsException(500, new PermissionsFormattedError(ex)); } catch (IOException io) { 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/SchedulerFormattedError.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/SchedulerFormattedError.java new file mode 100644 index 0000000000..774903f235 --- /dev/null +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/exceptions/SchedulerFormattedError.java @@ -0,0 +1,36 @@ +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.services.MerlinServiceException; + +@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); + } + + public SchedulerFormattedError(NoSuchPlanException npe) { + super(AerieService.SCHEDULER_SERVER, "NO_SUCH_PLAN", npe); + } + //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)); + } +} 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..22f1a92359 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,15 +6,11 @@ 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; @@ -238,66 +234,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..c77fe914ad 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,24 @@ 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.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 +59,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 @@ -73,6 +77,49 @@ 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( + 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 workspace request {}", fe); + ctx.status(500).json(fe); + }); } /** @@ -88,32 +135,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 +191,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 +210,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 +221,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 +233,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/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(); } From 5b25aee629a1675ad869f94ddcc2bbc6e8061a54 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 3 Feb 2026 17:09:38 -0500 Subject: [PATCH 14/27] Update endpoints added in rebase --- .../nasa/jpl/aerie/merlin/server/http/MerlinBindings.java | 6 ++---- .../merlin/server/remotes/postgres/GetConstraintAction.java | 6 ++---- .../jpl/aerie/merlin/server/services/ConstraintAction.java | 3 ++- .../server/services/ConstraintsDSLCompilationService.java | 4 +++- .../server/services/GenerateConstraintsLibAction.java | 3 ++- .../services/TypescriptCodeGenerationServiceAdapter.java | 5 +++-- .../services/TypescriptCodeGenerationServiceTest.java | 3 ++- 7 files changed, 16 insertions(+), 14 deletions(-) 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 2c5a07957d..68f626d4cc 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 @@ -479,10 +479,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)); } } 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/services/ConstraintAction.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintAction.java index ce1b42c137..d91bc0318f 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,6 +5,7 @@ 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.NoSuchPlanException; import gov.nasa.jpl.aerie.merlin.server.exceptions.SimulationDatasetMismatchException; @@ -251,7 +252,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/ConstraintsDSLCompilationService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationService.java index 33f186e0b5..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,6 +3,7 @@ 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.InvalidJsonEntityException; import gov.nasa.jpl.aerie.merlin.server.models.ConstraintsCompilationError; @@ -59,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() 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/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/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()); From 53ccb33b009daaa3a7108638c5424adb83747f82 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 3 Feb 2026 17:26:54 -0500 Subject: [PATCH 15/27] Remove object wrapping in NoSuchPlanException and NoSuchPlanDatasetException message --- .../merlin/server/exceptions/NoSuchPlanDatasetException.java | 2 +- .../jpl/aerie/merlin/server/exceptions/NoSuchPlanException.java | 2 +- .../jpl/aerie/permissions/exceptions/NoSuchPlanException.java | 2 +- .../aerie/scheduler/server/exceptions/NoSuchPlanException.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/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; } From b91f89de7ef0a7fd457a0f8d2653203315ca0e45 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 3 Feb 2026 17:49:24 -0500 Subject: [PATCH 16/27] Include the id of the missing object for "No such X"-type exceptions, --- .../exceptions/MerlinFormattedError.java | 48 +++++++++++++++++-- .../nasa/jpl/aerie/json/FormattedError.java | 17 +++++++ .../exceptions/PermissionsFormattedError.java | 19 ++++++-- .../exceptions/SchedulerFormattedError.java | 20 +++++++- .../server/WorkspaceFormattedError.java | 21 +++++++- .../postgres/NoSuchWorkspaceException.java | 7 +++ 6 files changed, 120 insertions(+), 12 deletions(-) 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 index fe0944237e..71e8929db9 100644 --- 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 @@ -8,6 +8,8 @@ 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 @@ -16,23 +18,59 @@ public class MerlinFormattedError extends FormattedError { // region "NO SUCH X" Exceptions public MerlinFormattedError(NoSuchPlanException npe) { - super(AerieService.MERLIN_SERVER, "NO_SUCH_PLAN", 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); + 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); + 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); + 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); + super( + AerieService.MERLIN_SERVER, + "NO_SUCH_ACTIVITY_TYPE", + message, + nae, + Json.createObjectBuilder() + .add("activity_type", nae.activityTypeId) + .build() + ); } // endregion 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 index d2168bfe62..3452291a9b 100644 --- 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 @@ -127,6 +127,7 @@ public FormattedError(AerieService service, String type, String message, String * 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; @@ -136,6 +137,22 @@ public FormattedError(AerieService service, String type, Exception ex, JsonValue 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. 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 index f2b0fafc1e..d7041fb3e9 100644 --- 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 @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import gov.nasa.jpl.aerie.json.FormattedError; +import javax.json.Json; import java.io.IOException; /** @@ -17,21 +18,33 @@ 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); + 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); + 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); + nse, + Json.createObjectBuilder() + .add("workspace_id", nse.id.id()) + .build() + ); } // IOException 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 index 774903f235..a886ba54c6 100644 --- 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 @@ -6,15 +6,31 @@ import gov.nasa.jpl.aerie.scheduler.server.models.SchedulingCompilationError; 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); + 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); + super( + AerieService.SCHEDULER_SERVER, + "NO_SUCH_PLAN", + npe, + Json.createObjectBuilder() + .add("plan_id", npe.getInvalidPlanId().id()) + .build() + ); } //endregion 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 index 2181e6521b..aefee24660 100644 --- 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 @@ -8,6 +8,8 @@ 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 @@ -16,10 +18,25 @@ final class WorkspaceFormattedError extends FormattedError { // NoSuchWorkspace public WorkspaceFormattedError(NoSuchWorkspaceException nse) { - super(AerieService.WORKSPACE_SERVER, "NO_SUCH_WORKSPACE", 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); + super( + AerieService.WORKSPACE_SERVER, + "NO_SUCH_WORKSPACE", + message, + nse, + Json.createObjectBuilder() + .add("workspace_id", nse.getWorkspaceId()) + .build() + ); } // NoSuchFile 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; } } From 013be917dea8183ad02ed9a079b12172029eac03 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 3 Feb 2026 18:02:10 -0500 Subject: [PATCH 17/27] Update E2E Tests --- .../gov/nasa/jpl/aerie/e2e/AutomaticValidationTests.java | 4 ++-- .../test/java/gov/nasa/jpl/aerie/e2e/ConstraintsTests.java | 3 ++- .../e2e/procedural/scheduling/BasicSchedulingTests.java | 2 +- .../gov/nasa/jpl/aerie/e2e/types/ActivityValidation.java | 6 ++++-- 4 files changed, 9 insertions(+), 6 deletions(-) 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..197e0d5368 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 @@ -98,7 +98,8 @@ void constraintsFailNoSimData() { 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")) { + final var expectedMessage = "plan with id " + planId + " has not yet been simulated at its current revision"; + if (!message.equals(expectedMessage)) { throw exception; } } 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); }; From 897e0541bc979da7c4bac5bf0426204076bfcb97 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Mon, 2 Mar 2026 17:44:09 -0500 Subject: [PATCH 18/27] Update Worker AppDrivers to Format Errors --- .../merlin/worker/MerlinWorkerAppDriver.java | 16 +++++++++++----- .../gov/nasa/jpl/aerie/json/FormattedError.java | 8 +++++--- .../worker/SchedulerWorkerAppDriver.java | 13 ++++++++++--- 3 files changed, 26 insertions(+), 11 deletions(-) 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/src/main/java/gov/nasa/jpl/aerie/json/FormattedError.java b/parsing-utilities/src/main/java/gov/nasa/jpl/aerie/json/FormattedError.java index 3452291a9b..af5c5e4584 100644 --- 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 @@ -30,7 +30,9 @@ public enum AerieService { MERLIN_SERVER("aerie_merlin"), SCHEDULER_SERVER("aerie_scheduler"), WORKSPACE_SERVER("aerie_workspace"), - PERMISSIONS_SERVICE("aerie_permissions"); + PERMISSIONS_SERVICE("aerie_permissions"), + SIMULATION_WORKER("aerie_merlin_worker"), + SCHEDULER_WORKER("aerie_scheduler_worker"); private final String serviceName; AerieService(String serviceName) { this.serviceName = serviceName; } @@ -98,7 +100,7 @@ public FormattedError(AerieService service, String type, Exception ex) { * Should be human-readable and between 1-2 sentences. * @param ex the exception to be formatted. */ - public FormattedError(AerieService service, String type, String message, Exception ex) { + public FormattedError(AerieService service, String type, String message, Throwable ex) { this.type = type; this.message = message; this.service = service; @@ -227,7 +229,7 @@ public FormattedError(AerieService service, SecurityException se) { /** * Generate a stack trace string from an Exception. */ - protected static String generateTrace(Exception ex) { + private String generateTrace(Throwable ex) { final var sw = new StringWriter(); try(final var pw = new PrintWriter(sw)) { ex.printStackTrace(pw); 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(); From 2be97fa18ebeada5ace9d720b675bd38fdc66daf Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Mon, 2 Mar 2026 18:02:31 -0500 Subject: [PATCH 19/27] Remove Swallowed NoSuchConstraintException, ProcedureLoadException Catch DatabaseException in `apply`. This exception should be revisited, as it's one of three wrappers around SQLException that are classified as "RuntimeExceptions" --- .../server/exceptions/MerlinFormattedError.java | 14 ++++++++++++++ .../aerie/merlin/server/http/MerlinBindings.java | 12 ++++++++++++ .../merlin/server/services/ConstraintAction.java | 5 ++++- .../merlin/server/services/ConstraintService.java | 5 ++++- .../server/services/LocalConstraintService.java | 12 +++--------- .../server/exceptions/SchedulerFormattedError.java | 5 +++++ .../scheduler/server/http/SchedulerBindings.java | 7 +++++++ 7 files changed, 49 insertions(+), 11 deletions(-) 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 index 71e8929db9..6d91e12ecc 100644 --- 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 @@ -5,6 +5,8 @@ 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; @@ -72,6 +74,10 @@ public MerlinFormattedError(NoSuchActivityTypeException nae, String message) { .build() ); } + + public MerlinFormattedError(NoSuchConstraintException ex) { + super(AerieService.MERLIN_SERVER, "NO_SUCH_CONSTRAINT", ex); + } // endregion public MerlinFormattedError(MissionModelLoadException mle) { @@ -89,4 +95,12 @@ public MerlinFormattedError(InputMismatchException 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/http/MerlinBindings.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java index 68f626d4cc..180f23eae6 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 @@ -4,6 +4,9 @@ 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; @@ -149,6 +152,11 @@ public void apply(final Javalin javalin) { 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( @@ -248,6 +256,10 @@ private void refreshConstrainProcedureParameterTypes(final Context ctx) { 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)); } } 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 d91bc0318f..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 @@ -7,6 +7,7 @@ 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; @@ -40,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); } 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/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/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 index a886ba54c6..14f966ce7c 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -49,4 +50,8 @@ public SchedulerFormattedError(SpecificationLoadException ex) { ex, SchedulingCompilationError.schedulingErrorJsonP.unparse(ex.errors)); } + + public SchedulerFormattedError(DatabaseException ex) { + super(AerieService.MERLIN_SERVER, "DATABASE_EXCEPTION", ex); + } } 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 c77fe914ad..6266f6dca3 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 @@ -25,6 +25,7 @@ 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; @@ -94,6 +95,12 @@ public void apply(final Javalin javalin) { 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"; From f7f56ec3d28cb662a86cdf8e109d54affb12a34c Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Mon, 2 Mar 2026 18:13:30 -0500 Subject: [PATCH 20/27] Apply `EffectModel` Annotation to ExceptionActivity's EffectModel --- .../jpl/aerie/banananation/activities/ExceptionActivity.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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"); } From 7e3f9a85feb476e4fa74b31ad1b2bdd414d86c52 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Wed, 13 May 2026 16:25:15 -0400 Subject: [PATCH 21/27] Merlin: Use Switch Pattern Matching for new Serializers --- .../server/http/ResponseSerializers.java | 142 ++++++++---------- ...lkConstraintEffectiveArgumentResponse.java | 1 + 2 files changed, 62 insertions(+), 81 deletions(-) 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 00b7a19a87..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 @@ -3,7 +3,6 @@ import gov.nasa.jpl.aerie.constraints.model.ConstraintResult; 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.ConstraintId; 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; @@ -12,13 +11,11 @@ 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.models.ConstraintsCompilationError; -import gov.nasa.jpl.aerie.merlin.server.models.ProcedureLoader; 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.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; @@ -115,82 +112,70 @@ 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) { @@ -227,10 +212,6 @@ public static JsonValue serializeBulkArgumentValidationResponse(BulkArgumentVali .add("noSuchMissionModelError", new MerlinFormattedError(m.ex()).toJson()) .build()) .build(); - default -> Json.createObjectBuilder() - .add("success", JsonValue.FALSE) - .add("errors", String.format("Internal error: %s", response)) - .build(); }; } @@ -247,7 +228,6 @@ private static JsonValue serializeUnconstructableActivityFailure(final MissionMo builder.add("reason", serializeInstantiationException(r.ex())).build(); case MissionModelService.ActivityInstantiationFailure.NoSuchActivityType r -> builder.add("reason", new MerlinFormattedError(r.ex()).toJson()).build(); - default -> throw new UnexpectedSubtypeError(MissionModelService.ActivityInstantiationFailure.class, reason); }; } 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 { } From 8505a7fe947bc315161088b7b2220266cc8f3c4c Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Wed, 13 May 2026 16:25:21 -0400 Subject: [PATCH 22/27] Scheduling: Use Switch Pattern Matching for new Serializers --- .../http/BulkEffectiveArgumentResponse.java | 1 + .../server/http/ResponseSerializers.java | 151 ++++++++---------- 2 files changed, 67 insertions(+), 85 deletions(-) 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/ResponseSerializers.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/http/ResponseSerializers.java index 22f1a92359..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 @@ -9,13 +9,10 @@ import gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException; import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; -import gov.nasa.jpl.aerie.scheduler.ProcedureLoader; -import gov.nasa.jpl.aerie.scheduler.server.exceptions.NoSuchSchedulingGoalException; 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; @@ -49,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) { @@ -93,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) { From c7d14983f38b392aac3db688e09b9fc3e27aa62d Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Wed, 13 May 2026 16:32:35 -0400 Subject: [PATCH 23/27] Merlin: Additional InvalidEntityException -> InvalidJsonEntityException renames --- .../server/remotes/postgres/ExternalEventRepository.java | 6 +++--- .../remotes/postgres/GetExternalSourcesMapAction.java | 6 +++--- .../remotes/postgres/GetPlanExternalEventsAction.java | 6 +++--- .../server/remotes/postgres/PostgresPlanRepository.java | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) 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/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/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/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); From 596e1096b214467de5bf641702e5a2fdbaf2d1a0 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Thu, 14 May 2026 15:15:50 -0400 Subject: [PATCH 24/27] Add a per-service toggle to write FormattedErrors in a digestable way for Hasura --- .../aerie/merlin/server/http/MerlinBindings.java | 3 +++ .../gov/nasa/jpl/aerie/json/FormattedError.java | 14 +++++++++++++- .../scheduler/server/http/SchedulerBindings.java | 3 +++ .../aerie/workspace/server/WorkspaceBindings.java | 3 +++ 4 files changed, 22 insertions(+), 1 deletion(-) 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 180f23eae6..fb6ad27d7f 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 @@ -98,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")); 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 index af5c5e4584..a3e5c8268d 100644 --- 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 @@ -270,13 +270,25 @@ public String toString() { * 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 { - jsonGenerator.writeRaw(formattedError.toString()); + // 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/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 6266f6dca3..b916e881f5 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 @@ -69,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")); 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 b37d2e58ae..9131446bbf 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 @@ -92,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 From cb29393ac8bc71d9bf78693b3fc7b9871f301417 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Mon, 18 May 2026 11:39:09 -0400 Subject: [PATCH 25/27] Update E2ETests --- .../nasa/jpl/aerie/e2e/ConstraintsTests.java | 18 ++-- .../e2e/bindings/MerlinBindingsTests.java | 94 +++++++++++-------- .../e2e/bindings/SchedulerBindingsTests.java | 17 +++- .../MetadataWorkspaceRoutesTests.java | 18 ++-- .../workspace/WorkspaceAuthorizeTests.java | 4 +- .../WorkspaceManagementRoutesTests.java | 6 +- .../workspaces/HasuraRequestFailure.java | 27 ++++++ .../jpl/aerie/e2e/utils/HasuraRequests.java | 3 +- 8 files changed, 121 insertions(+), 66 deletions(-) create mode 100644 e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/workspaces/HasuraRequestFailure.java 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 197e0d5368..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,14 +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 + 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"; - if (!message.equals(expectedMessage)) { - throw exception; - } + 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/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"); } From 6e0cba7948efb37012fbe5151748624e7b9e98d5 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 19 May 2026 12:08:42 -0400 Subject: [PATCH 26/27] Update log message for catch-all exceptions --- .../gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java | 2 +- .../nasa/jpl/aerie/scheduler/server/http/SchedulerBindings.java | 2 +- .../gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 fb6ad27d7f..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 @@ -169,7 +169,7 @@ public void apply(final Javalin javalin) { // 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 workspace request {}", fe); + logger.error("Unexpected error processing request: {}", fe); ctx.status(500).json(fe); }); } 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 b916e881f5..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 @@ -127,7 +127,7 @@ public void apply(final Javalin javalin) { // 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 workspace request {}", fe); + logger.error("Unexpected error processing request: {}", fe); ctx.status(500).json(fe); }); } 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 9131446bbf..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 @@ -180,7 +180,7 @@ public void apply(final Javalin javalin) { // Catch-all for unexpected issues final var message = ex.getMessage() != null ? ex.getMessage() : "Unknown error."; final var fe = new FormattedError(AerieService.WORKSPACE_SERVER, "UNKNOWN_ERROR", message, ex); - logger.error("Unexpected error processing workspace request {}", fe); + logger.error("Unexpected error processing request: {}", fe); ctx.status(500).json(fe); }); } From 62e60eadbba3eef5e70fd0883dd7dd8a1a0fae2b Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 19 May 2026 13:57:33 -0400 Subject: [PATCH 27/27] Fix typo --- .../scheduler/server/exceptions/SchedulerFormattedError.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 14f966ce7c..d5aaf29d07 100644 --- 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 @@ -52,6 +52,6 @@ public SchedulerFormattedError(SpecificationLoadException ex) { } public SchedulerFormattedError(DatabaseException ex) { - super(AerieService.MERLIN_SERVER, "DATABASE_EXCEPTION", ex); + super(AerieService.SCHEDULER_SERVER, "DATABASE_EXCEPTION", ex); } }