Skip to content

Commit a21622c

Browse files
committed
graph: implement driveItem creation under drives/items/children
Implements the CreateDriveItem/CreateChildDriveItem operations from libre-graph-api#42 including @libre.graph.conflictBehavior and @libre.graph.missingParentsBehavior.
1 parent 98f3a9e commit a21622c

6 files changed

Lines changed: 794 additions & 14 deletions

File tree

services/graph/mocks/drives_drive_item_provider.go

Lines changed: 86 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/graph/pkg/middleware/path_lookup.go

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ var (
4949
errPathNotFound = errors.New("path not found")
5050
errInvalidRequest = errors.New("invalid request")
5151
errUnauthenticated = errors.New("unauthenticated")
52+
errAccessDenied = errors.New("access denied")
5253
)
5354

5455
// ResolveGraphPath returns middleware that detects MS Graph colon-syntax path
@@ -91,7 +92,7 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
9192

9293
driveID := chi.URLParam(r, "driveID")
9394
original := r.URL.Path
94-
rewritten, err := rewriteColonPath(r.Context(), gws, l, driveID, rctx.RoutePath)
95+
rewritten, err := rewriteColonPath(r.Context(), gws, l, driveID, rctx.RoutePath, r)
9596
switch {
9697
case errors.Is(err, errPathNotFound):
9798
l.Debug().Str("original", original).Msg("colon-path resolution: not found")
@@ -105,6 +106,10 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
105106
l.Debug().Str("original", original).Msg("colon-path resolution: unauthenticated")
106107
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, "unauthenticated")
107108
return
109+
case errors.Is(err, errAccessDenied):
110+
l.Debug().Str("original", original).Msg("colon-path resolution: access denied")
111+
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "access denied")
112+
return
108113
case err != nil:
109114
l.Error().Err(err).Str("original", original).Msg("colon-path resolution: internal error")
110115
errorcode.GeneralException.Render(
@@ -154,19 +159,38 @@ type colonMatch struct {
154159
// - "" + other error - operational / internal failure (5xx)
155160
//
156161
// driveIDParam is the {driveID} route param (raw chi.URLParam value); routePath
157-
// is chi.RouteContext().RoutePath (the part below /drives/{driveID}).
162+
// is chi.RouteContext().RoutePath (the part below /drives/{driveID}). r is only
163+
// consulted for the method and the @libre.graph.missingParentsBehavior query
164+
// parameter, which is meaningful for POST .../children requests only and
165+
// ignored otherwise.
158166
func rewriteColonPath(
159167
ctx context.Context,
160168
gws pool.Selectable[gateway.GatewayAPIClient],
161169
logger zerolog.Logger,
162170
driveIDParam string,
163171
routePath string,
172+
r *http.Request,
164173
) (string, error) {
165174
match, ok := parseColonPath(routePath)
166175
if !ok {
167176
return "", nil
168177
}
169178

179+
// The colon path addresses the parent of the item a POST .../children
180+
// creates. With missingParentsBehavior=create the missing folders along
181+
// that path are created instead of returning 404.
182+
createParents := false
183+
if r.Method == http.MethodPost && match.suffix == "/children" {
184+
switch r.URL.Query().Get("@libre.graph.missingParentsBehavior") {
185+
case "", "fail":
186+
case "create":
187+
createParents = true
188+
default:
189+
logger.Debug().Msg("invalid @libre.graph.missingParentsBehavior in colon path")
190+
return "", errInvalidRequest
191+
}
192+
}
193+
170194
// RoutePath follows chi's RawPath, i.e. the percent-encoded wire form
171195
// (e.g. "/Documents/My%20File"). A single PathUnescape reproduces exactly
172196
// what net/http put in r.URL.Path; it is NOT a double-decode (a crafted
@@ -218,7 +242,11 @@ func rewriteColonPath(
218242
return "", errInvalidRequest
219243
}
220244

221-
itemID, err := resolvePath(ctx, gws, &anchor, relPath)
245+
resolve := resolvePath
246+
if createParents {
247+
resolve = resolveOrCreatePath
248+
}
249+
itemID, err := resolve(ctx, gws, &anchor, relPath)
222250
if err != nil {
223251
return "", err
224252
}
@@ -320,6 +348,79 @@ func resolvePath(
320348
return "", fmt.Errorf("gateway selector: %w", err)
321349
}
322350

351+
return statPath(ctx, gw, anchor, relPath)
352+
}
353+
354+
// resolveOrCreatePath is resolvePath for POST .../children requests with
355+
// @libre.graph.missingParentsBehavior=create: it walks relPath segment by
356+
// segment and creates the missing folders along the way.
357+
func resolveOrCreatePath(
358+
ctx context.Context,
359+
gws pool.Selectable[gateway.GatewayAPIClient],
360+
anchor *storageprovider.ResourceId,
361+
relPath string,
362+
) (string, error) {
363+
gw, err := gws.Next()
364+
if err != nil {
365+
return "", fmt.Errorf("gateway selector: %w", err)
366+
}
367+
368+
var id, walked string
369+
for _, segment := range strings.Split(strings.Trim(relPath, "/"), "/") {
370+
walked += "/" + segment
371+
id, err = statPath(ctx, gw, anchor, walked)
372+
if err == nil {
373+
continue
374+
}
375+
if !errors.Is(err, errPathNotFound) {
376+
return "", err
377+
}
378+
379+
res, err := gw.CreateContainer(ctx, &storageprovider.CreateContainerRequest{
380+
Ref: &storageprovider.Reference{
381+
ResourceId: anchor,
382+
Path: utils.MakeRelativePath(walked),
383+
},
384+
})
385+
if err != nil {
386+
return "", fmt.Errorf("CS3 CreateContainer: %w", err)
387+
}
388+
switch res.GetStatus().GetCode() {
389+
case cs3rpc.Code_CODE_OK:
390+
// fall through
391+
case cs3rpc.Code_CODE_ALREADY_EXISTS:
392+
// lost a creation race, the folder is there
393+
case cs3rpc.Code_CODE_NOT_FOUND:
394+
return "", errPathNotFound
395+
case cs3rpc.Code_CODE_PERMISSION_DENIED:
396+
return "", errAccessDenied
397+
case cs3rpc.Code_CODE_UNAUTHENTICATED:
398+
return "", errUnauthenticated
399+
default:
400+
return "", fmt.Errorf(
401+
"CS3 CreateContainer returned %s: %s",
402+
res.GetStatus().GetCode(),
403+
res.GetStatus().GetMessage(),
404+
)
405+
}
406+
407+
id, err = statPath(ctx, gw, anchor, walked)
408+
if err != nil {
409+
return "", err
410+
}
411+
}
412+
return id, nil
413+
}
414+
415+
// statPath stats a relative filesystem path (anchored at the given CS3
416+
// resource id) and returns the item's id, running with the request user's
417+
// permissions.
418+
func statPath(
419+
ctx context.Context,
420+
gw gateway.GatewayAPIClient,
421+
anchor *storageprovider.ResourceId,
422+
relPath string,
423+
) (string, error) {
323424
// relPath is already decoded (PathUnescape'd once by the caller), matching
324425
// the form a normal handler would receive from r.URL.Path.
325426
statRes, err := gw.Stat(ctx, &storageprovider.StatRequest{

services/graph/pkg/middleware/path_lookup_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package middleware_test
22

33
import (
4+
"context"
45
"net/http"
56
"net/http/httptest"
67
"net/url"
@@ -115,6 +116,7 @@ func newGraphTestRouter(t *testing.T, gw *cs3mocks.GatewayAPIClient) (http.Handl
115116
r.Use(middleware.ResolveGraphPath(selector, log.NopLogger()))
116117
r.Route("/items/{itemID}", func(r chi.Router) {
117118
r.Get("/", leaf("item"))
119+
r.Post("/children", leaf("createChild"))
118120
r.Post("/createLink", leaf("createLink"))
119121
r.Route("/permissions", func(r chi.Router) {
120122
r.Get("/", leaf("permissions"))
@@ -469,3 +471,94 @@ func TestResolveGraphPath_OriginalPathContext(t *testing.T) {
469471
assert.Equal(t, original, cap.original, "original URL must be available via OriginalPathContextKey")
470472
assert.Equal(t, original, cap.urlPath, "r.URL.Path must remain the original request path")
471473
}
474+
475+
// TestResolveGraphPath_MissingParentsBehavior covers the
476+
// @libre.graph.missingParentsBehavior query parameter on POST .../children
477+
// colon-syntax requests.
478+
func TestResolveGraphPath_MissingParentsBehavior(t *testing.T) {
479+
childrenURL := "/graph/v1beta1/drives/" + testDriveID + "/root:/a/b:/children"
480+
481+
t.Run("create creates the missing folders along the path", func(t *testing.T) {
482+
gw := cs3mocks.NewGatewayAPIClient(t)
483+
created := false
484+
gw.EXPECT().Stat(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
485+
func(_ context.Context, req *storageprovider.StatRequest, _ ...grpc.CallOption) (*storageprovider.StatResponse, error) {
486+
switch path := req.GetRef().GetPath(); path {
487+
case "./a":
488+
return statResponse(cs3rpc.Code_CODE_OK, true), nil
489+
case "./a/b":
490+
if created {
491+
return statResponse(cs3rpc.Code_CODE_OK, true), nil
492+
}
493+
return statResponse(cs3rpc.Code_CODE_NOT_FOUND, false), nil
494+
default:
495+
t.Errorf("unexpected stat path %q", path)
496+
return statResponse(cs3rpc.Code_CODE_NOT_FOUND, false), nil
497+
}
498+
})
499+
gw.EXPECT().CreateContainer(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
500+
func(_ context.Context, req *storageprovider.CreateContainerRequest, _ ...grpc.CallOption) (*storageprovider.CreateContainerResponse, error) {
501+
assert.Equal(t, "./a/b", req.GetRef().GetPath())
502+
created = true
503+
return &storageprovider.CreateContainerResponse{Status: &cs3rpc.Status{Code: cs3rpc.Code_CODE_OK}}, nil
504+
}).Once()
505+
506+
router, cap := newGraphTestRouter(t, gw)
507+
rr := httptest.NewRecorder()
508+
router.ServeHTTP(rr, httptest.NewRequest(
509+
http.MethodPost, childrenURL+"?%40libre.graph.missingParentsBehavior=create", nil,
510+
))
511+
512+
assert.Equal(t, http.StatusOK, rr.Code)
513+
assert.Equal(t, "createChild", cap.hit)
514+
assert.Equal(t, testItemID, cap.itemID)
515+
})
516+
517+
t.Run("default fail returns 404 for a missing path without creating anything", func(t *testing.T) {
518+
gw := cs3mocks.NewGatewayAPIClient(t)
519+
gw.EXPECT().Stat(mock.Anything, mock.Anything, mock.Anything).
520+
Return(statResponse(cs3rpc.Code_CODE_NOT_FOUND, false), nil).
521+
Once()
522+
523+
router, cap := newGraphTestRouter(t, gw)
524+
rr := httptest.NewRecorder()
525+
router.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, childrenURL, nil))
526+
527+
assert.Equal(t, http.StatusNotFound, rr.Code)
528+
assert.Equal(t, "", cap.hit)
529+
})
530+
531+
t.Run("invalid value returns 400", func(t *testing.T) {
532+
gw := cs3mocks.NewGatewayAPIClient(t)
533+
534+
router, cap := newGraphTestRouter(t, gw)
535+
rr := httptest.NewRecorder()
536+
router.ServeHTTP(rr, httptest.NewRequest(
537+
http.MethodPost, childrenURL+"?%40libre.graph.missingParentsBehavior=maybe", nil,
538+
))
539+
540+
assert.Equal(t, http.StatusBadRequest, rr.Code)
541+
assert.Equal(t, "", cap.hit)
542+
})
543+
544+
t.Run("denied folder creation returns 403", func(t *testing.T) {
545+
gw := cs3mocks.NewGatewayAPIClient(t)
546+
gw.EXPECT().Stat(mock.Anything, mock.Anything, mock.Anything).
547+
Return(statResponse(cs3rpc.Code_CODE_NOT_FOUND, false), nil).
548+
Once()
549+
gw.EXPECT().CreateContainer(mock.Anything, mock.Anything, mock.Anything).
550+
Return(&storageprovider.CreateContainerResponse{
551+
Status: &cs3rpc.Status{Code: cs3rpc.Code_CODE_PERMISSION_DENIED},
552+
}, nil).
553+
Once()
554+
555+
router, cap := newGraphTestRouter(t, gw)
556+
rr := httptest.NewRecorder()
557+
router.ServeHTTP(rr, httptest.NewRequest(
558+
http.MethodPost, childrenURL+"?%40libre.graph.missingParentsBehavior=create", nil,
559+
))
560+
561+
assert.Equal(t, http.StatusForbidden, rr.Code)
562+
assert.Equal(t, "", cap.hit)
563+
})
564+
}

0 commit comments

Comments
 (0)