From fdebaba41db42ee6a0de63df43fbff1e7c1930e3 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 7 Sep 2026 12:32:57 +0200 Subject: [PATCH] graph: support $expand=children on drive special folders --- .../pkg/service/v0/driveitems_special.go | 64 +++++++++++----- .../pkg/service/v0/driveitems_special_test.go | 74 ++++++++++++++++++- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/services/graph/pkg/service/v0/driveitems_special.go b/services/graph/pkg/service/v0/driveitems_special.go index 8826c4506a..4bb83a4010 100644 --- a/services/graph/pkg/service/v0/driveitems_special.go +++ b/services/graph/pkg/service/v0/driveitems_special.go @@ -24,6 +24,7 @@ const RecycleBinSpecialFolderName = "recyclebin" // GetDriveSpecial returns the driveItem of a special folder. In the colon path // form (special/recyclebin:/{key}) it returns the trashed item with that key. +// $expand=children embeds the listing for folders. func (g Graph) GetDriveSpecial(w http.ResponseWriter, r *http.Request) { g.logger.Debug().Msg("Calling GetDriveSpecial") @@ -33,29 +34,45 @@ func (g Graph) GetDriveSpecial(w http.ResponseWriter, r *http.Request) { } key := specialFolderKey(r) - if key == "" { - render.Status(r, http.StatusOK) - render.JSON(w, r, recycleBinDriveItem(&driveID)) + driveItem, ok := g.getSpecialDriveItemForKey(w, r, &driveID, key) + if !ok { return } + if driveItem.Folder != nil && driveItemRelationExpanded(r, _expandChildren) { + children, ok := g.listRecycleChildren(w, r, &driveID, key) + if !ok { + return + } + driveItem.Children = children + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, driveItem) +} + +// getSpecialDriveItemForKey returns the trash root for an empty key, else the trashed item with that key +func (g Graph) getSpecialDriveItemForKey(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, key string) (*libregraph.DriveItem, bool) { + if key == "" { + return recycleBinDriveItem(driveID), true + } + // A top-level key lists itself, a nested key only shows up in its parent's listing. listKey := key if strings.Contains(key, "/") { listKey = path.Dir(key) + "/" } - items, ok := g.listRecycle(w, r, &driveID, listKey) + items, ok := g.listRecycle(w, r, driveID, listKey) if !ok { - return + return nil, false } for _, item := range items { if item.GetKey() == key { - render.Status(r, http.StatusOK) - render.JSON(w, r, recycleItemToDriveItem(&driveID, item)) - return + return recycleItemToDriveItem(driveID, item), true } } errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found") + return nil, false } // ListDriveSpecialChildren lists the children of a special folder. For the @@ -69,25 +86,34 @@ func (g Graph) ListDriveSpecialChildren(w http.ResponseWriter, r *http.Request) return } - // the trailing slash asks reva for the children of the key instead of the key itself - listKey := "" - if key := specialFolderKey(r); key != "" { - listKey = key + "/" - } - items, ok := g.listRecycle(w, r, &driveID, listKey) + files, ok := g.listRecycleChildren(w, r, &driveID, specialFolderKey(r)) if !ok { return } - files := make([]libregraph.DriveItem, 0, len(items)) - for _, item := range items { - files = append(files, *recycleItemToDriveItem(&driveID, item)) - } - render.Status(r, http.StatusOK) render.JSON(w, r, &ListResponse{Value: files}) } +// listRecycleChildren lists the trash root for an empty key, else the children of the trashed folder with that key +func (g Graph) listRecycleChildren(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, key string) ([]libregraph.DriveItem, bool) { + // the trailing slash asks reva for the children of the key instead of the key itself + listKey := "" + if key != "" { + listKey = key + "/" + } + items, ok := g.listRecycle(w, r, driveID, listKey) + if !ok { + return nil, false + } + + files := make([]libregraph.DriveItem, 0, len(items)) + for _, item := range items { + files = append(files, *recycleItemToDriveItem(driveID, item)) + } + return files, true +} + func parseSpecialParams(w http.ResponseWriter, r *http.Request) (storageprovider.ResourceId, bool) { driveID, err := parseIDParam(r, "driveID") if err != nil { diff --git a/services/graph/pkg/service/v0/driveitems_special_test.go b/services/graph/pkg/service/v0/driveitems_special_test.go index 48066a0be4..205e5f6bf8 100644 --- a/services/graph/pkg/service/v0/driveitems_special_test.go +++ b/services/graph/pkg/service/v0/driveitems_special_test.go @@ -57,10 +57,10 @@ var _ = Describe("Drive special folders", func() { deletedAt = time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) ) - // newRequest builds a request for /drives/{driveID}/special/{specialName}[/children]; + // newRequestWithQuery builds a request for /drives/{driveID}/special/{specialName}[/children]; // specialPath mimics what the colon-path middleware puts into the context. - newRequest := func(specialName, specialPath string) *http.Request { - r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/special/"+specialName, nil) + newRequestWithQuery := func(specialName, specialPath, query string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/special/"+specialName+query, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("driveID", "storageid$spaceid") rctx.URLParams.Add("specialName", specialName) @@ -70,6 +70,9 @@ var _ = Describe("Drive special folders", func() { } return r.WithContext(c) } + newRequest := func(specialName, specialPath string) *http.Request { + return newRequestWithQuery(specialName, specialPath, "") + } decodeItem := func() libregraph.DriveItem { var item libregraph.DriveItem @@ -200,6 +203,71 @@ var _ = Describe("Drive special folders", func() { Expect(item.File).To(BeNil()) }) + It("embeds the trash listing with $expand=children", func() { + var keys []string + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid", Ref: &provider.Reference{Path: "/a.txt"}}, + }, + }, nil) + + svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "", "?$expand=children")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(keys).To(Equal([]string{""})) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!recyclebin")) + Expect(item.Children).To(HaveLen(1)) + Expect(item.Children[0].GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.Children[0].Trash).ToNot(BeNil()) + }) + + It("embeds the children of a trashed folder with $expand=children", func() { + var keys []string + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, Key: "nodeid", Ref: &provider.Reference{Path: "/folder"}}, + }, + }, nil).Once() + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid/a.txt", Ref: &provider.Reference{Path: "/folder/a.txt"}}, + }, + }, nil).Once() + + svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "/nodeid", "?$expand=children")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(keys).To(Equal([]string{"nodeid", "nodeid/"})) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.Children).To(HaveLen(1)) + Expect(item.Children[0].GetId()).To(Equal("storageid$spaceid!nodeid/a.txt")) + }) + + It("does not expand children of a trashed file", func() { + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid", Ref: &provider.Reference{Path: "/a.txt"}}, + }, + }, nil) + + svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "/nodeid", "?$expand=children")) + Expect(rr.Code).To(Equal(http.StatusOK)) + gatewayClient.AssertNumberOfCalls(GinkgoT(), "ListRecycle", 1) + Expect(decodeItem().Children).To(BeNil()) + }) + It("returns not found when the key is not in the listing", func() { gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ Status: status.NewOK(ctx),