graph: support $expand=children on drive special folders

This commit is contained in:
Dominik Schmidt committed 2026-09-07 12:32:57 +02:00
1 parent edcfa95a99
commit fdebaba41d
2 files changed
+116 -22

No files matched your search

@@ -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 {
@@ -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),