feat(graph): open extension endpoints on drive items

Adds the openTypeExtension collection of a driveItem under
/v1beta1/drives/{drive-id}/items/{item-id}/extensions: list, get, upsert
(merge, null removes) and delete, plus $expand=extensions on the item.
Every property is one arbitrary metadata key, so an upsert writes and
removes only the properties of the request; values are returned as
written, date-times and geo points carry their @odata.type.
This commit is contained in:
Dominik Schmidt committed 2026-09-16 09:20:39 +00:00
1 parent c5b84a599c
commit 44b97637ba
5 files changed
+928

No files matched your search

@@ -355,6 +355,15 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
}
render.Status(r, http.StatusOK)
if driveItemRelationExpanded(r, _expandOpenExtensions) {
expanded, err := driveItemWithOpenExtensions(driveItem, res.GetInfo())
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.JSON(w, r, expanded)
return
}
render.JSON(w, r, &driveItem)
}
@@ -0,0 +1,314 @@
package svc_test
import (
"encoding/json"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/reva/v2/pkg/openextension"
)
// The codec lives in reva (WebDAV and graph share it); its behavior is pinned
// here because the graph endpoints and the search index build on it.
var _ = Describe("open extension codec", func() {
const project = "com.example.project"
key := func(name, property string) string {
return "http://opencloud.eu/ns/extensions/" + name + "/" + property
}
parse := func(body string) openextension.Patch {
GinkgoHelper()
p, err := openextension.Parse([]byte(body))
Expect(err).NotTo(HaveOccurred())
return p
}
rejects := func(body, reason string) {
GinkgoHelper()
_, err := openextension.Parse([]byte(body))
Expect(err).To(MatchError(openextension.ErrInvalid))
Expect(err.Error()).To(ContainSubstring(reason))
}
Describe("Parse", func() {
It("infers the kind from the JSON type", func() {
p := parse(`{"extensionName":"com.example.project","status":"reviewed","priority":3,"done":false,"tags":["a","b"]}`)
Expect(p.Set).To(HaveLen(4))
Expect(p.Set["status"].Kind).To(Equal(openextension.KindString))
Expect(p.Set["priority"].Kind).To(Equal(openextension.KindNumber))
Expect(p.Set["priority"].IsInteger()).To(BeTrue())
Expect(p.Set["done"].Kind).To(Equal(openextension.KindBool))
Expect(p.Set["tags"].Kind).To(Equal(openextension.KindString))
Expect(p.Set["tags"].Array).To(BeTrue())
Expect(p.Set["tags"].Strings()).To(Equal([]string{"a", "b"}))
})
It("keeps the JSON as written", func() {
p := parse(`{"n":"34","m":34,"f":1.50}`)
Expect(string(p.Set["n"].Raw)).To(Equal(`"34"`))
Expect(p.Set["n"].Kind).To(Equal(openextension.KindString))
Expect(string(p.Set["m"].Raw)).To(Equal(`34`))
Expect(string(p.Set["f"].Raw)).To(Equal(`1.50`))
Expect(p.Set["f"].IsInteger()).To(BeFalse())
})
It("takes dates and geo points from their annotations", func() {
p := parse(`{"due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset",` +
`"site":{"latitude":52.5,"longitude":13.4},"site@odata.type":"#microsoft.graph.geoCoordinates",` +
`"dates":["2026-10-01T00:00:00Z"],"dates@odata.type":"#Collection(DateTimeOffset)"}`)
Expect(p.Set["due"].Kind).To(Equal(openextension.KindDate))
Expect(p.Set["due"].Times()).To(HaveLen(1))
Expect(p.Set["site"].Kind).To(Equal(openextension.KindGeo))
geo, ok := p.Set["site"].Geo()
Expect(ok).To(BeTrue())
Expect(geo.Latitude).To(Equal(52.5))
Expect(geo.Longitude).To(Equal(13.4))
Expect(geo.Altitude).To(BeNil())
Expect(p.Set["dates"].Kind).To(Equal(openextension.KindDate))
Expect(p.Set["dates"].Array).To(BeTrue())
})
It("treats a date-time string without annotation as a string", func() {
p := parse(`{"due":"2026-10-01T00:00:00Z"}`)
Expect(p.Set["due"].Kind).To(Equal(openextension.KindString))
})
It("removes properties sent as null", func() {
p := parse(`{"status":"approved","priority":null}`)
Expect(p.Set).To(HaveKey("status"))
Expect(p.Remove).To(Equal([]string{"priority"}))
})
It("accepts the optional inferable annotations", func() {
p := parse(`{"a":"x","a@odata.type":"#String","b":3,"b@odata.type":"#Int64","c":2.5,"c@odata.type":"#Double","d":true,"d@odata.type":"#Boolean","e":[1,2],"e@odata.type":"#Collection(Int64)"}`)
Expect(p.Set).To(HaveLen(5))
})
It("rejects a value that does not match its annotation", func() {
rejects(`{"due":"next week","due@odata.type":"#DateTimeOffset"}`, "not an RFC 3339 date-time")
rejects(`{"n":"34","n@odata.type":"#Int64"}`, "is a string but annotated as #Int64")
rejects(`{"n":1.5,"n@odata.type":"#Int64"}`, "is not an integer")
rejects(`{"n":1,"n@odata.type":"#Boolean"}`, "annotated as #Boolean")
rejects(`{"n":1,"n@odata.type":"#Whatever"}`, "annotated as #Whatever")
})
It("rejects objects unless they are annotated geo coordinates", func() {
rejects(`{"assignee":{"name":"alice"}}`, "objects are only allowed as geoCoordinates")
rejects(`{"site":{"latitude":52.5},"site@odata.type":"#microsoft.graph.geoCoordinates"}`, "geoCoordinates need numeric latitude and longitude")
rejects(`{"site":{"latitude":91,"longitude":0},"site@odata.type":"#microsoft.graph.geoCoordinates"}`, "geoCoordinates need numeric latitude and longitude")
rejects(`{"site":{"latitude":1,"longitude":2,"name":"x"},"site@odata.type":"#microsoft.graph.geoCoordinates"}`, "geoCoordinates need numeric latitude and longitude")
})
It("rejects nested and mixed arrays", func() {
rejects(`{"a":[[1]]}`, "arrays hold scalars only")
rejects(`{"a":[1,"x"]}`, "array elements must all be number")
rejects(`{"a":[{"latitude":1,"longitude":2}],"a@odata.type":"#Collection(microsoft.graph.geoCoordinates)"}`, "arrays hold scalars only")
})
It("rejects bad property names, dangling annotations and oversized values", func() {
rejects(`{"a.b":1}`, "must be a simple identifier")
rejects(`{"1a":1}`, "must be a simple identifier")
rejects(`{"a@odata.type":"#String"}`, "annotates a property that is not in the body")
rejects(`[1]`, "body must be a JSON object")
rejects(`{"a":"`+strings.Repeat("a", openextension.MaxValueSize)+`"}`, "at most")
})
})
Describe("stored form", func() {
It("prefixes every value with the code of its kind", func() {
p := parse(`{"s":"open","n":3,"f":1.50,"b":true,"t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset",` +
`"g":{"latitude":52.5,"longitude":13.4,"altitude":34},"g@odata.type":"#microsoft.graph.geoCoordinates",` +
`"l":["a","b"],"m":[1,2.5],"c":[true],"d":["2026-10-01T00:00:00Z"],"d@odata.type":"#Collection(DateTimeOffset)"}`)
encoded := map[string]string{}
for k, v := range p.Set {
encoded[k] = openextension.EncodeValue(v)
}
Expect(encoded).To(Equal(map[string]string{
"s": "s:open",
"n": "n:3",
"f": "n:1.50",
"b": "b:true",
"t": "d:2026-10-01T00:00:00Z",
"g": "g:52.5,13.4,34",
"l": `S:["a","b"]`,
"m": `N:[1,2.5]`,
"c": `B:[true]`,
"d": `D:["2026-10-01T00:00:00Z"]`,
}))
})
It("round-trips every kind", func() {
p := parse(`{"s":"a:b, c","n":3,"f":1.50,"b":false,"t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset",` +
`"g":{"latitude":52.5,"longitude":13.4},"g@odata.type":"#microsoft.graph.geoCoordinates",` +
`"l":["a","b"],"m":[1,2.5],"d":["2026-10-01T00:00:00Z"],"d@odata.type":"#Collection(DateTimeOffset)"}`)
for k, v := range p.Set {
back, err := openextension.DecodeValue(openextension.EncodeValue(v))
Expect(err).NotTo(HaveOccurred(), k)
Expect(back).To(Equal(v), k)
}
})
It("rejects a value without a known code or with a payload that does not fit it", func() {
for _, raw := range []string{"open", "", "x:1", "34", "http://example.org", "s", "n:abc", "n:[1]", "b:maybe", "d:tomorrow", "g:1", "g:91,0", "S:1", "N:[1,\"x\"]", "D:[\"x\"]"} {
_, err := openextension.DecodeValue(raw)
Expect(err).To(MatchError(openextension.ErrInvalid), raw)
}
})
It("turns a patch into metadata keys", func() {
set, unset := parse(`{"status":"open","priority":3,"old":null,"due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset"}`).Metadata(project)
Expect(set).To(Equal(map[string]string{
key(project, "status"): "s:open",
key(project, "priority"): "n:3",
key(project, "due"): "d:2026-10-01T00:00:00Z",
}))
Expect(unset).To(Equal([]string{key(project, "old")}))
})
It("applies removals together with sets", func() {
ext := openextension.OpenExtension{Name: "x.y"}
ext.Apply(parse(`{"a":1,"b":2}`))
ext.Apply(parse(`{"a":null,"c":3}`))
Expect(ext.Keys()).To(Equal([]string{"b", "c"}))
})
})
Describe("MarshalJSON", func() {
It("annotates only what JSON cannot express", func() {
ext := openextension.OpenExtension{Name: "com.example.project"}
ext.Apply(parse(`{"status":"open","priority":3,"due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset","tags":["a"]}`))
out, err := json.Marshal(ext)
Expect(err).NotTo(HaveOccurred())
Expect(string(out)).To(Equal(`{"extensionName":"com.example.project","due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset","priority":3,"status":"open","tags":["a"]}`))
})
It("reports the OData type of every kind", func() {
p := parse(`{"s":"x","i":3,"d":2.5,"b":true,"l":["a"],"n":[1],"t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset","g":{"latitude":1,"longitude":2},"g@odata.type":"#microsoft.graph.geoCoordinates"}`)
Expect(p.Set["s"].ODataType()).To(Equal("#String"))
Expect(p.Set["i"].ODataType()).To(Equal("#Int64"))
Expect(p.Set["d"].ODataType()).To(Equal("#Double"))
Expect(p.Set["b"].ODataType()).To(Equal("#Boolean"))
Expect(p.Set["l"].ODataType()).To(Equal("#Collection(String)"))
Expect(p.Set["n"].ODataType()).To(Equal("#Collection(Double)"))
Expect(p.Set["t"].ODataType()).To(Equal("#DateTimeOffset"))
Expect(p.Set["g"].ODataType()).To(Equal("#microsoft.graph.geoCoordinates"))
})
})
Describe("names and keys", func() {
It("requires reverse DNS extension names", func() {
Expect(openextension.ValidateName("com.example.project")).To(Succeed())
Expect(openextension.ValidateName("eu.opencloud.work-flow_2")).To(Succeed())
Expect(openextension.ValidateName("project")).To(MatchError(openextension.ErrInvalid))
Expect(openextension.ValidateName("com..example")).To(MatchError(openextension.ErrInvalid))
Expect(openextension.ValidateName("com/example")).To(MatchError(openextension.ErrInvalid))
})
It("maps names and properties to namespaces and metadata keys", func() {
Expect(openextension.Namespace(project)).To(Equal("http://opencloud.eu/ns/extensions/com.example.project"))
Expect(openextension.Key(project, "status")).To(Equal(key(project, "status")))
name, ok := openextension.NameFromNamespace("http://opencloud.eu/ns/extensions/com.example.project")
Expect(ok).To(BeTrue())
Expect(name).To(Equal(project))
for _, ns := range []string{"http://opencloud.eu/ns/extensions/", "http://opencloud.eu/ns/extensions/a/b", "http://owncloud.org/ns", "DAV:"} {
_, ok := openextension.NameFromNamespace(ns)
Expect(ok).To(BeFalse(), ns)
}
name, property, ok := openextension.SplitKey(key(project, "status"))
Expect(ok).To(BeTrue())
Expect(name).To(Equal(project))
Expect(property).To(Equal("status"))
for _, k := range []string{"tags", "http://owncloud.org/ns/favorite", "http://opencloud.eu/ns/extensions/com.example.project", "http://opencloud.eu/ns/extensions/com.example.project/", "http://opencloud.eu/ns/extensions//status"} {
_, _, ok := openextension.SplitKey(k)
Expect(ok).To(BeFalse(), k)
}
})
It("collects the extensions out of arbitrary metadata", func() {
metadata := map[string]string{
"tags": "a,b",
key("com.example.b", "x"): "n:1",
key("com.example.a", "y"): "s:z",
key("com.example.a", "plain"): "written without a type code",
key("com.example.a", "bad"): "n:abc",
}
exts := openextension.FromMetadata(metadata)
Expect(exts).To(HaveLen(2))
Expect(exts[0].Name).To(Equal("com.example.a"))
Expect(exts[0].Keys()).To(Equal([]string{"y"}), "unreadable values are skipped")
Expect(exts[1].Name).To(Equal("com.example.b"))
ext, found := openextension.Lookup(metadata, "com.example.b")
Expect(found).To(BeTrue())
Expect(ext.Values["x"].Numbers()).To(Equal([]float64{1}))
_, found = openextension.Lookup(metadata, "com.example")
Expect(found).To(BeFalse(), "a name that is a prefix of another does not match")
Expect(openextension.MetadataKeys(metadata, "com.example.a")).To(Equal([]string{key("com.example.a", "bad"), key("com.example.a", "plain"), key("com.example.a", "y")}))
})
})
Describe("WebDAV", func() {
It("renders every kind with its xsi:type", func() {
p := parse(`{"s":"a<b","i":3,"d":2.5,"b":true,"t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset",` +
`"g":{"latitude":52.5,"longitude":13.4,"altitude":34},"g@odata.type":"#microsoft.graph.geoCoordinates","l":["x","y"],"n":[1,2]}`)
render := func(key string) [2]string {
d := openextension.ToDAV(p.Set[key])
return [2]string{d.Type, string(d.InnerXML)}
}
Expect(render("s")).To(Equal([2]string{"", "a&lt;b"}))
Expect(render("i")).To(Equal([2]string{"xs:integer", "3"}))
Expect(render("d")).To(Equal([2]string{"xs:decimal", "2.5"}))
Expect(render("b")).To(Equal([2]string{"xs:boolean", "true"}))
Expect(render("t")).To(Equal([2]string{"xs:dateTime", "2026-10-01T00:00:00Z"}))
Expect(render("g")).To(Equal([2]string{"oc:geoCoordinates", "<oc:latitude>52.5</oc:latitude><oc:longitude>13.4</oc:longitude><oc:altitude>34</oc:altitude>"}))
Expect(render("l")).To(Equal([2]string{"oc:list", "<oc:item>x</oc:item><oc:item>y</oc:item>"}))
Expect(render("n")).To(Equal([2]string{"oc:list", `<oc:item xsi:type="xs:integer">1</oc:item><oc:item xsi:type="xs:integer">2</oc:item>`}))
})
It("parses PROPPATCH values, a property without xsi:type is a string", func() {
from := func(inner, typ string) openextension.Value {
GinkgoHelper()
v, err := openextension.FromDAV("p", []byte(inner), typ)
Expect(err).NotTo(HaveOccurred())
return v
}
Expect(from("34", "")).To(Equal(openextension.Value{Kind: openextension.KindString, Raw: json.RawMessage(`"34"`)}))
Expect(from("a &amp; b", "")).To(Equal(openextension.Value{Kind: openextension.KindString, Raw: json.RawMessage(`"a & b"`)}))
Expect(from("34", "xs:integer")).To(Equal(openextension.Value{Kind: openextension.KindNumber, Raw: json.RawMessage(`34`)}))
Expect(from(" 2.5 ", "xsd:decimal")).To(Equal(openextension.Value{Kind: openextension.KindNumber, Raw: json.RawMessage(`2.5`)}))
Expect(from("1", "xs:boolean")).To(Equal(openextension.Value{Kind: openextension.KindBool, Raw: json.RawMessage(`true`)}))
Expect(from("2026-10-01T00:00:00Z", "xs:dateTime")).To(Equal(openextension.Value{Kind: openextension.KindDate, Raw: json.RawMessage(`"2026-10-01T00:00:00Z"`)}))
Expect(from("<oc:latitude>52.5</oc:latitude><x:longitude xmlns:x=\"http://owncloud.org/ns\">13.4</x:longitude>", "oc:geoCoordinates")).
To(Equal(openextension.Value{Kind: openextension.KindGeo, Raw: json.RawMessage(`{"latitude":52.5,"longitude":13.4}`)}))
Expect(from(`<oc:item xsi:type="xs:integer">1</oc:item><oc:item xsi:type="xs:integer">2</oc:item>`, "oc:list")).
To(Equal(openextension.Value{Kind: openextension.KindNumber, Array: true, Raw: json.RawMessage(`[1,2]`)}))
Expect(from(`<oc:item>a</oc:item><oc:item>b</oc:item>`, "oc:list")).
To(Equal(openextension.Value{Kind: openextension.KindString, Array: true, Raw: json.RawMessage(`["a","b"]`)}))
})
It("rejects PROPPATCH values that do not fit their type", func() {
for _, c := range [][2]string{{"abc", "xs:integer"}, {"maybe", "xs:boolean"}, {"tomorrow", "xs:dateTime"}, {"<oc:latitude>x</oc:latitude>", "oc:geoCoordinates"}, {"<oc:item>1</oc:item><oc:item xsi:type=\"xs:integer\">2</oc:item>", "oc:list"}} {
_, err := openextension.FromDAV("p", []byte(c[0]), c[1])
Expect(err).To(MatchError(openextension.ErrInvalid), "%s as %s", c[0], c[1])
}
})
It("round-trips a value through WebDAV and the stored form", func() {
p := parse(`{"n":[1,2.5],"g":{"latitude":52.5,"longitude":13.4},"g@odata.type":"#microsoft.graph.geoCoordinates","t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset"}`)
for key, v := range p.Set {
d := openextension.ToDAV(v)
back, err := openextension.FromDAV(key, d.InnerXML, d.Type)
Expect(err).NotTo(HaveOccurred())
Expect(back).To(Equal(v))
stored, err := openextension.DecodeValue(openextension.EncodeValue(back))
Expect(err).NotTo(HaveOccurred())
Expect(stored).To(Equal(v))
}
})
})
})
@@ -0,0 +1,274 @@
package svc
import (
"fmt"
"io"
"net/http"
"net/url"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/openextension"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
)
const _expandOpenExtensions = "extensions"
const maxOpenExtensionBody = 2 * openextension.MaxProperties * openextension.MaxValueSize
type openExtensionCollection struct {
Value []openextension.OpenExtension `json:"value"`
}
// ListOpenExtensions lists the open extensions of a driveItem.
func (g Graph) ListOpenExtensions(w http.ResponseWriter, r *http.Request) {
info, ok := g.statOpenExtensionItem(w, r)
if !ok {
return
}
exts := openextension.FromMetadata(info.GetArbitraryMetadata().GetMetadata())
if exts == nil {
exts = []openextension.OpenExtension{}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, openExtensionCollection{Value: exts})
}
// GetOpenExtension returns one open extension of a driveItem.
func (g Graph) GetOpenExtension(w http.ResponseWriter, r *http.Request) {
name, ok := openExtensionNameParam(w, r)
if !ok {
return
}
info, ok := g.statOpenExtensionItem(w, r)
if !ok {
return
}
ext, found := openextension.Lookup(info.GetArbitraryMetadata().GetMetadata(), name)
if !found {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "extension not found")
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, ext)
}
// UpsertOpenExtension creates an open extension or merges into an existing one:
// members set, null removes, omitted stay.
func (g Graph) UpsertOpenExtension(w http.ResponseWriter, r *http.Request) {
name, ok := openExtensionNameParam(w, r)
if !ok {
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxOpenExtensionBody+1))
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "could not read the request body")
return
}
if len(body) > maxOpenExtensionBody {
errorcode.InvalidRequest.Render(w, r, http.StatusRequestEntityTooLarge, "the request body is too large")
return
}
patch, err := openextension.Parse(body)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
info, ok := g.statOpenExtensionItem(w, r)
if !ok {
return
}
if !canWriteOpenExtensions(info) {
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "no permission to write extensions")
return
}
metadata := info.GetArbitraryMetadata().GetMetadata()
ext, existed := openextension.Lookup(metadata, name)
ext.Apply(patch)
if len(ext.Values) > openextension.MaxProperties {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("extension %s would have %d properties, at most %d are allowed", name, len(ext.Values), openextension.MaxProperties))
return
}
set, unset := patch.Metadata(name)
unset = storedOnly(unset, metadata)
client, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("error selecting next gateway client")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
ref := &provider.Reference{ResourceId: info.GetId()}
if len(set) > 0 {
res, err := client.SetArbitraryMetadata(r.Context(), &provider.SetArbitraryMetadataRequest{
Ref: ref,
ArbitraryMetadata: &provider.ArbitraryMetadata{Metadata: set},
})
if !g.renderMetadataStatus(w, r, res.GetStatus(), err) {
return
}
}
if len(unset) > 0 {
res, err := client.UnsetArbitraryMetadata(r.Context(), &provider.UnsetArbitraryMetadataRequest{Ref: ref, ArbitraryMetadataKeys: unset})
if !g.renderMetadataStatus(w, r, res.GetStatus(), err) {
return
}
}
if len(ext.Values) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
status := http.StatusOK
if !existed {
status = http.StatusCreated
}
render.Status(r, status)
render.JSON(w, r, ext)
}
// DeleteOpenExtension removes an open extension from a driveItem.
func (g Graph) DeleteOpenExtension(w http.ResponseWriter, r *http.Request) {
name, ok := openExtensionNameParam(w, r)
if !ok {
return
}
info, ok := g.statOpenExtensionItem(w, r)
if !ok {
return
}
if !canWriteOpenExtensions(info) {
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "no permission to write extensions")
return
}
keys := openextension.MetadataKeys(info.GetArbitraryMetadata().GetMetadata(), name)
if len(keys) == 0 {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "extension not found")
return
}
client, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("error selecting next gateway client")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
res, err := client.UnsetArbitraryMetadata(r.Context(), &provider.UnsetArbitraryMetadataRequest{
Ref: &provider.Reference{ResourceId: info.GetId()},
ArbitraryMetadataKeys: keys,
})
if !g.renderMetadataStatus(w, r, res.GetStatus(), err) {
return
}
w.WriteHeader(http.StatusNoContent)
}
func openExtensionNameParam(w http.ResponseWriter, r *http.Request) (string, bool) {
name, err := url.PathUnescape(chi.URLParam(r, "extensionName"))
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid extension name")
return "", false
}
if err := openextension.ValidateName(name); err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return "", false
}
return name, true
}
// statOpenExtensionItem stats the item of the request; false means the error was rendered.
func (g Graph) statOpenExtensionItem(w http.ResponseWriter, r *http.Request) (*provider.ResourceInfo, bool) {
_, itemID, err := GetDriveAndItemIDParam(r, g.logger)
if err != nil {
errorcode.RenderError(w, r, err)
return nil, false
}
client, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("error selecting next gateway client")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return nil, false
}
res, err := client.Stat(r.Context(), &provider.StatRequest{Ref: &provider.Reference{ResourceId: itemID}})
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error statting the item")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return nil, false
case res.GetStatus().GetCode() == rpc.Code_CODE_OK:
return res.GetInfo(), true
case res.GetStatus().GetCode() == rpc.Code_CODE_NOT_FOUND, res.GetStatus().GetCode() == rpc.Code_CODE_PERMISSION_DENIED:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return nil, false
case res.GetStatus().GetCode() == rpc.Code_CODE_UNAUTHENTICATED:
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage())
return nil, false
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return nil, false
}
}
// renderMetadataStatus renders a failed write and reports whether it succeeded.
func (g Graph) renderMetadataStatus(w http.ResponseWriter, r *http.Request, status *rpc.Status, err error) bool {
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error writing arbitrary metadata")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return false
case status.GetCode() == rpc.Code_CODE_OK:
return true
case status.GetCode() == rpc.Code_CODE_LOCKED:
errorcode.ItemIsLocked.Render(w, r, http.StatusLocked, "the item is locked")
return false
case status.GetCode() == rpc.Code_CODE_PERMISSION_DENIED:
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, status.GetMessage())
return false
case status.GetCode() == rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, status.GetMessage())
return false
default:
g.logger.Error().Interface("status", status).Msg("error writing arbitrary metadata")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, status.GetMessage())
return false
}
}
// canWriteOpenExtensions is the tag rule: writing metadata needs write access.
func canWriteOpenExtensions(info *provider.ResourceInfo) bool {
pm := info.GetPermissionSet()
return pm != nil && (pm.GetInitiateFileUpload() || pm.GetCreateContainer())
}
func storedOnly(keys []string, metadata map[string]string) []string {
out := keys[:0]
for _, key := range keys {
if _, ok := metadata[key]; ok {
out = append(out, key)
}
}
return out
}
// driveItemWithOpenExtensions adds the extensions relation; the generated model
// has no field for it yet.
func driveItemWithOpenExtensions(item *libregraph.DriveItem, info *provider.ResourceInfo) (map[string]any, error) {
m, err := item.ToMap()
if err != nil {
return nil, err
}
exts := openextension.FromMetadata(info.GetArbitraryMetadata().GetMetadata())
if exts == nil {
exts = []openextension.OpenExtension{}
}
m[_expandOpenExtensions] = exts
return m, nil
}
@@ -0,0 +1,319 @@
package svc_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/mock"
"github.com/tidwall/gjson"
"google.golang.org/grpc"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/shared"
"github.com/opencloud-eu/opencloud/services/graph/mocks"
"github.com/opencloud-eu/opencloud/services/graph/pkg/config"
"github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults"
"github.com/opencloud-eu/opencloud/services/graph/pkg/metrics"
service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0"
)
var _ = Describe("OpenExtensions", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
rr *httptest.ResponseRecorder
itemID = &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "nodeid"}
info *provider.ResourceInfo
writable = &provider.ResourcePermissions{InitiateFileUpload: true}
readonly = &provider.ResourcePermissions{Stat: true}
currentUser = &userpb.User{Id: &userpb.UserId{OpaqueId: "user"}}
)
key := func(name, property string) string {
return "http://opencloud.eu/ns/extensions/" + name + "/" + property
}
// request builds a request against the extension routes with the chi
// parameters set the way the router would.
request := func(method, name, body string, query ...string) *http.Request {
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, "/graph/v1beta1/drives/storageid$spaceid/items/storageid$spaceid!nodeid/extensions/"+name, strings.NewReader(body))
} else {
r = httptest.NewRequest(method, "/graph/v1beta1/drives/storageid$spaceid/items/storageid$spaceid!nodeid/extensions/"+name, nil)
}
if len(query) > 0 {
r.URL.RawQuery = query[0]
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("itemID", "storageid$spaceid!nodeid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
if name != "" {
rctx.URLParams.Add("extensionName", name)
}
return r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
}
statReturns := func(info *provider.ResourceInfo) {
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: status.NewOK(ctx), Info: info}, nil)
}
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
pool.RemoveSelector("GatewaySelector" + "eu.opencloud.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"eu.opencloud.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
logger := log.NewLogger()
metrics, _ := metrics.New(prometheus.NewRegistry(), &logger, func([]string) (string, string) { return "", "" })
rr = httptest.NewRecorder()
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = ""
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.Metrics(metrics),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
)
Expect(err).ToNot(HaveOccurred())
info = &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: itemID,
Etag: "etag",
PermissionSet: writable,
ArbitraryMetadata: &provider.ArbitraryMetadata{Metadata: map[string]string{
"tags": "a,b",
key("com.example.project", "due"): "d:2026-10-01T00:00:00Z",
key("com.example.project", "priority"): "n:3",
key("com.example.project", "status"): "s:open",
key("com.example.audit", "reviewed"): "b:true",
}},
}
})
Describe("ListOpenExtensions", func() {
It("lists every extension with its annotations, sorted by name", func() {
statReturns(info)
svc.ListOpenExtensions(rr, request(http.MethodGet, "", ""))
Expect(rr.Code).To(Equal(http.StatusOK))
body := rr.Body.String()
Expect(gjson.Get(body, "value.#").Int()).To(Equal(int64(2)))
Expect(gjson.Get(body, "value.0.extensionName").String()).To(Equal("com.example.audit"))
Expect(gjson.Get(body, "value.0.reviewed").Bool()).To(BeTrue())
Expect(gjson.Get(body, "value.1.extensionName").String()).To(Equal("com.example.project"))
Expect(gjson.Get(body, "value.1.priority").Int()).To(Equal(int64(3)))
Expect(gjson.Get(body, "value.1.due@odata\\.type").String()).To(Equal("#DateTimeOffset"))
Expect(gjson.Get(body, "value.1.status").String()).To(Equal("open"), "the stored type code stays internal")
Expect(gjson.Get(body, "value.1.status@odata\\.type").Exists()).To(BeFalse(), "inferable types are not annotated")
})
It("answers an item without extensions with an empty collection", func() {
info.ArbitraryMetadata = nil
statReturns(info)
svc.ListOpenExtensions(rr, request(http.MethodGet, "", ""))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect(rr.Body.String()).To(MatchJSON(`{"value":[]}`))
})
It("hides items the caller cannot see", func() {
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: status.NewPermissionDenied(ctx, nil, "denied")}, nil)
svc.ListOpenExtensions(rr, request(http.MethodGet, "", ""))
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
})
Describe("GetOpenExtension", func() {
It("returns the extension", func() {
statReturns(info)
svc.GetOpenExtension(rr, request(http.MethodGet, "com.example.project", ""))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect(rr.Body.String()).To(MatchJSON(`{"extensionName":"com.example.project","due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset","priority":3,"status":"open"}`))
})
It("answers 404 for an extension the item does not have", func() {
statReturns(info)
svc.GetOpenExtension(rr, request(http.MethodGet, "com.example.other", ""))
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("rejects a name that is not reverse DNS", func() {
svc.GetOpenExtension(rr, request(http.MethodGet, "project", ""))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything)
})
})
Describe("UpsertOpenExtension", func() {
var written map[string]string
BeforeEach(func() {
written = nil
gatewayClient.On("SetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
req := args.Get(1).(*provider.SetArbitraryMetadataRequest)
Expect(req.GetRef().GetResourceId()).To(Equal(itemID))
written = req.GetArbitraryMetadata().GetMetadata()
}).Return(&provider.SetArbitraryMetadataResponse{Status: status.NewOK(ctx)}, nil)
})
It("creates an extension, one typed value per property", func() {
statReturns(info)
svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.new",
`{"extensionName":"com.example.new","state":"open","site":{"latitude":52.5,"longitude":13.4},"site@odata.type":"#microsoft.graph.geoCoordinates"}`))
Expect(rr.Code).To(Equal(http.StatusCreated))
Expect(written).To(Equal(map[string]string{
key("com.example.new", "site"): "g:52.5,13.4",
key("com.example.new", "state"): "s:open",
}))
Expect(rr.Body.String()).To(MatchJSON(`{"extensionName":"com.example.new","site":{"latitude":52.5,"longitude":13.4},"site@odata.type":"#microsoft.graph.geoCoordinates","state":"open"}`))
})
It("merges into an existing extension, null removes", func() {
statReturns(info)
var removed []string
gatewayClient.On("UnsetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
removed = args.Get(1).(*provider.UnsetArbitraryMetadataRequest).GetArbitraryMetadataKeys()
}).Return(&provider.UnsetArbitraryMetadataResponse{Status: status.NewOK(ctx)}, nil)
svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"status":"approved","priority":null,"effort":2.5}`))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect(written).To(Equal(map[string]string{
key("com.example.project", "status"): "s:approved",
key("com.example.project", "effort"): "n:2.5",
}), "only the properties of the request are written")
Expect(removed).To(Equal([]string{key("com.example.project", "priority")}))
Expect(rr.Body.String()).To(MatchJSON(`{"extensionName":"com.example.project","due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset","effort":2.5,"status":"approved"}`))
})
It("re-types a property written without its old annotation", func() {
statReturns(info)
svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"due":"later"}`))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect(written).To(Equal(map[string]string{key("com.example.project", "due"): "s:later"}))
})
It("skips the removal of a property that is not stored", func() {
statReturns(info)
svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"nope":null}`))
Expect(rr.Code).To(Equal(http.StatusOK))
gatewayClient.AssertNotCalled(GinkgoT(), "SetArbitraryMetadata", mock.Anything, mock.Anything)
gatewayClient.AssertNotCalled(GinkgoT(), "UnsetArbitraryMetadata", mock.Anything, mock.Anything)
})
It("answers 204 once every property is removed", func() {
statReturns(info)
gatewayClient.On("UnsetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
req := args.Get(1).(*provider.UnsetArbitraryMetadataRequest)
Expect(req.GetArbitraryMetadataKeys()).To(Equal([]string{key("com.example.audit", "reviewed")}))
}).Return(&provider.UnsetArbitraryMetadataResponse{Status: status.NewOK(ctx)}, nil)
svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.audit", `{"reviewed":null}`))
Expect(rr.Code).To(Equal(http.StatusNoContent))
gatewayClient.AssertNotCalled(GinkgoT(), "SetArbitraryMetadata", mock.Anything, mock.Anything)
})
It("rejects an invalid body before touching the storage", func() {
svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"assignee":{"name":"alice"}}`))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
Expect(gjson.Get(rr.Body.String(), "error.message").String()).To(ContainSubstring("objects are only allowed as geoCoordinates"))
gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything)
})
It("needs write access", func() {
info.PermissionSet = readonly
statReturns(info)
svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"status":"x"}`))
Expect(rr.Code).To(Equal(http.StatusForbidden))
gatewayClient.AssertNotCalled(GinkgoT(), "SetArbitraryMetadata", mock.Anything, mock.Anything)
})
It("reports a locked item", func() {
statReturns(info)
gatewayClient.ExpectedCalls = nil
statReturns(info)
gatewayClient.On("SetArbitraryMetadata", mock.Anything, mock.Anything).Return(&provider.SetArbitraryMetadataResponse{Status: status.NewLocked(ctx, "locked")}, nil)
svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"status":"x"}`))
Expect(rr.Code).To(Equal(http.StatusLocked))
})
})
Describe("DeleteOpenExtension", func() {
It("unsets every stored property", func() {
statReturns(info)
gatewayClient.On("UnsetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
req := args.Get(1).(*provider.UnsetArbitraryMetadataRequest)
Expect(req.GetArbitraryMetadataKeys()).To(Equal([]string{key("com.example.project", "due"), key("com.example.project", "priority"), key("com.example.project", "status")}))
}).Return(&provider.UnsetArbitraryMetadataResponse{Status: status.NewOK(ctx)}, nil)
svc.DeleteOpenExtension(rr, request(http.MethodDelete, "com.example.project", ""))
Expect(rr.Code).To(Equal(http.StatusNoContent))
})
It("answers 404 for an extension the item does not have", func() {
statReturns(info)
svc.DeleteOpenExtension(rr, request(http.MethodDelete, "com.example.other", ""))
Expect(rr.Code).To(Equal(http.StatusNotFound))
gatewayClient.AssertNotCalled(GinkgoT(), "UnsetArbitraryMetadata", mock.Anything, mock.Anything)
})
It("needs write access", func() {
info.PermissionSet = readonly
statReturns(info)
svc.DeleteOpenExtension(rr, request(http.MethodDelete, "com.example.project", ""))
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
})
Describe("GetDriveItem", func() {
It("expands the extensions on request", func() {
statReturns(info)
svc.GetDriveItem(rr, request(http.MethodGet, "", "", "$expand=extensions"))
Expect(rr.Code).To(Equal(http.StatusOK))
body := rr.Body.String()
Expect(gjson.Get(body, "id").String()).To(Equal("storageid$spaceid!nodeid"))
Expect(gjson.Get(body, "extensions.#").Int()).To(Equal(int64(2)))
Expect(gjson.Get(body, "extensions.1.extensionName").String()).To(Equal("com.example.project"))
Expect(gjson.Get(body, "extensions.1.due@odata\\.type").String()).To(Equal("#DateTimeOffset"))
})
It("leaves the extensions out without $expand", func() {
statReturns(info)
svc.GetDriveItem(rr, request(http.MethodGet, "", ""))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect(gjson.Get(rr.Body.String(), "extensions").Exists()).To(BeFalse())
})
})
})
+12
View File
@@ -103,6 +103,10 @@ type Service interface { //nolint:interfacebloat
GetRootDriveChildren(w http.ResponseWriter, r *http.Request)
GetDriveItem(w http.ResponseWriter, r *http.Request)
ListOpenExtensions(w http.ResponseWriter, r *http.Request)
GetOpenExtension(w http.ResponseWriter, r *http.Request)
UpsertOpenExtension(w http.ResponseWriter, r *http.Request)
DeleteOpenExtension(w http.ResponseWriter, r *http.Request)
GetDriveItemChildren(w http.ResponseWriter, r *http.Request)
CreateUploadSession(w http.ResponseWriter, r *http.Request)
@@ -273,6 +277,14 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Delete("/", drivesDriveItemApi.DeleteDriveItem)
r.Post("/invite", driveItemPermissionsApi.Invite)
r.Post("/createLink", driveItemPermissionsApi.CreateLink)
r.Route("/extensions", func(r chi.Router) {
r.Get("/", svc.ListOpenExtensions)
r.Route("/{extensionName}", func(r chi.Router) {
r.Get("/", svc.GetOpenExtension)
r.Put("/", svc.UpsertOpenExtension)
r.Delete("/", svc.DeleteOpenExtension)
})
})
r.Route("/permissions", func(r chi.Router) {
r.Get("/", driveItemPermissionsApi.ListPermissions)
r.Route("/{permissionID}", func(r chi.Router) {