diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b7c5f..56b7f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.3] - unreleased + +### Changed + +- `getMyFolders(int limit)` now reads your folders from `GET /v3/files/folders/home/list`, because the +endpoint it used before was retired by NDEx ([ndexbio/ndex-rest#163](https://github.com/ndexbio/ndex-rest/issues/163)); +the method signature and return type are unchanged, so existing code still compiles. +- It now returns the folders at the top level of your home, most recently modified first, rather than +every folder at any depth ordered by name. +- Against NDEx servers older than 3.0.7 it still works and still returns at most `limit` folders, but +leaves each folder's `creationTime` unset. + ## [3.0.2] - 2026-09-04 ### Fixed diff --git a/src/main/java/org/ndexbio/rest/client/NdexRestClientModelAccessLayer.java b/src/main/java/org/ndexbio/rest/client/NdexRestClientModelAccessLayer.java index 4ab6c4d..275e587 100644 --- a/src/main/java/org/ndexbio/rest/client/NdexRestClientModelAccessLayer.java +++ b/src/main/java/org/ndexbio/rest/client/NdexRestClientModelAccessLayer.java @@ -34,6 +34,8 @@ import java.io.InputStream; import java.io.StringWriter; import java.net.HttpURLConnection; +import java.sql.Timestamp; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -47,7 +49,9 @@ import org.ndexbio.model.exceptions.BadRequestException; import org.ndexbio.model.exceptions.NdexException; import org.ndexbio.model.object.CXSimplePathQuery; +import org.ndexbio.model.object.FileItemSummary; import org.ndexbio.model.object.FileSearchResult; +import org.ndexbio.model.object.FileType; import org.ndexbio.model.object.FileVisibilityType; import org.ndexbio.model.object.MoveNetworksRequest; import org.ndexbio.model.object.NdexFolder; @@ -706,15 +710,72 @@ public void setNetworkSystemProperty(UUID networkId, Map properti /** - * Lists the folders owned by the signed-in user. + * Lists the folders at the top level of the signed-in user's home, most recently modified first. + * + *

Returns at most {@code limit} folders; a non-positive {@code limit} returns an empty list. + * A full result is not proof that there are no further folders.

+ * + *

Folders nested inside another folder are not included, and {@code owner} and + * {@code owner_id} are now filled in where they previously came back null.

+ * + *

Against NDEx servers older than 3.0.7 each folder's {@code creationTime} is left unset; + * everything else is populated the same way on every supported server.

* * @param limit maximum number of folders to return - * @return the user's folders, newest-first as ordered by the server + * @return the user's top-level folders, most recently modified first */ public List getMyFolders(int limit) throws JsonProcessingException, IOException, NdexException { - final String route = NdexApiVersion.v3 + "/files/folders?limit=" + limit; - return ndexRestClient.getNdexObjectList(route, "", NdexFolder.class); + if (limit <= 0) + return new ArrayList<>(); + + // size is spent on folders AND folder-targeted shortcuts, which are dropped below, so the + // server can return fewer than `limit` folders in a full page. Servers before 3.0.7 ignore + // size altogether. Both are why the result is trimmed here rather than trusted as-is. + final String route = NdexApiVersion.v3 + "/files/folders/home/list" + + buildQuery("type", "folder", "format", "compact", "size", limit); + List items = + ndexRestClient.getNdexObjectList(route, "", FileItemSummary.class); + + List folders = new ArrayList<>(); + for (FileItemSummary item : items) { + if (item == null || item.getType() != FileType.FOLDER) + continue; + folders.add(toFolder(item)); + if (folders.size() == limit) + break; + } + return folders; + } + + /** Maps one listing entry onto the folder representation this client has always returned. */ + private static NdexFolder toFolder(FileItemSummary item) { + NdexFolder folder = new NdexFolder(); + folder.setExternalId(item.getUuid()); + folder.setName(item.getName()); + folder.setModificationTime(item.getModificationTime()); + folder.setOwner(item.getOwner()); + folder.setOwner_id(item.getOwnerId() == null ? null : item.getOwnerId().toString()); + folder.setParent(null); // these are the caller's top-level folders + folder.setIsDeleted(false); // a listing never includes deleted folders + + // NdexExternalObject's constructor stamps creationTime with "now". Clear it, or a server that + // sends no creationTime (before 3.0.7) would hand back a fabricated timestamp instead of none. + folder.setCreationTime(null); + + // Fields with no value are omitted from the response entirely, so every lookup below must + // tolerate an absent key, not merely a null value. + Map attributes = item.getAttributes(); + if (attributes != null) { + Object description = attributes.get("description"); + if (description != null) + folder.setDescription(description.toString()); + + Object created = attributes.get("creationTime"); + if (created instanceof Number) + folder.setCreationTime(new Timestamp(((Number) created).longValue())); + } + return folder; } /** diff --git a/src/test/java/org/ndexbio/rest/client/NdexRestClientModelAccessLayerV3Test.java b/src/test/java/org/ndexbio/rest/client/NdexRestClientModelAccessLayerV3Test.java index cdb5e4d..18b10d0 100644 --- a/src/test/java/org/ndexbio/rest/client/NdexRestClientModelAccessLayerV3Test.java +++ b/src/test/java/org/ndexbio/rest/client/NdexRestClientModelAccessLayerV3Test.java @@ -17,15 +17,20 @@ import java.io.InputStream; import java.net.HttpURLConnection; import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.List; import java.util.UUID; import org.easymock.Capture; import org.easymock.EasyMock; import org.junit.Test; +import org.ndexbio.model.object.FileItemSummary; import org.ndexbio.model.object.FileSearchResult; +import org.ndexbio.model.object.FileType; import org.ndexbio.model.object.FileVisibilityType; import org.ndexbio.model.object.MoveNetworksRequest; import org.ndexbio.model.object.NdexFolder; @@ -63,22 +68,137 @@ public void buildQueryRejectsOddArgumentCount() { // ---------- getMyFolders ---------- + /** One listing entry as the server returns it, with attributes populated the compact view's way. */ + private static FileItemSummary folderItem(UUID uuid, String name, Map attributes) { + FileItemSummary item = new FileItemSummary(); + item.setUuid(uuid); + item.setType(FileType.FOLDER); + item.setName(name); + item.setAttributes(attributes); + return item; + } + + @Test + public void getMyFoldersReadsTheHomeFolderListing() throws Exception { + NdexRestClient client = mock(NdexRestClient.class); + Capture route = Capture.newInstance(); + expect(client.getNdexObjectList(EasyMock.capture(route), eq(""), eq(FileItemSummary.class))) + .andReturn(Collections.singletonList(folderItem(FOLDER_ID, "My Project", null))); + replay(client); + + List result = new NdexRestClientModelAccessLayer(client).getMyFolders(50); + + assertEquals("v3/files/folders/home/list?type=folder&format=compact&size=50", route.getValue()); + assertEquals(1, result.size()); + assertEquals("My Project", result.get(0).getName()); + assertEquals(FOLDER_ID, result.get(0).getExternalId()); + verify(client); + } + @Test - public void getMyFoldersUsesV3RouteWithLimit() throws Exception { + public void getMyFoldersDropsFolderTargetedShortcuts() throws Exception { + // ?type=folder also returns shortcuts POINTING AT folders; they are not folders and must go. + FileItemSummary shortcut = new FileItemSummary(); + shortcut.setUuid(UUID.randomUUID()); + shortcut.setType(FileType.SHORTCUT); + shortcut.setName("link to a folder"); + NdexRestClient client = mock(NdexRestClient.class); - NdexFolder folder = new NdexFolder(); - folder.setName("My Project"); - List folders = Collections.singletonList(folder); - expect(client.getNdexObjectList("v3/files/folders?limit=50", "", NdexFolder.class)).andReturn(folders); + expect(client.getNdexObjectList(anyObject(String.class), eq(""), eq(FileItemSummary.class))) + .andReturn(Arrays.asList(shortcut, folderItem(FOLDER_ID, "My Project", null))); replay(client); List result = new NdexRestClientModelAccessLayer(client).getMyFolders(50); - assertSame(folders, result); + assertEquals(1, result.size()); assertEquals("My Project", result.get(0).getName()); verify(client); } + @Test + public void getMyFoldersReadsDescriptionAndCreationTimeFromAttributes() throws Exception { + long epoch = 1767312245000L; + Map attributes = new HashMap<>(); + attributes.put("description", "a folder"); + attributes.put("creationTime", epoch); + + NdexRestClient client = mock(NdexRestClient.class); + expect(client.getNdexObjectList(anyObject(String.class), eq(""), eq(FileItemSummary.class))) + .andReturn(Collections.singletonList(folderItem(FOLDER_ID, "My Project", attributes))); + replay(client); + + List result = new NdexRestClientModelAccessLayer(client).getMyFolders(50); + + assertEquals("a folder", result.get(0).getDescription()); + assertEquals(new Timestamp(epoch), result.get(0).getCreationTime()); + verify(client); + } + + @Test + public void getMyFoldersLeavesCreationTimeUnsetWhenServerOmitsIt() throws Exception { + // Servers older than 3.0.7 send no creationTime, and absent fields are omitted rather than + // sent as null -- so the lookup must degrade, not throw. + Map attributes = new HashMap<>(); + attributes.put("description", "a folder"); + + NdexRestClient client = mock(NdexRestClient.class); + expect(client.getNdexObjectList(anyObject(String.class), eq(""), eq(FileItemSummary.class))) + .andReturn(Collections.singletonList(folderItem(FOLDER_ID, "My Project", attributes))); + replay(client); + + List result = new NdexRestClientModelAccessLayer(client).getMyFolders(50); + + assertNull(result.get(0).getCreationTime()); + assertEquals("a folder", result.get(0).getDescription()); + verify(client); + } + + @Test + public void getMyFoldersToleratesAbsentAttributesMap() throws Exception { + NdexRestClient client = mock(NdexRestClient.class); + expect(client.getNdexObjectList(anyObject(String.class), eq(""), eq(FileItemSummary.class))) + .andReturn(Collections.singletonList(folderItem(FOLDER_ID, "My Project", null))); + replay(client); + + List result = new NdexRestClientModelAccessLayer(client).getMyFolders(50); + + assertNull(result.get(0).getCreationTime()); + assertNull(result.get(0).getDescription()); + verify(client); + } + + @Test + public void getMyFoldersTrimsAnOverLongResponseToLimit() throws Exception { + // A server that ignores size (before 3.0.7) returns everything; the client still honours limit. + NdexRestClient client = mock(NdexRestClient.class); + expect(client.getNdexObjectList(anyObject(String.class), eq(""), eq(FileItemSummary.class))) + .andReturn(Arrays.asList( + folderItem(UUID.randomUUID(), "one", null), + folderItem(UUID.randomUUID(), "two", null), + folderItem(UUID.randomUUID(), "three", null))); + replay(client); + + List result = new NdexRestClientModelAccessLayer(client).getMyFolders(2); + + assertEquals(2, result.size()); + assertEquals("one", result.get(0).getName()); + assertEquals("two", result.get(1).getName()); + verify(client); + } + + @Test + public void getMyFoldersReturnsEmptyWithoutCallingTheServerWhenLimitNotPositive() throws Exception { + // The old route was a SQL LIMIT, so 0 meant "nothing". The listing treats a non-positive size as + // unbounded, so without the guard this would flip from returning nothing to returning everything. + NdexRestClient client = mock(NdexRestClient.class); + replay(client); // no calls expected + + NdexRestClientModelAccessLayer layer = new NdexRestClientModelAccessLayer(client); + assertTrue(layer.getMyFolders(0).isEmpty()); + assertTrue(layer.getMyFolders(-1).isEmpty()); + verify(client); + } + // ---------- searchFiles ---------- @Test