Skip to content

Commit c960f5c

Browse files
authored
Merge pull request #1760 from NASA-AMMOS/feat/workspace-bulk-endpoints
Create Bulk Endpoint for Workspaces
2 parents 9ea0566 + a251a28 commit c960f5c

15 files changed

Lines changed: 3081 additions & 291 deletions

File tree

e2e-tests/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ dependencies {
6868
testImplementation "com.zaxxer:HikariCP:5.1.0"
6969
testImplementation("org.postgresql:postgresql:42.6.0")
7070

71-
testImplementation 'com.microsoft.playwright:playwright:1.37.0'
71+
testImplementation 'com.microsoft.playwright:playwright:1.55.0'
7272

7373
testImplementation 'org.glassfish:javax.json:1.1.4'
7474
testImplementation 'org.apache.commons:commons-lang3:3.13.0'

e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/BindingsTests.java

Lines changed: 1675 additions & 2 deletions
Large diffs are not rendered by default.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package gov.nasa.jpl.aerie.e2e.types.workspaces;
2+
3+
4+
import com.microsoft.playwright.options.FilePayload;
5+
6+
import javax.json.Json;
7+
import javax.json.JsonObject;
8+
import java.nio.charset.StandardCharsets;
9+
import java.nio.file.Path;
10+
import java.util.Optional;
11+
12+
/**
13+
* A well-formatted input for one item to be created via the bulk-put Workspace Server endpoint.
14+
* If testing malformed inputs, generate the request in the test itself.
15+
*/
16+
public sealed interface BulkPutItem {
17+
JsonObject toJson();
18+
Path getPath();
19+
20+
/**
21+
* Input to create a file
22+
* @param filePath Where to upload the file to in the workspace
23+
* @param fileContents What to put in the file
24+
* @param inputFileName If provided, the file contents will be added to the request under this name instead of the file name.
25+
* @param overwrite If provided, what to set the `overwrite` flag to.
26+
*/
27+
record FileBulkPutItem(Path filePath, String fileContents, Optional<String> inputFileName, Optional<Boolean> overwrite) implements BulkPutItem {
28+
public FileBulkPutItem(Path filePath, String contents) {
29+
this(filePath, contents, Optional.empty(), Optional.empty());
30+
}
31+
32+
public FileBulkPutItem(String filePath, String contents) {
33+
this(Path.of(filePath), contents, Optional.empty(), Optional.empty());
34+
}
35+
36+
public FileBulkPutItem(Path filePath, String contents, String inputFileName) {
37+
this(filePath, contents, Optional.ofNullable(inputFileName), Optional.empty());
38+
}
39+
40+
public FileBulkPutItem(Path filePath, String contents, boolean overwrite) {
41+
this(filePath, contents, Optional.empty(), Optional.of(overwrite));
42+
}
43+
44+
public FileBulkPutItem(Path filePath, String contents, String inputFileName, boolean overwrite) {
45+
this(filePath, contents, Optional.of(inputFileName), Optional.of(overwrite));
46+
}
47+
48+
public FilePayload generateFilePayload() {
49+
if (inputFileName().isPresent()) {
50+
return new FilePayload(
51+
inputFileName.get(),
52+
"text/plain",
53+
fileContents.getBytes(StandardCharsets.UTF_8)
54+
);
55+
}
56+
return new FilePayload(
57+
filePath.getFileName().toString(),
58+
"text/plain",
59+
fileContents.getBytes(StandardCharsets.UTF_8)
60+
);
61+
}
62+
63+
public JsonObject toJson() {
64+
final var obj = Json.createObjectBuilder()
65+
.add("path", filePath.toString())
66+
.add("type", "file");
67+
68+
inputFileName.ifPresent(i -> obj.add("input_file_name", i));
69+
overwrite.ifPresent(o -> obj.add("overwrite", o));
70+
71+
return obj.build();
72+
}
73+
74+
public Path getPath() {return filePath;}
75+
}
76+
77+
/**
78+
* Input to create a directory
79+
* @param dirPath Path to create the folder at
80+
* @param useFolder If true, uses 'folder' as the type instead of 'directory'. Defaults to false.
81+
*/
82+
record DirectoryBulkPutItem(Path dirPath, boolean useFolder) implements BulkPutItem {
83+
public DirectoryBulkPutItem(Path dirPath) {
84+
this(dirPath, false);
85+
}
86+
87+
public DirectoryBulkPutItem(String dirPath) {
88+
this(Path.of(dirPath), false);
89+
}
90+
91+
public JsonObject toJson() {
92+
final var obj = Json.createObjectBuilder()
93+
.add("path", dirPath.toString());
94+
95+
if(useFolder) {
96+
obj.add("type", "folder");
97+
} else {
98+
obj.add("type", "directory");
99+
}
100+
101+
return obj.build();
102+
}
103+
104+
public Path getPath() {return dirPath;}
105+
}
106+
}

e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/WorkspaceRequests.java

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
import com.microsoft.playwright.options.FilePayload;
88
import com.microsoft.playwright.options.FormData;
99
import com.microsoft.playwright.options.RequestOptions;
10+
import gov.nasa.jpl.aerie.e2e.types.workspaces.BulkPutItem;
1011

1112
import javax.json.Json;
1213
import java.io.IOException;
1314
import java.nio.charset.StandardCharsets;
1415
import java.nio.file.Path;
16+
import java.util.List;
1517
import java.util.Map;
1618
import java.util.Optional;
1719

@@ -185,12 +187,154 @@ public void deleteWorkspace(int workspaceId) throws IOException {
185187
* @return the APIResponse from the Workspace Server
186188
*/
187189
public APIResponse deleteWorkspace(String authToken, int workspaceId) {
188-
final var options = RequestOptions.create()
189-
.setHeader("Authorization", "Bearer "+authToken);
190+
final var options = RequestOptions.create().setHeader("Authorization", "Bearer "+authToken);
190191
return request.delete("/ws/%d".formatted(workspaceId), options);
191192
}
192193

193194

195+
/**
196+
* Call the GET endpoint in the Workspace Server
197+
* @param token The JWT token for the user making the request
198+
* @param workspaceId The workspace the item is in
199+
* @param itemPath The Path within the workspace where the item is
200+
* @return The APIResponse from the server
201+
*/
202+
public APIResponse get(String token, int workspaceId, Path itemPath) {
203+
final var options = RequestOptions.create().setHeader("Authorization", "Bearer " + token);
204+
return request.get("/ws/%d/%s".formatted(workspaceId, itemPath.toString()), options);
205+
}
206+
207+
/**
208+
* Call the 'Bulk PUT' endpoint in the Workspace server.
209+
* @param token The JWT token for the user making the request
210+
* @param workspaceId The workspace to insert the file into
211+
* @param toPut List of things to be placed on the server. If there are file contents, it will be uploaded as file.
212+
* If the Optional is empty, it will be uploaded as a directory.
213+
* @return The APIResponse from the server
214+
*/
215+
public APIResponse bulkPut(String token, int workspaceId, List<BulkPutItem> toPut) {
216+
final var formData = FormData.create();
217+
final var bodyArray = Json.createArrayBuilder();
218+
219+
// Generate the request body
220+
for(final var putItem : toPut) {
221+
bodyArray.add(putItem.toJson());
222+
if(putItem instanceof BulkPutItem.FileBulkPutItem fileInput) {
223+
formData.append("files", fileInput.generateFilePayload());
224+
}
225+
}
226+
227+
// Generate the request
228+
final var options = RequestOptions
229+
.create()
230+
.setHeader("Authorization", "Bearer "+token)
231+
.setMultipart(formData.set("body", bodyArray.build().toString()));
232+
233+
return request.put("/ws/bulk/%d".formatted(workspaceId), options);
234+
}
235+
236+
/**
237+
* Call the 'Bulk POST' endpoint in the Workspace server to move items.
238+
*
239+
* @param token The JWT token for the user making the request
240+
* @param workspaceId The source workspace
241+
* @param paths The list of items to be affected by the request
242+
* @param destination The destination folder to place the items in
243+
* @param destinationWorkspaceId If present, the destination workspace.
244+
* @param overwrite If present, the value of the 'overwrite' flag
245+
* @return The APIResponse from the server
246+
*/
247+
public APIResponse bulkMove(
248+
String token,
249+
int workspaceId,
250+
List<Path> paths,
251+
Path destination,
252+
Optional<Integer> destinationWorkspaceId,
253+
Optional<Boolean> overwrite
254+
) {
255+
// Generate the request body
256+
final var body = Json.createObjectBuilder().add("moveTo", destination.toString());
257+
258+
final var itemsArray = Json.createArrayBuilder();
259+
paths.forEach(p -> itemsArray.add(Json.createObjectBuilder().add("path", p.toString())));
260+
body.add("items", itemsArray);
261+
262+
destinationWorkspaceId.ifPresent(wid -> body.add("toWorkspace", wid));
263+
264+
overwrite.ifPresent(o -> body.add("overwrite", o));
265+
266+
// Generate request
267+
final var options = RequestOptions
268+
.create()
269+
.setHeader("Authorization", "Bearer "+token)
270+
.setHeader("Content-type", "application/json")
271+
.setData(body.build().toString());
272+
273+
return request.post("/ws/bulk/%d".formatted(workspaceId), options);
274+
}
275+
276+
/**
277+
* Call the 'Bulk POST' endpoint in the Workspace server to copy items.
278+
* @param token The JWT token for the user making the request
279+
* @param workspaceId The source workspace
280+
* @param paths The list of items to be affected by the request
281+
* @param destination The destination folder to place the items in
282+
* @param destinationWorkspaceId If present, the destination workspace.
283+
* @param overwrite If present, the value of the 'overwrite' flag
284+
* @return The APIResponse from the server
285+
*/
286+
public APIResponse bulkCopy(
287+
String token,
288+
int workspaceId,
289+
List<Path> paths,
290+
Path destination,
291+
Optional<Integer> destinationWorkspaceId,
292+
Optional<Boolean> overwrite
293+
) {
294+
// Generate the request body
295+
final var body = Json.createObjectBuilder().add("copyTo", destination.toString());
296+
297+
final var itemsArray = Json.createArrayBuilder();
298+
paths.forEach(p -> itemsArray.add(Json.createObjectBuilder().add("path", p.toString())));
299+
body.add("items", itemsArray);
300+
301+
destinationWorkspaceId.ifPresent(wid -> body.add("toWorkspace", wid));
302+
303+
overwrite.ifPresent(o -> body.add("overwrite", o));
304+
305+
// Generate request
306+
final var options = RequestOptions
307+
.create()
308+
.setHeader("Authorization", "Bearer "+token)
309+
.setHeader("Content-type", "application/json")
310+
.setData(body.build().toString());
311+
312+
return request.post("/ws/bulk/%d".formatted(workspaceId), options);
313+
}
314+
315+
316+
/**
317+
* Call the 'Bulk DELETE' endpoint in the Workspace server.
318+
* @param token The JWT token for the user making the request
319+
* @param workspaceId The source workspace
320+
* @param paths The list of items to be deleted
321+
* @return The APIResponse from the server
322+
*/
323+
public APIResponse bulkDelete(String token, int workspaceId, List<Path> paths) {
324+
// Generate the request body
325+
final var body = Json.createArrayBuilder();
326+
paths.forEach(p -> body.add(p.toString()));
327+
328+
// Generate request
329+
final var options = RequestOptions
330+
.create()
331+
.setHeader("Authorization", "Bearer "+token)
332+
.setHeader("Content-type", "application/json")
333+
.setData(body.build().toString());
334+
335+
return request.delete("/ws/bulk/%d".formatted(workspaceId), options);
336+
}
337+
194338
@Override
195339
public void close() {
196340
request.dispose();

workspace-server/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies {
3535
implementation 'com.zaxxer:HikariCP:5.0.1'
3636

3737
testImplementation 'org.junit.jupiter:junit-jupiter-engine:6.0.1'
38+
testImplementation 'org.junit.jupiter:junit-jupiter-params:6.0.1'
3839
testImplementation 'net.jqwik:jqwik:1.6.5'
3940

4041
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,25 @@ public FormattedError(String message) {
4646
this.message = message;
4747
}
4848

49+
/**
50+
* For use in the event of an endpoint failing without throwing an exception, but where there's a more detailed cause.
51+
*/
52+
public FormattedError(String message, String cause) {
53+
this.type = "INTERNAL_ERROR";
54+
this.message = message;
55+
this.cause = Optional.ofNullable(cause);
56+
}
57+
58+
/**
59+
* For use in the event of an endpoint failing without throwing an exception,
60+
* but "INTERNAL_ERROR" does not make sense as the error type (i.e. the request is malformed)
61+
*/
62+
public FormattedError(String type, String message, Optional<String> cause) {
63+
this.type = type;
64+
this.message = message;
65+
this.cause = cause;
66+
}
67+
4968
/**
5069
* Create a FormattedException from a generic Exception object.
5170
* @param type the category of exception. Should be in SCREAMING_SNAKE_CASE
@@ -135,8 +154,8 @@ public FormattedError(NumberFormatException nfe) {
135154
}
136155

137156
// IllegalArgumentException
138-
public FormattedError(IllegalArgumentException iae, String message) {
139-
this("ILLEGAL_ARGUMENT", message, iae);
157+
public FormattedError(IllegalArgumentException iae) {
158+
this("ILLEGAL_ARGUMENT", iae);
140159
}
141160

142161
// JSONException
@@ -150,6 +169,16 @@ public FormattedError(ValidationException ve) {
150169
this.message = ve.getMessage() != null ? ve.getMessage() : "Invalid request";
151170
trace = Optional.of(generateTrace(ve));
152171
}
172+
173+
// Null Pointer Exception
174+
public FormattedError(NullPointerException ne, String message) {
175+
this("NULL_POINTER_EXCEPTION", message, ne);
176+
}
177+
178+
//Security Exception
179+
public FormattedError(SecurityException se) {
180+
this("SECURITY_EXCEPTION", se.getMessage(), se);
181+
}
153182
//endregion
154183

155184
/**
@@ -182,6 +211,11 @@ public JsonObject toJson() {
182211
return builder.build();
183212
}
184213

214+
@Override
215+
public String toString() {
216+
return this.toJson().toString();
217+
}
218+
185219
/**
186220
* Internal class so that Javalin serializes the FormattedError class using its `toJson` method.
187221
* This avoids needing to call `toJson` every time the FormattedError class is used as an endpoint return.
@@ -194,7 +228,7 @@ public void serialize(
194228
final JsonGenerator jsonGenerator,
195229
final SerializerProvider serializerProvider) throws IOException
196230
{
197-
jsonGenerator.writeRaw(formattedError.toJson().toString());
231+
jsonGenerator.writeRaw(formattedError.toString());
198232
}
199233
}
200234
}

0 commit comments

Comments
 (0)