mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-25 13:37:09 -04:00
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:
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<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 & 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user