mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-06 10:52:08 -04:00
feat(graph): add POST /search/query
MS Graph style search endpoint: searchRequest with KQL queryString, from/size pagination (values outside the spec's bounds are rejected), entityTypes validated to driveItem, hits as driveItems with webUrl, parentReference, remoteItem for shares, the facets and @libre.graph.permissions.actions.allowedValues, which the search service now projects onto the Entity from the same permission set as the WebDAV report. Properties the endpoint does not evaluate yet (aggregations, aggregationFilters, sortProperties) answer 501 instead of being ignored, an unknown $expand answers 400, and a failing search service keeps its status. Hits reuse what the drive item listing has, which moves a little: the thumbnails helper is named for the drive item it fills (setDriveItemThumbnailsByID, it served shares only), the web URL of an id gets a helper (webURLForID), and the facets come out of the proto through one generic mapping.FromProto instead of a copy per facet.
This commit is contained in:
1 parent
f12be4c61e
commit
3a702a2e5b
14 files changed
+789
-22
No files matched your search
@@ -824,6 +824,10 @@ type Entity struct {
|
||||
MotionPhoto *MotionPhoto `protobuf:"bytes,21,opt,name=motionPhoto,proto3" json:"motionPhoto,omitempty"`
|
||||
Video *Video `protobuf:"bytes,22,opt,name=video,proto3" json:"video,omitempty"`
|
||||
LivePhoto *LivePhoto `protobuf:"bytes,23,opt,name=livePhoto,proto3" json:"livePhoto,omitempty"`
|
||||
// The effective permission actions of the caller, projected from the space
|
||||
// root permission set at query time (the same source as `permissions`), for
|
||||
// the driveItem `@libre.graph.permissions.actions.allowedValues` facet.
|
||||
PermissionsActionsAllowedValues []string `protobuf:"bytes,24,rep,name=permissionsActionsAllowedValues,proto3" json:"permissionsActionsAllowedValues,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Entity) Reset() {
|
||||
@@ -1019,6 +1023,13 @@ func (x *Entity) GetLivePhoto() *LivePhoto {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Entity) GetPermissionsActionsAllowedValues() []string {
|
||||
if x != nil {
|
||||
return x.PermissionsActionsAllowedValues
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Match struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -1259,7 +1270,7 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{
|
||||
0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69,
|
||||
0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61,
|
||||
0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69,
|
||||
0x6f, 0x6e, 0x22, 0xc9, 0x08, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a,
|
||||
0x6f, 0x6e, 0x22, 0x93, 0x09, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a,
|
||||
0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65,
|
||||
0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||
0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65,
|
||||
@@ -1327,19 +1338,23 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{
|
||||
0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65,
|
||||
0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||
0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68,
|
||||
0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x22, 0x5b,
|
||||
0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74,
|
||||
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c,
|
||||
0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61,
|
||||
0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65,
|
||||
0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67,
|
||||
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c,
|
||||
0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64,
|
||||
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70,
|
||||
0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
||||
0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x33,
|
||||
0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x48,
|
||||
0x0a, 0x1f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x41, 0x63, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x73, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x73, 0x18, 0x18, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73,
|
||||
0x69, 0x6f, 0x6e, 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
|
||||
0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63,
|
||||
0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30,
|
||||
0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12,
|
||||
0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05,
|
||||
0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e,
|
||||
0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75,
|
||||
0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75,
|
||||
0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63,
|
||||
0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -307,6 +307,13 @@
|
||||
},
|
||||
"livePhoto": {
|
||||
"$ref": "#/definitions/v0LivePhoto"
|
||||
},
|
||||
"permissionsActionsAllowedValues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The effective permission actions of the caller, projected from the space\nroot permission set at query time (the same source as `permissions`), for\nthe driveItem `@libre.graph.permissions.actions.allowedValues` facet."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -110,6 +110,10 @@ message Entity {
|
||||
MotionPhoto motionPhoto = 21;
|
||||
Video video = 22;
|
||||
LivePhoto livePhoto = 23;
|
||||
// The effective permission actions of the caller, projected from the space
|
||||
// root permission set at query time (the same source as `permissions`), for
|
||||
// the driveItem `@libre.graph.permissions.actions.allowedValues` facet.
|
||||
repeated string permissionsActionsAllowedValues = 24;
|
||||
}
|
||||
|
||||
message Match {
|
||||
|
||||
@@ -500,6 +500,15 @@ func cs3TimestampToTime(t *types.Timestamp) time.Time {
|
||||
return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos()))
|
||||
}
|
||||
|
||||
func webURLForID(publicBaseURL *url.URL, id string) *string {
|
||||
if publicBaseURL == nil {
|
||||
return nil
|
||||
}
|
||||
u := *publicBaseURL
|
||||
u.Path = path.Join(u.Path, "f", id)
|
||||
return libregraph.PtrString(u.String())
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
|
||||
size := new(int64)
|
||||
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
|
||||
@@ -509,9 +518,7 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto
|
||||
Size: size,
|
||||
}
|
||||
|
||||
webURL := *publicBaseURL
|
||||
webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(res.GetId()))
|
||||
driveItem.WebUrl = libregraph.PtrString(webURL.String())
|
||||
driveItem.WebUrl = webURLForID(publicBaseURL, storagespace.FormatResourceID(res.GetId()))
|
||||
|
||||
if name := path.Base(res.GetPath()); name != "" {
|
||||
driveItem.Name = &name
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
"go-micro.dev/v4/metadata"
|
||||
|
||||
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// SearchQuery handles POST /v1beta1/search/query (MS Graph searchQuery).
|
||||
func (g Graph) SearchQuery(w http.ResponseWriter, r *http.Request) {
|
||||
var req libregraph.SearchQueryRequest
|
||||
if err := StrictJSONUnmarshal(r.Body, &req); err != nil {
|
||||
g.logger.Debug().Err(err).Msg("could not decode search query request")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Requests) == 0 {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "requests array must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateSearchExpand(r); err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// every request is validated before the first one runs
|
||||
searches := make([]*searchsvc.SearchRequest, 0, len(req.Requests))
|
||||
for _, sr := range req.Requests {
|
||||
if property := unsupportedProperty(sr); property != "" {
|
||||
errorcode.NotSupported.Render(w, r, http.StatusNotImplemented, property+" is not supported yet")
|
||||
return
|
||||
}
|
||||
prepared, err := searchRequestOf(sr)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
searches = append(searches, prepared)
|
||||
}
|
||||
|
||||
th := r.Header.Get(revaCtx.TokenHeader)
|
||||
ctx := revaCtx.ContextSetToken(r.Context(), th)
|
||||
ctx = metadata.Set(ctx, revaCtx.TokenHeader, th)
|
||||
|
||||
expandThumbnails := driveItemRelationExpanded(r, _expandThumbnails)
|
||||
|
||||
responses := make([]libregraph.SearchResponse, 0, len(req.Requests))
|
||||
for i, sr := range req.Requests {
|
||||
sresp, err := g.runSingleSearch(ctx, sr, searches[i], expandThumbnails)
|
||||
if err != nil {
|
||||
g.renderSearchError(w, r, err)
|
||||
return
|
||||
}
|
||||
responses = append(responses, sresp)
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, libregraph.SearchQuery200Response{Value: responses})
|
||||
}
|
||||
|
||||
func validateSearchExpand(r *http.Request) error {
|
||||
for _, values := range r.URL.Query()["$expand"] {
|
||||
for _, relation := range strings.Split(values, ",") {
|
||||
if relation != _expandThumbnails {
|
||||
return fmt.Errorf("unsupported $expand %q; only %s is supported", relation, _expandThumbnails)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsupportedProperty names a property of the spec this endpoint does not
|
||||
// evaluate yet.
|
||||
func unsupportedProperty(sr libregraph.SearchRequest) string {
|
||||
if len(sr.SortProperties) > 0 {
|
||||
return "sortProperties"
|
||||
}
|
||||
if len(sr.Aggregations) > 0 {
|
||||
return "aggregations"
|
||||
}
|
||||
if len(sr.AggregationFilters) > 0 {
|
||||
return "aggregationFilters"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// searchRequestOf validates one request against the spec and translates it.
|
||||
func searchRequestOf(sr libregraph.SearchRequest) (*searchsvc.SearchRequest, error) {
|
||||
if err := validateEntityTypes(sr.EntityTypes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePagination(sr.From, sr.Size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
from, size := pagination(sr.From, sr.Size)
|
||||
return &searchsvc.SearchRequest{
|
||||
Query: sr.Query.QueryString,
|
||||
From: from,
|
||||
PageSize: &size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateEntityTypes(entityTypes []string) error {
|
||||
if len(entityTypes) == 0 {
|
||||
return fmt.Errorf("entityTypes must contain at least one entry")
|
||||
}
|
||||
for _, t := range entityTypes {
|
||||
if t != "driveItem" {
|
||||
return fmt.Errorf("unsupported entity type %q; only driveItem is supported", t)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest, prepared *searchsvc.SearchRequest, expandThumbnails bool) (libregraph.SearchResponse, error) {
|
||||
from, size := prepared.GetFrom(), prepared.GetPageSize()
|
||||
|
||||
rsp, err := g.searchService.Search(ctx, prepared)
|
||||
if err != nil {
|
||||
return libregraph.SearchResponse{}, err
|
||||
}
|
||||
|
||||
// the caller's id decides @libre.graph.me.following (the WebDAV report's oc:favorite)
|
||||
uid := ""
|
||||
if u, ok := revaCtx.ContextGetUser(ctx); ok {
|
||||
uid = u.GetId().GetOpaqueId()
|
||||
}
|
||||
|
||||
hits := make([]libregraph.SearchHit, 0, len(rsp.Matches))
|
||||
for i, match := range rsp.Matches {
|
||||
item := searchEntityToDriveItem(match.GetEntity(), uid)
|
||||
item.WebUrl = webURLForID(g.publicBaseURL, item.GetId())
|
||||
if expandThumbnails {
|
||||
setDriveItemThumbnailsByID(item, item.GetId(), g.config.Commons.OpenCloudURL)
|
||||
}
|
||||
hit := libregraph.SearchHit{HitId: item.Id, Rank: libregraph.PtrInt32(from + int32(i) + 1), Resource: item}
|
||||
if h := match.GetEntity().GetHighlights(); h != "" {
|
||||
hit.Summary = libregraph.PtrString(h)
|
||||
}
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
|
||||
total := int64(rsp.TotalMatches)
|
||||
// a next page has to exist and to be within reach
|
||||
next := int64(from) + int64(size)
|
||||
more := next < total && next < search.MaxResultWindow
|
||||
return libregraph.SearchResponse{
|
||||
SearchTerms: []string{sr.Query.QueryString},
|
||||
HitsContainers: []libregraph.SearchHitsContainer{{
|
||||
Hits: hits,
|
||||
Total: &total,
|
||||
MoreResultsAvailable: &more,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// the spec's default and upper bound of SearchRequest.size
|
||||
const (
|
||||
defaultPageSize = 25
|
||||
maxPageSize = 500
|
||||
)
|
||||
|
||||
func pagination(fromP, sizeP *int32) (from, size int32) {
|
||||
from, size = 0, defaultPageSize
|
||||
if fromP != nil {
|
||||
from = *fromP
|
||||
}
|
||||
if sizeP != nil {
|
||||
size = *sizeP
|
||||
}
|
||||
return from, size
|
||||
}
|
||||
|
||||
// openapi-generator does not enforce the spec's [0,inf)/[0,500] bounds
|
||||
func validatePagination(fromP, sizeP *int32) error {
|
||||
from, size := pagination(fromP, sizeP)
|
||||
if from < 0 {
|
||||
return fmt.Errorf("from must not be negative")
|
||||
}
|
||||
if size < 0 || size > maxPageSize {
|
||||
return fmt.Errorf("size must be between 0 and %d", maxPageSize)
|
||||
}
|
||||
return search.CheckResultWindow(from, size)
|
||||
}
|
||||
|
||||
// renderSearchError answers with the status the search service failed with.
|
||||
func (g Graph) renderSearchError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
e := merrors.Parse(err.Error())
|
||||
switch e.Code {
|
||||
case http.StatusBadRequest:
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, e.Detail)
|
||||
case http.StatusRequestTimeout, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
g.logger.Error().Err(err).Msg("search service did not answer")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusServiceUnavailable, e.Detail)
|
||||
default:
|
||||
g.logger.Error().Err(err).Msg("search service call failed")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, e.Detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"path"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
|
||||
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
)
|
||||
|
||||
func searchResourceID(id *searchmsg.ResourceID) string {
|
||||
return storagespace.FormatResourceID(&storageprovider.ResourceId{
|
||||
StorageId: id.GetStorageId(),
|
||||
SpaceId: id.GetSpaceId(),
|
||||
OpaqueId: id.GetOpaqueId(),
|
||||
})
|
||||
}
|
||||
|
||||
func searchEntityToDriveItem(e *searchmsg.Entity, uid string) *libregraph.DriveItem {
|
||||
size := int64(e.GetSize())
|
||||
di := &libregraph.DriveItem{
|
||||
Id: libregraph.PtrString(searchResourceID(e.GetId())),
|
||||
Name: libregraph.PtrString(e.GetName()),
|
||||
Size: &size,
|
||||
}
|
||||
if etag := e.GetEtag(); etag != "" {
|
||||
di.ETag = &etag
|
||||
}
|
||||
if mt := e.GetLastModifiedTime(); mt != nil {
|
||||
lm := time.Unix(mt.GetSeconds(), int64(mt.GetNanos())).UTC()
|
||||
di.LastModifiedDateTime = &lm
|
||||
}
|
||||
if e.GetType() == uint64(storageprovider.ResourceType_RESOURCE_TYPE_FILE) && e.GetMimeType() != "" {
|
||||
mt := e.GetMimeType()
|
||||
di.File = &libregraph.OpenGraphFile{MimeType: &mt}
|
||||
}
|
||||
if e.GetType() == uint64(storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER) {
|
||||
di.Folder = &libregraph.Folder{}
|
||||
}
|
||||
if p := e.GetParentId(); p != nil {
|
||||
ref := libregraph.NewItemReference()
|
||||
ref.SetDriveId(storagespace.FormatStorageID(p.GetStorageId(), p.GetSpaceId()))
|
||||
ref.SetId(searchResourceID(p))
|
||||
if refPath := e.GetRef().GetPath(); refPath != "" {
|
||||
// the index keeps paths relative to the space root (./dir/file)
|
||||
parent := path.Dir(path.Join("/", refPath))
|
||||
ref.SetPath(parent)
|
||||
if parent != "/" {
|
||||
ref.SetName(path.Base(parent))
|
||||
}
|
||||
}
|
||||
di.ParentReference = ref
|
||||
}
|
||||
di.RemoteItem = searchEntityToRemoteItem(e)
|
||||
di.Audio = mapping.FromProto[libregraph.Audio](e.GetAudio())
|
||||
di.Image = mapping.FromProto[libregraph.Image](e.GetImage())
|
||||
di.Photo = mapping.FromProto[libregraph.Photo](e.GetPhoto())
|
||||
di.Location = mapping.FromProto[libregraph.GeoCoordinates](e.GetLocation())
|
||||
di.Video = mapping.FromProto[libregraph.Video](e.GetVideo())
|
||||
di.LibreGraphMotionPhoto = mapping.FromProto[libregraph.MotionPhoto](e.GetMotionPhoto())
|
||||
di.LibreGraphLivePhoto = mapping.FromProto[libregraph.LivePhoto](e.GetLivePhoto())
|
||||
if tags := e.GetTags(); len(tags) > 0 {
|
||||
di.LibreGraphTags = tags
|
||||
}
|
||||
if av := e.GetPermissionsActionsAllowedValues(); len(av) > 0 {
|
||||
di.LibreGraphPermissionsActionsAllowedValues = av
|
||||
}
|
||||
// the WebDAV report emits oc:favorite only when the caller favorited the item; mirror that
|
||||
if uid != "" && slices.Contains(e.GetFavorites(), uid) {
|
||||
di.LibreGraphMeFollowing = libregraph.PtrBool(true)
|
||||
}
|
||||
return di
|
||||
}
|
||||
|
||||
// remoteItem carries the id in the owner's drive and the mountpoint it is
|
||||
// reached through; absent for hits from the caller's own spaces
|
||||
func searchEntityToRemoteItem(e *searchmsg.Entity) *libregraph.RemoteItem {
|
||||
id := e.GetRemoteItemId()
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
item := libregraph.NewRemoteItem()
|
||||
item.SetId(searchResourceID(id))
|
||||
|
||||
if root := e.GetShareRootName(); root != "" {
|
||||
item.SetPath(root)
|
||||
item.SetName(path.Base(root))
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
// ginkgo qualified: the svc package declares Context (option.go), which
|
||||
// would collide with a dot-import.
|
||||
ginkgo "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"go-micro.dev/v4/client"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
|
||||
)
|
||||
|
||||
type stubSearchService struct {
|
||||
search func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error)
|
||||
}
|
||||
|
||||
func (s stubSearchService) Search(_ context.Context, req *searchsvc.SearchRequest, _ ...client.CallOption) (*searchsvc.SearchResponse, error) {
|
||||
return s.search(req)
|
||||
}
|
||||
|
||||
func (s stubSearchService) IndexSpace(_ context.Context, _ *searchsvc.IndexSpaceRequest, _ ...client.CallOption) (searchsvc.SearchProvider_IndexSpaceService, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func graphWithSearch(stub stubSearchService) Graph {
|
||||
logger := log.NewLogger()
|
||||
return Graph{
|
||||
BaseGraphService: BaseGraphService{logger: &logger},
|
||||
searchService: stub,
|
||||
}
|
||||
}
|
||||
|
||||
// graphWithSearchAnswer answers every search with resp and hands out the last
|
||||
// request it saw.
|
||||
func graphWithSearchAnswer(resp *searchsvc.SearchResponse) (Graph, func() *searchsvc.SearchRequest) {
|
||||
var captured *searchsvc.SearchRequest
|
||||
g := graphWithSearch(stubSearchService{
|
||||
search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
|
||||
captured = req
|
||||
return resp, nil
|
||||
},
|
||||
})
|
||||
return g, func() *searchsvc.SearchRequest { return captured }
|
||||
}
|
||||
|
||||
func graphWithoutSearch() Graph {
|
||||
return graphWithSearch(stubSearchService{
|
||||
search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
|
||||
ginkgo.Fail("search service must not be called when validation fails")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func postSearchQuery(g Graph, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/search/query", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
g.SearchQuery(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func searchQueryBody(fragment string) string {
|
||||
return `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "mediatype:audio"}, "size": 0, ` + fragment + `}]}`
|
||||
}
|
||||
|
||||
func oneMatch(entity *searchmsg.Entity) *searchsvc.SearchResponse {
|
||||
return &searchsvc.SearchResponse{TotalMatches: 1, Matches: []*searchmsg.Match{{Entity: entity}}}
|
||||
}
|
||||
|
||||
type searchHitsContainerJSON struct {
|
||||
Hits []struct {
|
||||
Rank int32 `json:"rank"`
|
||||
Resource struct {
|
||||
RemoteItem *struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
} `json:"remoteItem"`
|
||||
ParentReference struct {
|
||||
Id string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Name *string `json:"name"`
|
||||
} `json:"parentReference"`
|
||||
} `json:"resource"`
|
||||
} `json:"hits"`
|
||||
Total int64 `json:"total"`
|
||||
MoreResultsAvailable bool `json:"moreResultsAvailable"`
|
||||
}
|
||||
|
||||
// hitsContainers decodes the one hits container of every request.
|
||||
func hitsContainers(rr *httptest.ResponseRecorder) []searchHitsContainerJSON {
|
||||
var decoded struct {
|
||||
Value []struct {
|
||||
HitsContainers []searchHitsContainerJSON `json:"hitsContainers"`
|
||||
} `json:"value"`
|
||||
}
|
||||
Expect(json.Unmarshal(rr.Body.Bytes(), &decoded)).To(Succeed())
|
||||
out := make([]searchHitsContainerJSON, 0, len(decoded.Value))
|
||||
for _, v := range decoded.Value {
|
||||
Expect(v.HitsContainers).To(HaveLen(1))
|
||||
out = append(out, v.HitsContainers[0])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hitsContainer(rr *httptest.ResponseRecorder) searchHitsContainerJSON {
|
||||
containers := hitsContainers(rr)
|
||||
Expect(containers).To(HaveLen(1))
|
||||
return containers[0]
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("SearchQuery", func() {
|
||||
ginkgo.DescribeTable("rejects a malformed request with 400 before any search runs",
|
||||
func(body, message string) {
|
||||
rr := postSearchQuery(graphWithoutSearch(), body)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String())
|
||||
Expect(rr.Body.String()).To(ContainSubstring(message))
|
||||
},
|
||||
ginkgo.Entry("entityTypes absent, the generated model requires it", `{"requests": [{"query": {"queryString": "fox"}}]}`, "invalid body"),
|
||||
ginkgo.Entry("entityTypes empty", `{"requests": [{"entityTypes": [], "query": {"queryString": "fox"}}]}`, "entityTypes"),
|
||||
ginkgo.Entry("another entity", `{"requests": [{"entityTypes": ["message"], "query": {"queryString": "fox"}}]}`, "message"),
|
||||
ginkgo.Entry("a page beyond the first 10000 matches, whatever the engine",
|
||||
`{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}, "from": 10000, "size": 25}]}`,
|
||||
`"message":"from and size reach beyond`),
|
||||
ginkgo.Entry("a bad second request, every request is validated before the first one runs", `{"requests": [
|
||||
{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}},
|
||||
{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}, "size": 1000}
|
||||
]}`, "size must be"),
|
||||
)
|
||||
|
||||
ginkgo.It("answers one hits container per request, in request order", func() {
|
||||
g := graphWithSearch(stubSearchService{
|
||||
search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
|
||||
return &searchsvc.SearchResponse{TotalMatches: int32(len(req.GetQuery()))}, nil
|
||||
},
|
||||
})
|
||||
rr := postSearchQuery(g, `{"requests": [
|
||||
{"entityTypes": ["driveItem"], "query": {"queryString": "a"}, "size": 0},
|
||||
{"entityTypes": ["driveItem"], "query": {"queryString": "abc"}, "size": 0}
|
||||
]}`)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
|
||||
|
||||
containers := hitsContainers(rr)
|
||||
Expect(containers).To(HaveLen(2))
|
||||
Expect(containers[0].Total).To(Equal(int64(1)))
|
||||
Expect(containers[1].Total).To(Equal(int64(3)))
|
||||
})
|
||||
|
||||
ginkgo.DescribeTable("describes a hit from a shared space as a remote item",
|
||||
func(entity *searchmsg.Entity, wantID, wantPath, wantName string) {
|
||||
g, _ := graphWithSearchAnswer(oneMatch(entity))
|
||||
rr := postSearchQuery(g, searchQueryBody(`"from": 0`))
|
||||
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
|
||||
|
||||
remote := hitsContainer(rr).Hits[0].Resource.RemoteItem
|
||||
if wantID == "" {
|
||||
Expect(remote).To(BeNil())
|
||||
return
|
||||
}
|
||||
Expect(remote.Id).To(Equal(wantID))
|
||||
Expect(remote.Path).To(Equal(wantPath))
|
||||
Expect(remote.Name).To(Equal(wantName), "the mountpoint name the caller sees")
|
||||
},
|
||||
ginkgo.Entry("a hit from a shared space", &searchmsg.Entity{
|
||||
Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
|
||||
Name: "contract.pdf",
|
||||
ShareRootName: "/Project X",
|
||||
RemoteItemId: &searchmsg.ResourceID{StorageId: "4", SpaceId: "5", OpaqueId: "6"},
|
||||
}, "4$5!6", "/Project X", "Project X"),
|
||||
ginkgo.Entry("a hit from the caller's own space has none", &searchmsg.Entity{
|
||||
Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
|
||||
Name: "notes.txt",
|
||||
}, "", "", ""),
|
||||
)
|
||||
|
||||
ginkgo.DescribeTable("describes the parent of a hit by its path from the space root",
|
||||
func(refPath, wantPath string, wantName *string) {
|
||||
g, _ := graphWithSearchAnswer(oneMatch(&searchmsg.Entity{
|
||||
Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
|
||||
ParentId: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "4"},
|
||||
Ref: &searchmsg.Reference{Path: refPath},
|
||||
Name: "notes.txt",
|
||||
}))
|
||||
rr := postSearchQuery(g, searchQueryBody(`"from": 0`))
|
||||
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
|
||||
|
||||
parent := hitsContainer(rr).Hits[0].Resource.ParentReference
|
||||
Expect(parent.Id).To(Equal("1$2!4"))
|
||||
Expect(parent.Path).To(Equal(wantPath))
|
||||
if wantName == nil {
|
||||
Expect(parent.Name).To(BeNil())
|
||||
} else {
|
||||
Expect(parent.Name).To(HaveValue(Equal(*wantName)))
|
||||
}
|
||||
},
|
||||
ginkgo.Entry("a hit in a folder", "./projects/2026/notes.txt", "/projects/2026", libregraph.PtrString("2026")),
|
||||
ginkgo.Entry("a hit in the space root, the root has no name", "./notes.txt", "/", nil),
|
||||
)
|
||||
|
||||
ginkgo.DescribeTable("validatePagination enforces the spec's from/size bounds",
|
||||
func(from, size *int32, wantErr bool) {
|
||||
err := validatePagination(from, size)
|
||||
if wantErr {
|
||||
Expect(err).To(HaveOccurred())
|
||||
} else {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
},
|
||||
ginkgo.Entry("absent values are valid", nil, nil, false),
|
||||
ginkgo.Entry("zero size is valid", libregraph.PtrInt32(5), libregraph.PtrInt32(0), false),
|
||||
ginkgo.Entry("maximum size is valid", libregraph.PtrInt32(0), libregraph.PtrInt32(500), false),
|
||||
ginkgo.Entry("negative from is rejected", libregraph.PtrInt32(-10), libregraph.PtrInt32(5), true),
|
||||
ginkgo.Entry("negative size is rejected", libregraph.PtrInt32(10), libregraph.PtrInt32(-1), true),
|
||||
ginkgo.Entry("oversized size is rejected", libregraph.PtrInt32(0), libregraph.PtrInt32(1000), true),
|
||||
ginkgo.Entry("the last page within the first 10000 matches is valid", libregraph.PtrInt32(9975), libregraph.PtrInt32(25), false),
|
||||
ginkgo.Entry("a page reaching beyond the first 10000 matches is rejected", libregraph.PtrInt32(9990), libregraph.PtrInt32(25), true),
|
||||
ginkgo.Entry("the default size counts", libregraph.PtrInt32(10000), nil, true),
|
||||
ginkgo.Entry("a from that overflows with the size is rejected", libregraph.PtrInt32(2147483647), libregraph.PtrInt32(500), true),
|
||||
)
|
||||
|
||||
ginkgo.It("pushes from/size to the search service and offsets the ranks", func() {
|
||||
g, captured := graphWithSearchAnswer(&searchsvc.SearchResponse{
|
||||
TotalMatches: 42,
|
||||
Matches: []*searchmsg.Match{
|
||||
{Entity: &searchmsg.Entity{Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "a"}, Name: "a.txt"}},
|
||||
{Entity: &searchmsg.Entity{Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "b"}, Name: "b.txt"}},
|
||||
},
|
||||
})
|
||||
rr := postSearchQuery(g, `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}, "from": 10, "size": 2}]}`)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
|
||||
Expect(captured().GetFrom()).To(Equal(int32(10)))
|
||||
Expect(captured().GetPageSize()).To(Equal(int32(2)))
|
||||
|
||||
hc := hitsContainer(rr)
|
||||
Expect(hc.Total).To(Equal(int64(42)))
|
||||
Expect(hc.MoreResultsAvailable).To(BeTrue())
|
||||
Expect(hc.Hits).To(HaveLen(2))
|
||||
Expect(hc.Hits[0].Rank).To(Equal(int32(11)))
|
||||
Expect(hc.Hits[1].Rank).To(Equal(int32(12)))
|
||||
})
|
||||
|
||||
ginkgo.DescribeTable("promises more results only within reach of from and size",
|
||||
func(from, size, total int, want bool) {
|
||||
g, _ := graphWithSearchAnswer(&searchsvc.SearchResponse{TotalMatches: int32(total)})
|
||||
rr := postSearchQuery(g, fmt.Sprintf(`{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}, "from": %d, "size": %d}]}`, from, size))
|
||||
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
|
||||
Expect(hitsContainer(rr).MoreResultsAvailable).To(Equal(want))
|
||||
},
|
||||
ginkgo.Entry("more matches than the page", 0, 25, 42, true),
|
||||
ginkgo.Entry("the last page", 25, 25, 42, false),
|
||||
ginkgo.Entry("a next page within the first 10000 matches", 9950, 25, 20000, true),
|
||||
ginkgo.Entry("a next page the search would refuse", 9975, 25, 20000, false),
|
||||
)
|
||||
|
||||
ginkgo.It("sends an explicit zero page size for facet-only requests", func() {
|
||||
g, captured := graphWithSearchAnswer(&searchsvc.SearchResponse{TotalMatches: 7})
|
||||
rr := postSearchQuery(g, searchQueryBody(`"from": 0`))
|
||||
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
|
||||
Expect(captured().PageSize).To(HaveValue(Equal(int32(0))))
|
||||
|
||||
hc := hitsContainer(rr)
|
||||
Expect(hc.Total).To(Equal(int64(7)))
|
||||
Expect(hc.Hits).To(BeEmpty())
|
||||
})
|
||||
|
||||
ginkgo.DescribeTable("answers a property it does not evaluate with 501 instead of ignoring it",
|
||||
func(fragment, property string) {
|
||||
rr := postSearchQuery(graphWithoutSearch(), searchQueryBody(fragment))
|
||||
Expect(rr.Code).To(Equal(http.StatusNotImplemented), rr.Body.String())
|
||||
Expect(rr.Body.String()).To(ContainSubstring("notSupported"))
|
||||
Expect(rr.Body.String()).To(ContainSubstring(property))
|
||||
},
|
||||
ginkgo.Entry("sortProperties", `"sortProperties": [{"name": "name"}]`, "sortProperties"),
|
||||
ginkgo.Entry("aggregations", `"aggregations": [{"field": "audio.artist"}]`, "aggregations"),
|
||||
ginkgo.Entry("aggregationFilters", `"aggregationFilters": ["audio.artist:\"ǂǂ5361786f6e\""]`, "aggregationFilters"),
|
||||
)
|
||||
|
||||
ginkgo.It("rejects an $expand it does not know with 400", func() {
|
||||
req := httptest.NewRequest(http.MethodPost, "/search/query?$expand=thumbnails,permissions", bytes.NewBufferString(searchQueryBody(`"from": 0`)))
|
||||
rr := httptest.NewRecorder()
|
||||
graphWithoutSearch().SearchQuery(rr, req)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String())
|
||||
Expect(rr.Body.String()).To(ContainSubstring("permissions"))
|
||||
})
|
||||
|
||||
ginkgo.DescribeTable("answers with the status the search service failed with",
|
||||
func(serviceErr error, status int, code, message string) {
|
||||
g := graphWithSearch(stubSearchService{
|
||||
search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { return nil, serviceErr },
|
||||
})
|
||||
rr := postSearchQuery(g, searchQueryBody(`"from": 0`))
|
||||
Expect(rr.Code).To(Equal(status), rr.Body.String())
|
||||
|
||||
var decoded struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
Expect(json.Unmarshal(rr.Body.Bytes(), &decoded)).To(Succeed())
|
||||
Expect(decoded.Error.Code).To(Equal(code))
|
||||
Expect(decoded.Error.Message).To(Equal(message), "the detail, not the error envelope of the service")
|
||||
},
|
||||
ginkgo.Entry("a request the service refuses",
|
||||
merrors.BadRequest("search", "empty query provided"),
|
||||
http.StatusBadRequest, "invalidRequest", "empty query provided"),
|
||||
ginkgo.Entry("a timeout", merrors.Timeout("search", "deadline exceeded"),
|
||||
http.StatusServiceUnavailable, "serviceNotAvailable", "deadline exceeded"),
|
||||
ginkgo.Entry("a failing engine", merrors.InternalServerError("search", "engine down"),
|
||||
http.StatusInternalServerError, "generalException", "engine down"),
|
||||
ginkgo.Entry("an error that is no service error", errors.New("connection refused"),
|
||||
http.StatusInternalServerError, "generalException", "connection refused"),
|
||||
)
|
||||
})
|
||||
@@ -111,6 +111,8 @@ type Service interface { //nolint:interfacebloat
|
||||
GetTags(w http.ResponseWriter, r *http.Request)
|
||||
AssignTags(w http.ResponseWriter, r *http.Request)
|
||||
UnassignTags(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
SearchQuery(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// NewService returns a service implementation for Service.
|
||||
@@ -289,6 +291,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
|
||||
r.Get("/", svc.GetRoleDefinitions)
|
||||
r.Get("/{roleID}", svc.GetRoleDefinition)
|
||||
})
|
||||
r.Post("/search/query", svc.SearchQuery)
|
||||
})
|
||||
r.Route("/v1.0", func(r chi.Router) {
|
||||
r.Route("/extensions/org.libregraph", func(r chi.Router) {
|
||||
|
||||
@@ -42,7 +42,7 @@ func (g Graph) GetSharedByMe(w http.ResponseWriter, r *http.Request) {
|
||||
expandThumbnails := strings.Contains(expand, "thumbnails")
|
||||
if expandThumbnails {
|
||||
for k, item := range driveItems {
|
||||
setShareThumbnails(&item, item.GetId(), g.config.Commons.OpenCloudURL)
|
||||
setDriveItemThumbnailsByID(&item, item.GetId(), g.config.Commons.OpenCloudURL)
|
||||
driveItems[k] = item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (g Graph) listSharedWithMe(ctx context.Context, expandThumbnails bool) ([]l
|
||||
|
||||
if expandThumbnails {
|
||||
for k, item := range driveItems {
|
||||
setShareThumbnails(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL)
|
||||
setDriveItemThumbnailsByID(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL)
|
||||
driveItems[k] = item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,8 @@ func previewThumbnail(base string, box int32) *libregraph.Thumbnail {
|
||||
return &libregraph.Thumbnail{Url: &url}
|
||||
}
|
||||
|
||||
// setShareThumbnails works off the driveItem, the share listings have no resource
|
||||
// info. The id comes separately, a received share carries it on its remote item.
|
||||
func setShareThumbnails(item *libregraph.DriveItem, itemID, baseURL string) {
|
||||
// for callers that have no CS3 resource info: the share listings and the search results
|
||||
func setDriveItemThumbnailsByID(item *libregraph.DriveItem, itemID, baseURL string) {
|
||||
mimeType := item.GetFile().MimeType
|
||||
if itemID == "" || mimeType == nil || !thumbnail.IsMimeTypeSupported(*mimeType) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/conversions"
|
||||
)
|
||||
|
||||
// FromProto builds a *T (a libregraph facet) from the equally shaped proto
|
||||
// facet message. The bridge runs over proto3 JSON, which renders int64 as
|
||||
// strings, so leaves that fail the typed set are re-parsed from the string.
|
||||
// Fail-soft like the deserializers: nil for nil input or an empty message.
|
||||
func FromProto[T any, M any](m *M) *T {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
t := reflect.TypeFor[T]()
|
||||
fields, err := conversions.To[map[string]any](m)
|
||||
if err != nil || len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := reflect.New(t)
|
||||
if !fillStruct(out.Elem(), fields, "", setValueLenient) {
|
||||
return nil
|
||||
}
|
||||
return out.Interface().(*T)
|
||||
}
|
||||
|
||||
func setValueLenient(v reflect.Value, raw any) error {
|
||||
err := setValue(v, raw)
|
||||
if err != nil {
|
||||
if s, ok := raw.(string); ok {
|
||||
return setValueFromString(v, s)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/opencloud/pkg/conversions"
|
||||
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
)
|
||||
|
||||
var _ = Describe("FromProto", func() {
|
||||
It("maps an audio facet including proto3-JSON int64 strings", func() {
|
||||
in := &searchmsg.Audio{
|
||||
Artist: conversions.ToPointer("Saxon"),
|
||||
Album: conversions.ToPointer("Wheels of Steel"),
|
||||
Bitrate: conversions.ToPointer(int64(320)),
|
||||
Year: conversions.ToPointer(int32(1980)),
|
||||
}
|
||||
out := FromProto[libregraph.Audio](in)
|
||||
Expect(out).ToNot(BeNil())
|
||||
Expect(out.GetArtist()).To(Equal("Saxon"))
|
||||
Expect(out.GetAlbum()).To(Equal("Wheels of Steel"))
|
||||
Expect(out.GetBitrate()).To(Equal(int64(320)))
|
||||
Expect(out.GetYear()).To(Equal(int32(1980)))
|
||||
})
|
||||
|
||||
It("maps a timestamp to time.Time and float32 to float64", func() {
|
||||
taken := timestamppb.New(mustTime("2023-07-21T10:11:12Z"))
|
||||
in := &searchmsg.Photo{
|
||||
CameraMake: conversions.ToPointer("Nikon"),
|
||||
FNumber: conversions.ToPointer(float32(2.8)),
|
||||
TakenDateTime: taken,
|
||||
}
|
||||
out := FromProto[libregraph.Photo](in)
|
||||
Expect(out).ToNot(BeNil())
|
||||
Expect(out.GetCameraMake()).To(Equal("Nikon"))
|
||||
Expect(out.GetFNumber()).To(BeNumerically("~", 2.8, 0.0001))
|
||||
Expect(out.GetTakenDateTime()).To(Equal(mustTime("2023-07-21T10:11:12Z")))
|
||||
})
|
||||
|
||||
It("returns nil for nil input and for an empty message", func() {
|
||||
Expect(FromProto[libregraph.Audio]((*searchmsg.Audio)(nil))).To(BeNil())
|
||||
Expect(FromProto[libregraph.Audio](&searchmsg.Audio{})).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
func mustTime(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return t
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/metrics"
|
||||
@@ -491,6 +492,9 @@ func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest,
|
||||
isMountpoint := isShared && match.GetEntity().GetRef().GetPath() == "."
|
||||
isDir := match.GetEntity().GetMimeType() == "httpd/unix-directory"
|
||||
match.Entity.Permissions = convertToWebDAVPermissions(isShared, isMountpoint, isDir, permissions)
|
||||
// allowedValues is the same effective permission set the WebDAV report's
|
||||
// oc:permissions string projects, in libregraph action notation.
|
||||
match.Entity.PermissionsActionsAllowedValues = unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissions)
|
||||
|
||||
if req.Ref != nil && searchPathPrefix == "/"+match.Entity.Name {
|
||||
continue
|
||||
|
||||
Reference in new issue
Block a user