Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cd6304c
Reorder file and add "region" tags
Mythicaeda Oct 30, 2025
83b4a71
Implement bulk endpoints
Mythicaeda Oct 30, 2025
dbf6156
Rename "filePath" param to more generic "path"
Mythicaeda Oct 30, 2025
4f3752a
Use new type "ItemType" in single-item "put"
Mythicaeda Oct 30, 2025
d226a61
Forbid providing both "moveTo" and "copyTo" in single-item POST
Mythicaeda Oct 30, 2025
f75920b
Make bulkPost and Post behavior align
Mythicaeda Oct 30, 2025
b268a6d
Make delete directory recursive
Mythicaeda Dec 1, 2025
363da04
Fix bulk move and bulk copy
Mythicaeda Dec 1, 2025
a72b0bd
Ensure that uploaded files have unique names in bulk PUT
Mythicaeda Dec 4, 2025
a2d7223
Fix bug preventing file overwrite in move and copy
Mythicaeda Dec 4, 2025
e373a45
Fix PUT multipart check
Mythicaeda Dec 4, 2025
1c562b6
Add Basic Bulk E2E Tests
Mythicaeda Dec 1, 2025
b1cf807
E2E Tests for Bulk POST Overwrite
Mythicaeda Dec 4, 2025
131d89c
Report SecurityException if thrown
Mythicaeda Dec 8, 2025
a1aca4f
Rename 'BulkPut' to 'BulkUpload'
Mythicaeda Dec 8, 2025
e8a3eb3
Update input body for BulkPost
Mythicaeda Dec 8, 2025
7401d9e
Fix whitespace PostBody
Mythicaeda Dec 8, 2025
78d17b8
Fix metadata files not being deleted
Mythicaeda Dec 8, 2025
4ea7455
BulkPut/BulkPost - ensure that all destination paths are unique
Mythicaeda Dec 8, 2025
982ba75
Update Workspace E2E tests to reflect Post input change
Mythicaeda Dec 9, 2025
870dca9
Specify MALFORMED_REQUEST instead of INTERNAL_ERROR when the request …
Mythicaeda Dec 11, 2025
36f6951
Fix incorrect return structure in bulkUpload
Mythicaeda Dec 11, 2025
d22bdb0
Ensure that the user has specified items to alter in BulkPost
Mythicaeda Dec 11, 2025
eccec54
Finish Bulk E2E Tests
Mythicaeda Dec 11, 2025
2bb8672
Check for sibling directory cases in BulkTests
Mythicaeda Dec 16, 2025
bee0bb6
Validate file and folder names to avoid illegal characters/reserved f…
Mythicaeda Dec 16, 2025
7c28615
Use non-500 handler for Javalin HTTPResponseExceptions
Mythicaeda Dec 18, 2025
6290a9a
Create method to resolve a path for writing and validate it
Mythicaeda Dec 18, 2025
bec83d3
Log when a 500 is returned by the server
Mythicaeda Dec 18, 2025
9542e74
Extract Duplicate Logic for Upload and Delete
Mythicaeda Dec 18, 2025
1a76ccb
Rename handler methods
Mythicaeda Dec 18, 2025
a251a28
Properly extract JsonString results
Mythicaeda Dec 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e-tests/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ dependencies {
testImplementation "com.zaxxer:HikariCP:5.1.0"
testImplementation("org.postgresql:postgresql:42.6.0")

testImplementation 'com.microsoft.playwright:playwright:1.37.0'
testImplementation 'com.microsoft.playwright:playwright:1.55.0'

testImplementation 'org.glassfish:javax.json:1.1.4'
testImplementation 'org.apache.commons:commons-lang3:3.13.0'
Expand Down
1,677 changes: 1,675 additions & 2 deletions e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/BindingsTests.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package gov.nasa.jpl.aerie.e2e.types.workspaces;


import com.microsoft.playwright.options.FilePayload;

import javax.json.Json;
import javax.json.JsonObject;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Optional;

/**
* A well-formatted input for one item to be created via the bulk-put Workspace Server endpoint.
* If testing malformed inputs, generate the request in the test itself.
*/
public sealed interface BulkPutItem {
JsonObject toJson();
Path getPath();

/**
* Input to create a file
* @param filePath Where to upload the file to in the workspace
* @param fileContents What to put in the file
* @param inputFileName If provided, the file contents will be added to the request under this name instead of the file name.
* @param overwrite If provided, what to set the `overwrite` flag to.
*/
record FileBulkPutItem(Path filePath, String fileContents, Optional<String> inputFileName, Optional<Boolean> overwrite) implements BulkPutItem {
public FileBulkPutItem(Path filePath, String contents) {
this(filePath, contents, Optional.empty(), Optional.empty());
}

public FileBulkPutItem(String filePath, String contents) {
this(Path.of(filePath), contents, Optional.empty(), Optional.empty());
}

public FileBulkPutItem(Path filePath, String contents, String inputFileName) {
this(filePath, contents, Optional.ofNullable(inputFileName), Optional.empty());
}

public FileBulkPutItem(Path filePath, String contents, boolean overwrite) {
this(filePath, contents, Optional.empty(), Optional.of(overwrite));
}

public FileBulkPutItem(Path filePath, String contents, String inputFileName, boolean overwrite) {
this(filePath, contents, Optional.of(inputFileName), Optional.of(overwrite));
}

public FilePayload generateFilePayload() {
if (inputFileName().isPresent()) {
return new FilePayload(
inputFileName.get(),
"text/plain",
fileContents.getBytes(StandardCharsets.UTF_8)
);
}
return new FilePayload(
filePath.getFileName().toString(),
"text/plain",
fileContents.getBytes(StandardCharsets.UTF_8)
);
}

public JsonObject toJson() {
final var obj = Json.createObjectBuilder()
.add("path", filePath.toString())
.add("type", "file");

inputFileName.ifPresent(i -> obj.add("input_file_name", i));
overwrite.ifPresent(o -> obj.add("overwrite", o));

return obj.build();
}

public Path getPath() {return filePath;}
}

/**
* Input to create a directory
* @param dirPath Path to create the folder at
* @param useFolder If true, uses 'folder' as the type instead of 'directory'. Defaults to false.
*/
record DirectoryBulkPutItem(Path dirPath, boolean useFolder) implements BulkPutItem {
public DirectoryBulkPutItem(Path dirPath) {
this(dirPath, false);
}

public DirectoryBulkPutItem(String dirPath) {
this(Path.of(dirPath), false);
}

public JsonObject toJson() {
final var obj = Json.createObjectBuilder()
.add("path", dirPath.toString());

if(useFolder) {
obj.add("type", "folder");
} else {
obj.add("type", "directory");
}

return obj.build();
}

public Path getPath() {return dirPath;}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
import com.microsoft.playwright.options.FilePayload;
import com.microsoft.playwright.options.FormData;
import com.microsoft.playwright.options.RequestOptions;
import gov.nasa.jpl.aerie.e2e.types.workspaces.BulkPutItem;

import javax.json.Json;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;

Expand Down Expand Up @@ -185,12 +187,154 @@ public void deleteWorkspace(int workspaceId) throws IOException {
* @return the APIResponse from the Workspace Server
*/
public APIResponse deleteWorkspace(String authToken, int workspaceId) {
final var options = RequestOptions.create()
.setHeader("Authorization", "Bearer "+authToken);
final var options = RequestOptions.create().setHeader("Authorization", "Bearer "+authToken);
return request.delete("/ws/%d".formatted(workspaceId), options);
}


/**
* Call the GET endpoint in the Workspace Server
* @param token The JWT token for the user making the request
* @param workspaceId The workspace the item is in
* @param itemPath The Path within the workspace where the item is
* @return The APIResponse from the server
*/
public APIResponse get(String token, int workspaceId, Path itemPath) {
final var options = RequestOptions.create().setHeader("Authorization", "Bearer " + token);
return request.get("/ws/%d/%s".formatted(workspaceId, itemPath.toString()), options);
}

/**
* Call the 'Bulk PUT' endpoint in the Workspace server.
* @param token The JWT token for the user making the request
* @param workspaceId The workspace to insert the file into
* @param toPut List of things to be placed on the server. If there are file contents, it will be uploaded as file.
* If the Optional is empty, it will be uploaded as a directory.
* @return The APIResponse from the server
*/
public APIResponse bulkPut(String token, int workspaceId, List<BulkPutItem> toPut) {
final var formData = FormData.create();
final var bodyArray = Json.createArrayBuilder();

// Generate the request body
for(final var putItem : toPut) {
bodyArray.add(putItem.toJson());
if(putItem instanceof BulkPutItem.FileBulkPutItem fileInput) {
formData.append("files", fileInput.generateFilePayload());
}
}

// Generate the request
final var options = RequestOptions
.create()
.setHeader("Authorization", "Bearer "+token)
.setMultipart(formData.set("body", bodyArray.build().toString()));

return request.put("/ws/bulk/%d".formatted(workspaceId), options);
}

/**
* Call the 'Bulk POST' endpoint in the Workspace server to move items.
*
* @param token The JWT token for the user making the request
* @param workspaceId The source workspace
* @param paths The list of items to be affected by the request
* @param destination The destination folder to place the items in
* @param destinationWorkspaceId If present, the destination workspace.
* @param overwrite If present, the value of the 'overwrite' flag
* @return The APIResponse from the server
*/
public APIResponse bulkMove(
String token,
int workspaceId,
List<Path> paths,
Path destination,
Optional<Integer> destinationWorkspaceId,
Optional<Boolean> overwrite
) {
// Generate the request body
final var body = Json.createObjectBuilder().add("moveTo", destination.toString());

final var itemsArray = Json.createArrayBuilder();
paths.forEach(p -> itemsArray.add(Json.createObjectBuilder().add("path", p.toString())));
body.add("items", itemsArray);

destinationWorkspaceId.ifPresent(wid -> body.add("toWorkspace", wid));

overwrite.ifPresent(o -> body.add("overwrite", o));

// Generate request
final var options = RequestOptions
.create()
.setHeader("Authorization", "Bearer "+token)
.setHeader("Content-type", "application/json")
.setData(body.build().toString());

return request.post("/ws/bulk/%d".formatted(workspaceId), options);
}

/**
* Call the 'Bulk POST' endpoint in the Workspace server to copy items.
* @param token The JWT token for the user making the request
* @param workspaceId The source workspace
* @param paths The list of items to be affected by the request
* @param destination The destination folder to place the items in
* @param destinationWorkspaceId If present, the destination workspace.
* @param overwrite If present, the value of the 'overwrite' flag
* @return The APIResponse from the server
*/
public APIResponse bulkCopy(
String token,
int workspaceId,
List<Path> paths,
Path destination,
Optional<Integer> destinationWorkspaceId,
Optional<Boolean> overwrite
) {
// Generate the request body
final var body = Json.createObjectBuilder().add("copyTo", destination.toString());

final var itemsArray = Json.createArrayBuilder();
paths.forEach(p -> itemsArray.add(Json.createObjectBuilder().add("path", p.toString())));
body.add("items", itemsArray);

destinationWorkspaceId.ifPresent(wid -> body.add("toWorkspace", wid));

overwrite.ifPresent(o -> body.add("overwrite", o));

// Generate request
final var options = RequestOptions
.create()
.setHeader("Authorization", "Bearer "+token)
.setHeader("Content-type", "application/json")
.setData(body.build().toString());

return request.post("/ws/bulk/%d".formatted(workspaceId), options);
}


/**
* Call the 'Bulk DELETE' endpoint in the Workspace server.
* @param token The JWT token for the user making the request
* @param workspaceId The source workspace
* @param paths The list of items to be deleted
* @return The APIResponse from the server
*/
public APIResponse bulkDelete(String token, int workspaceId, List<Path> paths) {
// Generate the request body
final var body = Json.createArrayBuilder();
paths.forEach(p -> body.add(p.toString()));

// Generate request
final var options = RequestOptions
.create()
.setHeader("Authorization", "Bearer "+token)
.setHeader("Content-type", "application/json")
.setData(body.build().toString());

return request.delete("/ws/bulk/%d".formatted(workspaceId), options);
}

@Override
public void close() {
request.dispose();
Expand Down
1 change: 1 addition & 0 deletions workspace-server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies {
implementation 'com.zaxxer:HikariCP:5.0.1'

testImplementation 'org.junit.jupiter:junit-jupiter-engine:6.0.1'
testImplementation 'org.junit.jupiter:junit-jupiter-params:6.0.1'
testImplementation 'net.jqwik:jqwik:1.6.5'

testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ public FormattedError(String message) {
this.message = message;
}

/**
* For use in the event of an endpoint failing without throwing an exception, but where there's a more detailed cause.
*/
public FormattedError(String message, String cause) {
this.type = "INTERNAL_ERROR";
this.message = message;
this.cause = Optional.ofNullable(cause);
}

/**
* For use in the event of an endpoint failing without throwing an exception,
* but "INTERNAL_ERROR" does not make sense as the error type (i.e. the request is malformed)
*/
public FormattedError(String type, String message, Optional<String> cause) {
this.type = type;
this.message = message;
this.cause = cause;
}

/**
* Create a FormattedException from a generic Exception object.
* @param type the category of exception. Should be in SCREAMING_SNAKE_CASE
Expand Down Expand Up @@ -135,8 +154,8 @@ public FormattedError(NumberFormatException nfe) {
}

// IllegalArgumentException
public FormattedError(IllegalArgumentException iae, String message) {
this("ILLEGAL_ARGUMENT", message, iae);
public FormattedError(IllegalArgumentException iae) {
this("ILLEGAL_ARGUMENT", iae);
}

// JSONException
Expand All @@ -150,6 +169,16 @@ public FormattedError(ValidationException ve) {
this.message = ve.getMessage() != null ? ve.getMessage() : "Invalid request";
trace = Optional.of(generateTrace(ve));
}

// Null Pointer Exception
public FormattedError(NullPointerException ne, String message) {
this("NULL_POINTER_EXCEPTION", message, ne);
}

//Security Exception
public FormattedError(SecurityException se) {
this("SECURITY_EXCEPTION", se.getMessage(), se);
}
//endregion

/**
Expand Down Expand Up @@ -182,6 +211,11 @@ public JsonObject toJson() {
return builder.build();
}

@Override
public String toString() {
return this.toJson().toString();
}

/**
* Internal class so that Javalin serializes the FormattedError class using its `toJson` method.
* This avoids needing to call `toJson` every time the FormattedError class is used as an endpoint return.
Expand All @@ -194,7 +228,7 @@ public void serialize(
final JsonGenerator jsonGenerator,
final SerializerProvider serializerProvider) throws IOException
{
jsonGenerator.writeRaw(formattedError.toJson().toString());
jsonGenerator.writeRaw(formattedError.toString());
}
}
}
Loading
Loading