mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-17 08:10:38 -04:00
feat(graph): POST /search/query
MS-Graph-style search query endpoint: hits from all accessible spaces, with from/size paging, remote items for hits from shared spaces, thumbnails on $expand, and the effective permission actions on every hit. The search proto gains the aggregation types of the graph API and the endpoint passes aggregations through to the search service, which does not evaluate them yet.
This commit is contained in:
14 files changed
+1876
-170
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 (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -167,6 +167,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v0AggregationOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "Required. The indexed field to aggregate on (for terms/range\naggregations) or to reduce (for metric aggregations)."
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Optional. Maximum number of buckets to return for a terms aggregation.\nIgnored for range and metric aggregations."
|
||||
},
|
||||
"bucketDefinition": {
|
||||
"$ref": "#/definitions/v0BucketDefinition",
|
||||
"description": "Optional. Controls bucket selection, ordering and filtering.\nIgnored for metric aggregations."
|
||||
},
|
||||
"subAggregations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v0AggregationOption"
|
||||
},
|
||||
"description": "Optional. Nested aggregations computed within each bucket of this\naggregation. On bleve, sub-aggregations are emulated by walking the\nmatched result set; on OpenSearch they translate to native composite\naggregations."
|
||||
},
|
||||
"metricKind": {
|
||||
"$ref": "#/definitions/v0MetricKind",
|
||||
"description": "Optional. When set, this aggregation is a scalar metric over `field`\nrather than a bucket aggregation; the corresponding AggregationResult\ncarries `value` instead of `buckets`."
|
||||
}
|
||||
}
|
||||
},
|
||||
"v0AggregationResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string"
|
||||
},
|
||||
"buckets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v0Bucket"
|
||||
}
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Scalar value for metric aggregations (metric_kind set on the\ncorresponding AggregationOption). Unset / zero for terms/range\naggregations."
|
||||
},
|
||||
"metricKind": {
|
||||
"$ref": "#/definitions/v0MetricKind",
|
||||
"description": "Echoes the metric_kind of the corresponding AggregationOption. Lets\nthe cross-space merge layer pick the right reducer."
|
||||
},
|
||||
"sum": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Accumulators used exclusively for AVG during cross-space merges.\nA backend computes (sum, count) per bucket so the service layer can\nmerge them additively and emit `value = sum/count` only at the\noutermost collapse. Other metric kinds leave these unset."
|
||||
},
|
||||
"count": {
|
||||
"type": "string",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v0Audio": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -227,6 +288,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v0Bucket": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"count": {
|
||||
"type": "string",
|
||||
"format": "int64"
|
||||
},
|
||||
"aggregationFilterToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"subAggregations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v0AggregationResult"
|
||||
},
|
||||
"description": "Nested aggregation results, one entry per sub_aggregation requested\non the parent AggregationOption."
|
||||
}
|
||||
}
|
||||
},
|
||||
"v0BucketDefinition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sortBy": {
|
||||
"type": "string",
|
||||
"description": "One of \"count\", \"keyAsString\", \"keyAsNumber\"."
|
||||
},
|
||||
"isDescending": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"ranges": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v0BucketRange"
|
||||
},
|
||||
"description": "Optional. When non-empty, the aggregation is computed over these numeric\nor date ranges instead of as a terms aggregation."
|
||||
}
|
||||
}
|
||||
},
|
||||
"v0BucketRange": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string",
|
||||
"description": "At least one of `from` or `to` must be set. Both are string-encoded;\nnumeric bounds are decimal strings, dates use RFC3339."
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v0Entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -307,6 +425,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -422,6 +547,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v0MetricKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"METRIC_KIND_UNSPECIFIED",
|
||||
"METRIC_KIND_SUM",
|
||||
"METRIC_KIND_MIN",
|
||||
"METRIC_KIND_MAX",
|
||||
"METRIC_KIND_AVG"
|
||||
],
|
||||
"default": "METRIC_KIND_UNSPECIFIED"
|
||||
},
|
||||
"v0MotionPhoto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -520,6 +656,20 @@
|
||||
},
|
||||
"ref": {
|
||||
"$ref": "#/definitions/v0Reference"
|
||||
},
|
||||
"aggregations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v0AggregationOption"
|
||||
},
|
||||
"description": "Optional. Per-space aggregations (facets) to compute alongside the matches."
|
||||
},
|
||||
"aggregationFilters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Optional. Decoded aggregation filters as KQL fragments; the engine parses\neach, forces exact/case-sensitive matching, and ANDs them with `query`."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -539,6 +689,13 @@
|
||||
"totalMatches": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"aggregations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v0AggregationResult"
|
||||
},
|
||||
"description": "Per-space aggregation results. The service layer merges these across\nspaces before returning them to the caller."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -559,6 +716,20 @@
|
||||
},
|
||||
"ref": {
|
||||
"$ref": "#/definitions/v0Reference"
|
||||
},
|
||||
"aggregations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v0AggregationOption"
|
||||
},
|
||||
"description": "Optional. Aggregations (facets) to compute alongside the matches."
|
||||
},
|
||||
"aggregationFilters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Optional. Decoded aggregation filters, one per selected bucket, as KQL\nfragments (e.g. `audio.artist:\"Pink Floyd\"`). Combined with `query` via AND\nand matched case-sensitively/exactly. Passed through from the graph layer."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -578,6 +749,13 @@
|
||||
"totalMatches": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"aggregations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v0AggregationResult"
|
||||
},
|
||||
"description": "Aggregation results, one entry per requested aggregation."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -72,6 +72,14 @@ message SearchRequest {
|
||||
|
||||
string query = 3;
|
||||
opencloud.messages.search.v0.Reference ref = 4 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. Aggregations (facets) to compute alongside the matches.
|
||||
repeated AggregationOption aggregations = 5 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. Decoded aggregation filters, one per selected bucket, as KQL
|
||||
// fragments (e.g. `audio.artist:"Pink Floyd"`). Combined with `query` via AND
|
||||
// and matched case-sensitively/exactly. Passed through from the graph layer.
|
||||
repeated string aggregation_filters = 6 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
message SearchResponse {
|
||||
@@ -81,6 +89,9 @@ message SearchResponse {
|
||||
// more results in the list
|
||||
string next_page_token = 2;
|
||||
int32 total_matches = 3;
|
||||
|
||||
// Aggregation results, one entry per requested aggregation.
|
||||
repeated AggregationResult aggregations = 4;
|
||||
}
|
||||
|
||||
message SearchIndexRequest {
|
||||
@@ -93,6 +104,13 @@ message SearchIndexRequest {
|
||||
|
||||
string query = 3;
|
||||
opencloud.messages.search.v0.Reference ref = 4 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. Per-space aggregations (facets) to compute alongside the matches.
|
||||
repeated AggregationOption aggregations = 5 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. Decoded aggregation filters as KQL fragments; the engine parses
|
||||
// each, forces exact/case-sensitive matching, and ANDs them with `query`.
|
||||
repeated string aggregation_filters = 6 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
message SearchIndexResponse {
|
||||
@@ -102,6 +120,83 @@ message SearchIndexResponse {
|
||||
// more results in the list
|
||||
string next_page_token = 2;
|
||||
int32 total_matches = 3;
|
||||
|
||||
// Per-space aggregation results. The service layer merges these across
|
||||
// spaces before returning them to the caller.
|
||||
repeated AggregationResult aggregations = 4;
|
||||
}
|
||||
|
||||
message AggregationOption {
|
||||
// Required. The indexed field to aggregate on (for terms/range
|
||||
// aggregations) or to reduce (for metric aggregations).
|
||||
string field = 1;
|
||||
// Optional. Maximum number of buckets to return for a terms aggregation.
|
||||
// Ignored for range and metric aggregations.
|
||||
int32 size = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
// Optional. Controls bucket selection, ordering and filtering.
|
||||
// Ignored for metric aggregations.
|
||||
BucketDefinition bucket_definition = 3 [(google.api.field_behavior) = OPTIONAL];
|
||||
// Optional. Nested aggregations computed within each bucket of this
|
||||
// aggregation. On bleve, sub-aggregations are emulated by walking the
|
||||
// matched result set; on OpenSearch they translate to native composite
|
||||
// aggregations.
|
||||
repeated AggregationOption sub_aggregations = 4 [(google.api.field_behavior) = OPTIONAL];
|
||||
// Optional. When set, this aggregation is a scalar metric over `field`
|
||||
// rather than a bucket aggregation; the corresponding AggregationResult
|
||||
// carries `value` instead of `buckets`.
|
||||
MetricKind metric_kind = 5 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
enum MetricKind {
|
||||
METRIC_KIND_UNSPECIFIED = 0;
|
||||
METRIC_KIND_SUM = 1;
|
||||
METRIC_KIND_MIN = 2;
|
||||
METRIC_KIND_MAX = 3;
|
||||
METRIC_KIND_AVG = 4;
|
||||
}
|
||||
|
||||
message BucketDefinition {
|
||||
// One of "count", "keyAsString", "keyAsNumber".
|
||||
string sort_by = 1;
|
||||
bool is_descending = 2;
|
||||
int32 minimum_count = 3;
|
||||
// Optional. When non-empty, the aggregation is computed over these numeric
|
||||
// or date ranges instead of as a terms aggregation.
|
||||
repeated BucketRange ranges = 4;
|
||||
}
|
||||
|
||||
message BucketRange {
|
||||
// At least one of `from` or `to` must be set. Both are string-encoded;
|
||||
// numeric bounds are decimal strings, dates use RFC3339.
|
||||
string from = 1;
|
||||
string to = 2;
|
||||
}
|
||||
|
||||
message AggregationResult {
|
||||
string field = 1;
|
||||
repeated Bucket buckets = 2;
|
||||
// Scalar value for metric aggregations (metric_kind set on the
|
||||
// corresponding AggregationOption). Unset / zero for terms/range
|
||||
// aggregations.
|
||||
double value = 3;
|
||||
// Echoes the metric_kind of the corresponding AggregationOption. Lets
|
||||
// the cross-space merge layer pick the right reducer.
|
||||
MetricKind metric_kind = 4;
|
||||
// Accumulators used exclusively for AVG during cross-space merges.
|
||||
// A backend computes (sum, count) per bucket so the service layer can
|
||||
// merge them additively and emit `value = sum/count` only at the
|
||||
// outermost collapse. Other metric kinds leave these unset.
|
||||
double sum = 5;
|
||||
int64 count = 6;
|
||||
}
|
||||
|
||||
message Bucket {
|
||||
string key = 1;
|
||||
int64 count = 2;
|
||||
string aggregation_filter_token = 3;
|
||||
// Nested aggregation results, one entry per sub_aggregation requested
|
||||
// on the parent AggregationOption.
|
||||
repeated AggregationResult sub_aggregations = 4;
|
||||
}
|
||||
|
||||
message IndexSpaceRequest {
|
||||
|
||||
@@ -500,6 +500,18 @@ func cs3TimestampToTime(t *types.Timestamp) time.Time {
|
||||
return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos()))
|
||||
}
|
||||
|
||||
// webURLForID builds an item's private link, {publicBaseURL}/f/{id}, which the
|
||||
// web client reads as privateLink. Used by the drive item listing and the
|
||||
// search hits so the field is identical in both.
|
||||
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 +521,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,495 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
"go-micro.dev/v4/metadata"
|
||||
|
||||
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/errorcode"
|
||||
)
|
||||
|
||||
// SearchQuery runs the search requests and returns results grouped by request
|
||||
// (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
|
||||
}
|
||||
|
||||
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 _, sr := range req.Requests {
|
||||
sresp, err := g.runSingleSearch(ctx, sr, 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 (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest, expandThumbnails bool) (libregraph.SearchResponse, error) {
|
||||
from, size := clampPagination(sr.From, sr.Size)
|
||||
|
||||
// The gRPC layer has no from field: request from+size matches and slice
|
||||
// client-side. int64 avoids int32 overflow.
|
||||
pageSize := int32(int64(from) + int64(size))
|
||||
if size == 0 {
|
||||
pageSize = 0
|
||||
}
|
||||
|
||||
rsp, err := g.searchService.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: sr.Query.QueryString,
|
||||
PageSize: pageSize,
|
||||
Aggregations: libregraphAggregationsToSearch(sr.Aggregations),
|
||||
})
|
||||
if err != nil {
|
||||
return libregraph.SearchResponse{}, err
|
||||
}
|
||||
|
||||
// the current user id decides the @libre.graph.me.following (favorite) flag,
|
||||
// mirroring the WebDAV report's oc:favorite (favorited by the caller).
|
||||
uid := ""
|
||||
if u, ok := revaCtx.ContextGetUser(ctx); ok {
|
||||
uid = u.GetId().GetOpaqueId()
|
||||
}
|
||||
|
||||
hits := make([]libregraph.SearchHit, 0)
|
||||
if size > 0 {
|
||||
start := min(int(from), len(rsp.Matches))
|
||||
end := min(start+int(size), len(rsp.Matches))
|
||||
for i := start; i < end; i++ {
|
||||
hit := matchToSearchHit(rsp.Matches[i], int32(i+1), uid)
|
||||
hit.Resource.WebUrl = webURLForID(g.publicBaseURL, hit.Resource.GetId())
|
||||
if expandThumbnails {
|
||||
setDriveItemThumbnailsByID(hit.Resource, hit.Resource.GetId(), g.config.Commons.OpenCloudURL)
|
||||
}
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
}
|
||||
|
||||
total := int64(rsp.TotalMatches)
|
||||
more := int64(from+size) < total
|
||||
return libregraph.SearchResponse{
|
||||
SearchTerms: []string{sr.Query.QueryString},
|
||||
HitsContainers: []libregraph.SearchHitsContainer{{
|
||||
Hits: hits,
|
||||
Total: &total,
|
||||
MoreResultsAvailable: &more,
|
||||
Aggregations: searchAggregationsToLibregraph(rsp.Aggregations, sr.Aggregations),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maxPageSize mirrors the openapi spec's upper bound on SearchRequest.size.
|
||||
const maxPageSize = 500
|
||||
|
||||
// clampPagination normalises the from/size JSON pointers into safe non-negative
|
||||
// int32s; openapi-generator does not enforce the spec's [0,500]/[0,inf) bounds.
|
||||
func clampPagination(fromP, sizeP *int32) (int32, int32) {
|
||||
from := int32(0)
|
||||
if fromP != nil && *fromP > 0 {
|
||||
from = *fromP
|
||||
}
|
||||
size := int32(25)
|
||||
if sizeP != nil {
|
||||
size = *sizeP
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
if size > maxPageSize {
|
||||
size = maxPageSize
|
||||
}
|
||||
}
|
||||
// from+size is sent as a single int32 PageSize; guard against overflow.
|
||||
const maxInt32 = int32(1<<31 - 1)
|
||||
if int64(from)+int64(size) > int64(maxInt32) {
|
||||
if from > maxInt32-size {
|
||||
from = maxInt32 - size
|
||||
}
|
||||
}
|
||||
return from, size
|
||||
}
|
||||
|
||||
func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*searchsvc.AggregationOption {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*searchsvc.AggregationOption, 0, len(in))
|
||||
for _, a := range in {
|
||||
agg := &searchsvc.AggregationOption{Field: a.Field}
|
||||
if a.Size != nil {
|
||||
agg.Size = *a.Size
|
||||
}
|
||||
if a.BucketDefinition != nil {
|
||||
agg.BucketDefinition = libregraphBucketDefinitionToSearch(*a.BucketDefinition)
|
||||
}
|
||||
if len(a.LibreGraphSubAggregations) > 0 {
|
||||
agg.SubAggregations = libregraphAggregationsToSearch(a.LibreGraphSubAggregations)
|
||||
}
|
||||
if a.LibreGraphMetricDefinition != nil {
|
||||
agg.MetricKind = metricKindFromLibregraph(a.LibreGraphMetricDefinition.Kind)
|
||||
}
|
||||
out = append(out, agg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// metricKindFromLibregraph maps the OpenAPI string enum to the proto enum;
|
||||
// unknown values degrade to UNSPECIFIED.
|
||||
func metricKindFromLibregraph(kind string) searchsvc.MetricKind {
|
||||
switch kind {
|
||||
case "sum":
|
||||
return searchsvc.MetricKind_METRIC_KIND_SUM
|
||||
case "min":
|
||||
return searchsvc.MetricKind_METRIC_KIND_MIN
|
||||
case "max":
|
||||
return searchsvc.MetricKind_METRIC_KIND_MAX
|
||||
case "avg":
|
||||
return searchsvc.MetricKind_METRIC_KIND_AVG
|
||||
}
|
||||
return searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED
|
||||
}
|
||||
|
||||
// metricKindToLibregraph is the inverse of metricKindFromLibregraph.
|
||||
func metricKindToLibregraph(kind searchsvc.MetricKind) *string {
|
||||
var s string
|
||||
switch kind {
|
||||
case searchsvc.MetricKind_METRIC_KIND_SUM:
|
||||
s = "sum"
|
||||
case searchsvc.MetricKind_METRIC_KIND_MIN:
|
||||
s = "min"
|
||||
case searchsvc.MetricKind_METRIC_KIND_MAX:
|
||||
s = "max"
|
||||
case searchsvc.MetricKind_METRIC_KIND_AVG:
|
||||
s = "avg"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func libregraphBucketDefinitionToSearch(in libregraph.BucketDefinition) *searchsvc.BucketDefinition {
|
||||
bd := &searchsvc.BucketDefinition{SortBy: in.SortBy}
|
||||
if in.IsDescending != nil {
|
||||
bd.IsDescending = *in.IsDescending
|
||||
}
|
||||
if in.MinimumCount != nil {
|
||||
bd.MinimumCount = *in.MinimumCount
|
||||
}
|
||||
if len(in.Ranges) > 0 {
|
||||
bd.Ranges = make([]*searchsvc.BucketRange, 0, len(in.Ranges))
|
||||
for _, r := range in.Ranges {
|
||||
br := &searchsvc.BucketRange{}
|
||||
if r.From != nil {
|
||||
br.From = *r.From
|
||||
}
|
||||
if r.To != nil {
|
||||
br.To = *r.To
|
||||
}
|
||||
bd.Ranges = append(bd.Ranges, br)
|
||||
}
|
||||
}
|
||||
return bd
|
||||
}
|
||||
|
||||
func searchAggregationsToLibregraph(in []*searchsvc.AggregationResult, defs []libregraph.AggregationOption) []libregraph.SearchAggregation {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
defsByField := make(map[string]libregraph.AggregationOption, len(defs))
|
||||
for _, d := range defs {
|
||||
defsByField[d.Field] = d
|
||||
}
|
||||
out := make([]libregraph.SearchAggregation, 0, len(in))
|
||||
for _, a := range in {
|
||||
field := a.GetField()
|
||||
def := defsByField[field]
|
||||
// Metric result: a scalar, no buckets. For AVG the backend
|
||||
// transported (sum, count); collapse to the average here.
|
||||
if kind := a.GetMetricKind(); kind != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED {
|
||||
value := a.GetValue()
|
||||
if kind == searchsvc.MetricKind_METRIC_KIND_AVG && a.GetCount() > 0 {
|
||||
value = a.GetSum() / float64(a.GetCount())
|
||||
}
|
||||
out = append(out, libregraph.SearchAggregation{
|
||||
Field: &field,
|
||||
LibreGraphMetric: &libregraph.SearchMetric{Kind: metricKindToLibregraph(kind), Value: &value},
|
||||
})
|
||||
continue
|
||||
}
|
||||
buckets := make([]libregraph.SearchBucket, 0, len(a.GetBuckets()))
|
||||
for _, b := range a.GetBuckets() {
|
||||
key := b.GetKey()
|
||||
count := b.GetCount()
|
||||
lb := libregraph.SearchBucket{
|
||||
Key: &key,
|
||||
Count: &count,
|
||||
}
|
||||
if subs := b.GetSubAggregations(); len(subs) > 0 {
|
||||
lb.LibreGraphSubAggregations = searchAggregationsToLibregraph(subs, def.LibreGraphSubAggregations)
|
||||
}
|
||||
buckets = append(buckets, lb)
|
||||
}
|
||||
out = append(out, libregraph.SearchAggregation{
|
||||
Field: &field,
|
||||
Buckets: buckets,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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)
|
||||
default:
|
||||
g.logger.Error().Err(err).Msg("search service call failed")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func matchToSearchHit(m *searchmsg.Match, rank int32, uid string) libregraph.SearchHit {
|
||||
hit := libregraph.SearchHit{
|
||||
HitId: libregraph.PtrString(searchEntityHitID(m.GetEntity())),
|
||||
Rank: &rank,
|
||||
}
|
||||
if h := m.GetEntity().GetHighlights(); h != "" {
|
||||
hit.Summary = libregraph.PtrString(h)
|
||||
}
|
||||
di := searchEntityToDriveItem(m.GetEntity(), uid)
|
||||
hit.Resource = di
|
||||
return hit
|
||||
}
|
||||
|
||||
func searchEntityHitID(e *searchmsg.Entity) string {
|
||||
return storagespace.FormatResourceID(&storageprovider.ResourceId{
|
||||
StorageId: e.GetId().GetStorageId(),
|
||||
SpaceId: e.GetId().GetSpaceId(),
|
||||
OpaqueId: e.GetId().GetOpaqueId(),
|
||||
})
|
||||
}
|
||||
|
||||
func searchEntityToDriveItem(e *searchmsg.Entity, uid string) *libregraph.DriveItem {
|
||||
size := int64(e.GetSize())
|
||||
di := &libregraph.DriveItem{
|
||||
Id: libregraph.PtrString(searchEntityHitID(e)),
|
||||
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(storagespace.FormatResourceID(&storageprovider.ResourceId{
|
||||
StorageId: p.GetStorageId(),
|
||||
SpaceId: p.GetSpaceId(),
|
||||
OpaqueId: p.GetOpaqueId(),
|
||||
}))
|
||||
if refPath := e.GetRef().GetPath(); refPath != "" {
|
||||
ref.SetName(path.Base(path.Dir(refPath)))
|
||||
ref.SetPath(path.Dir(refPath))
|
||||
}
|
||||
di.ParentReference = ref
|
||||
}
|
||||
di.RemoteItem = searchEntityToRemoteItem(e)
|
||||
di.Audio = searchAudioToLibregraph(e.GetAudio())
|
||||
di.Image = searchImageToLibregraph(e.GetImage())
|
||||
di.Photo = searchPhotoToLibregraph(e.GetPhoto())
|
||||
di.Location = searchLocationToLibregraph(e.GetLocation())
|
||||
di.Video = searchVideoToLibregraph(e.GetVideo())
|
||||
di.LibreGraphMotionPhoto = searchMotionPhotoToLibregraph(e.GetMotionPhoto())
|
||||
di.LibreGraphLivePhoto = searchLivePhotoToLibregraph(e.GetLivePhoto())
|
||||
if tags := e.GetTags(); len(tags) > 0 {
|
||||
di.LibreGraphTags = tags
|
||||
}
|
||||
if av := e.GetPermissionsActionsAllowedValues(); len(av) > 0 {
|
||||
di.LibreGraphPermissionsActionsAllowedValues = av
|
||||
}
|
||||
// @libre.graph.me.following mirrors the WebDAV report's oc:favorite: the
|
||||
// report emits it only when the current user has favorited the item, so set
|
||||
// it to true only in that case and leave it unset otherwise.
|
||||
if uid != "" && slices.Contains(e.GetFavorites(), uid) {
|
||||
di.LibreGraphMeFollowing = libregraph.PtrBool(true)
|
||||
}
|
||||
return di
|
||||
}
|
||||
|
||||
// searchEntityToRemoteItem describes a hit that lives in a space shared with the
|
||||
// caller: the item 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(storagespace.FormatResourceID(&storageprovider.ResourceId{
|
||||
StorageId: id.GetStorageId(),
|
||||
SpaceId: id.GetSpaceId(),
|
||||
OpaqueId: id.GetOpaqueId(),
|
||||
}))
|
||||
|
||||
if root := e.GetShareRootName(); root != "" {
|
||||
item.SetPath(root)
|
||||
item.SetName(path.Base(root))
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func searchAudioToLibregraph(a *searchmsg.Audio) *libregraph.Audio {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
out := &libregraph.Audio{
|
||||
Album: a.Album,
|
||||
AlbumArtist: a.AlbumArtist,
|
||||
Artist: a.Artist,
|
||||
Bitrate: a.Bitrate,
|
||||
Composers: a.Composers,
|
||||
Copyright: a.Copyright,
|
||||
Disc: a.Disc,
|
||||
DiscCount: a.DiscCount,
|
||||
Duration: a.Duration,
|
||||
Genre: a.Genre,
|
||||
HasDrm: a.HasDrm,
|
||||
IsVariableBitrate: a.IsVariableBitrate,
|
||||
Title: a.Title,
|
||||
Track: a.Track,
|
||||
TrackCount: a.TrackCount,
|
||||
Year: a.Year,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func searchImageToLibregraph(i *searchmsg.Image) *libregraph.Image {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
return &libregraph.Image{Width: i.Width, Height: i.Height}
|
||||
}
|
||||
|
||||
func searchPhotoToLibregraph(p *searchmsg.Photo) *libregraph.Photo {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
out := &libregraph.Photo{
|
||||
CameraMake: p.CameraMake,
|
||||
CameraModel: p.CameraModel,
|
||||
ExposureDenominator: f32ToF64(p.ExposureDenominator),
|
||||
ExposureNumerator: f32ToF64(p.ExposureNumerator),
|
||||
FNumber: f32ToF64(p.FNumber),
|
||||
FocalLength: f32ToF64(p.FocalLength),
|
||||
Iso: p.Iso,
|
||||
Orientation: p.Orientation,
|
||||
}
|
||||
if p.TakenDateTime != nil {
|
||||
t := time.Unix(p.TakenDateTime.GetSeconds(), int64(p.TakenDateTime.GetNanos())).UTC()
|
||||
out.TakenDateTime = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func searchLocationToLibregraph(l *searchmsg.GeoCoordinates) *libregraph.GeoCoordinates {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return &libregraph.GeoCoordinates{
|
||||
Altitude: l.Altitude,
|
||||
Latitude: l.Latitude,
|
||||
Longitude: l.Longitude,
|
||||
}
|
||||
}
|
||||
|
||||
func searchVideoToLibregraph(v *searchmsg.Video) *libregraph.Video {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return &libregraph.Video{
|
||||
AudioBitsPerSample: v.AudioBitsPerSample,
|
||||
AudioChannels: v.AudioChannels,
|
||||
AudioFormat: v.AudioFormat,
|
||||
AudioSamplesPerSecond: v.AudioSamplesPerSecond,
|
||||
Bitrate: v.Bitrate,
|
||||
Duration: v.Duration,
|
||||
FourCC: v.FourCC,
|
||||
FrameRate: v.FrameRate,
|
||||
Height: v.Height,
|
||||
Width: v.Width,
|
||||
}
|
||||
}
|
||||
|
||||
func searchMotionPhotoToLibregraph(m *searchmsg.MotionPhoto) *libregraph.MotionPhoto {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &libregraph.MotionPhoto{
|
||||
Version: m.Version,
|
||||
PresentationTimestampUs: m.PresentationTimestampUs,
|
||||
VideoSize: m.VideoSize,
|
||||
}
|
||||
}
|
||||
|
||||
func searchLivePhotoToLibregraph(l *searchmsg.LivePhoto) *libregraph.LivePhoto {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return &libregraph.LivePhoto{
|
||||
ContentId: l.GetContentId(),
|
||||
StillImageTimeUs: l.StillImageTimeUs,
|
||||
Auto: l.Auto,
|
||||
VitalityScore: l.VitalityScore,
|
||||
VitalityScoringVersion: l.VitalityScoringVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func f32ToF64(v *float32) *float64 {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
f := float64(*v)
|
||||
return &f
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
"go-micro.dev/v4/client"
|
||||
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
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 int32Ptr(v int32) *int32 { return &v }
|
||||
|
||||
// searchHitRemoteItem decodes the remoteItem of the first hit, nil when absent.
|
||||
func searchHitRemoteItem(rr *httptest.ResponseRecorder) *struct {
|
||||
Id *string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Path *string `json:"path"`
|
||||
} {
|
||||
var decoded struct {
|
||||
Value []struct {
|
||||
HitsContainers []struct {
|
||||
Hits []struct {
|
||||
Resource struct {
|
||||
RemoteItem *struct {
|
||||
Id *string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Path *string `json:"path"`
|
||||
} `json:"remoteItem"`
|
||||
} `json:"resource"`
|
||||
} `json:"hits"`
|
||||
} `json:"hitsContainers"`
|
||||
} `json:"value"`
|
||||
}
|
||||
Expect(json.Unmarshal(rr.Body.Bytes(), &decoded)).To(Succeed())
|
||||
Expect(decoded.Value).To(HaveLen(1))
|
||||
Expect(decoded.Value[0].HitsContainers).To(HaveLen(1))
|
||||
Expect(decoded.Value[0].HitsContainers[0].Hits).To(HaveLen(1))
|
||||
|
||||
return decoded.Value[0].HitsContainers[0].Hits[0].Resource.RemoteItem
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("SearchQuery", func() {
|
||||
ginkgo.It("forwards aggregations to the search service and groups results by request", func() {
|
||||
var captured *searchsvc.SearchRequest
|
||||
g := graphWithSearch(stubSearchService{
|
||||
search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
|
||||
captured = req
|
||||
return &searchsvc.SearchResponse{
|
||||
TotalMatches: 10,
|
||||
Aggregations: []*searchsvc.AggregationResult{{
|
||||
Field: "audio.artist",
|
||||
Buckets: []*searchsvc.Bucket{
|
||||
{Key: "Pink Floyd", Count: 7},
|
||||
{Key: "Motörhead", Count: 3},
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
rr := postSearchQuery(g, `{
|
||||
"requests": [{
|
||||
"entityTypes": ["driveItem"],
|
||||
"query": {"queryString": "mediatype:audio"},
|
||||
"size": 0,
|
||||
"aggregations": [{
|
||||
"field": "audio.artist",
|
||||
"size": 5,
|
||||
"bucketDefinition": {"sortBy": "count", "isDescending": true}
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
|
||||
Expect(captured).ToNot(BeNil())
|
||||
Expect(captured.Aggregations).To(HaveLen(1))
|
||||
Expect(captured.Aggregations[0].Field).To(Equal("audio.artist"))
|
||||
|
||||
var decoded struct {
|
||||
Value []struct {
|
||||
HitsContainers []struct {
|
||||
Aggregations []struct {
|
||||
Field *string `json:"field"`
|
||||
Buckets []struct {
|
||||
Key *string `json:"key"`
|
||||
Count *int64 `json:"count"`
|
||||
} `json:"buckets"`
|
||||
} `json:"aggregations"`
|
||||
} `json:"hitsContainers"`
|
||||
} `json:"value"`
|
||||
}
|
||||
Expect(json.Unmarshal(rr.Body.Bytes(), &decoded)).To(Succeed())
|
||||
Expect(decoded.Value).To(HaveLen(1))
|
||||
Expect(decoded.Value[0].HitsContainers).To(HaveLen(1))
|
||||
|
||||
aggs := decoded.Value[0].HitsContainers[0].Aggregations
|
||||
Expect(aggs).To(HaveLen(1))
|
||||
Expect(aggs[0].Field).To(HaveValue(Equal("audio.artist")))
|
||||
Expect(aggs[0].Buckets).To(HaveLen(2))
|
||||
})
|
||||
|
||||
ginkgo.It("describes a hit from a shared space as a remote item", func() {
|
||||
g := graphWithSearch(stubSearchService{
|
||||
search: func(_ *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
|
||||
return &searchsvc.SearchResponse{
|
||||
TotalMatches: 1,
|
||||
Matches: []*searchmsg.Match{{
|
||||
Entity: &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"},
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
rr := postSearchQuery(g, `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "contract"}}]}`)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
remote := searchHitRemoteItem(rr)
|
||||
Expect(remote).ToNot(BeNil())
|
||||
Expect(remote.Id).To(HaveValue(Equal("4$5!6")))
|
||||
Expect(remote.Path).To(HaveValue(Equal("/Project X")))
|
||||
Expect(remote.Name).To(HaveValue(Equal("Project X")), "the mountpoint name the caller sees")
|
||||
})
|
||||
|
||||
ginkgo.It("leaves the remote item out for hits from the caller's own spaces", func() {
|
||||
g := graphWithSearch(stubSearchService{
|
||||
search: func(_ *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
|
||||
return &searchsvc.SearchResponse{
|
||||
TotalMatches: 1,
|
||||
Matches: []*searchmsg.Match{{
|
||||
Entity: &searchmsg.Entity{
|
||||
Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
|
||||
Name: "notes.txt",
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
rr := postSearchQuery(g, `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}}]}`)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
Expect(searchHitRemoteItem(rr)).To(BeNil())
|
||||
})
|
||||
|
||||
ginkgo.DescribeTable("clampPagination keeps from/size within valid bounds",
|
||||
func(from, size *int32, wantFrom, wantSize int32) {
|
||||
gotFrom, gotSize := clampPagination(from, size)
|
||||
Expect(gotFrom).To(Equal(wantFrom))
|
||||
Expect(gotSize).To(Equal(wantSize))
|
||||
},
|
||||
ginkgo.Entry("defaults", nil, nil, int32(0), int32(25)),
|
||||
ginkgo.Entry("zero size", int32Ptr(5), int32Ptr(0), int32(5), int32(0)),
|
||||
ginkgo.Entry("negative from clamps to zero", int32Ptr(-10), int32Ptr(5), int32(0), int32(5)),
|
||||
ginkgo.Entry("negative size clamps to zero", int32Ptr(10), int32Ptr(-1), int32(10), int32(0)),
|
||||
ginkgo.Entry("oversized size clamps to max", int32Ptr(0), int32Ptr(1000), int32(0), int32(500)),
|
||||
ginkgo.Entry("from+size overflow collapses", int32Ptr(1<<31-1), int32Ptr(500), int32(1<<31-1-500), int32(500)),
|
||||
)
|
||||
})
|
||||
@@ -110,6 +110,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.
|
||||
@@ -288,6 +290,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,10 @@ 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) {
|
||||
// setDriveItemThumbnailsByID works off the driveItem itself (mime type from the
|
||||
// item, id passed separately), 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
|
||||
|
||||
@@ -30,6 +30,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"
|
||||
@@ -410,7 +411,8 @@ func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest,
|
||||
}
|
||||
|
||||
searchRequest := &searchsvc.SearchIndexRequest{
|
||||
Query: req.Query,
|
||||
Query: req.Query,
|
||||
Aggregations: req.GetAggregations(),
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: searchRootID,
|
||||
Path: searchPathPrefix,
|
||||
@@ -446,6 +448,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
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"go-micro.dev/v4/metadata"
|
||||
"golang.org/x/sync/errgroup"
|
||||
grpcmetadata "google.golang.org/grpc/metadata"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
@@ -94,14 +95,15 @@ func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *s
|
||||
}
|
||||
ctx = revactx.ContextSetUser(ctx, u)
|
||||
|
||||
key := cacheKey(in.Query, in.PageSize, in.Ref, u)
|
||||
key := cacheKey(in.Query, in.PageSize, in.Ref, u, in.Aggregations)
|
||||
res, ok := s.FromCache(key)
|
||||
if !ok {
|
||||
var err error
|
||||
res, err = s.searcher.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: in.Query,
|
||||
PageSize: in.PageSize,
|
||||
Ref: in.Ref,
|
||||
Query: in.Query,
|
||||
PageSize: in.PageSize,
|
||||
Ref: in.Ref,
|
||||
Aggregations: in.Aggregations,
|
||||
})
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
@@ -118,6 +120,7 @@ func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *s
|
||||
out.Matches = res.Matches
|
||||
out.TotalMatches = res.TotalMatches
|
||||
out.NextPageToken = res.NextPageToken
|
||||
out.Aggregations = res.Aggregations
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -242,6 +245,18 @@ func (s Service) Cache(key string, res *searchsvc.SearchResponse) {
|
||||
_ = s.cache.Set(key, res)
|
||||
}
|
||||
|
||||
func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User) string {
|
||||
return fmt.Sprintf("%s|%d|%s$%s!%s/%s|%s", query, pagesize, ref.GetResourceId().GetStorageId(), ref.GetResourceId().GetSpaceId(), ref.GetResourceId().GetOpaqueId(), ref.GetPath(), user.GetId().GetOpaqueId())
|
||||
// cacheKey builds the cache identity for a search. Every result-affecting field
|
||||
// must be in the key, including aggregations (serialised via deterministic proto
|
||||
// marshalling). If the aggregation proto ever gains a map field, determinism
|
||||
// requires all writers to set Deterministic=true.
|
||||
func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User, aggs []*searchsvc.AggregationOption) string {
|
||||
aggPart := ""
|
||||
if len(aggs) > 0 {
|
||||
b, _ := proto.MarshalOptions{Deterministic: true}.Marshal(&searchsvc.SearchRequest{Aggregations: aggs})
|
||||
aggPart = string(b)
|
||||
}
|
||||
return fmt.Sprintf("%s|%d|%s$%s!%s/%s|%s|%s",
|
||||
query, pagesize,
|
||||
ref.GetResourceId().GetStorageId(), ref.GetResourceId().GetSpaceId(), ref.GetResourceId().GetOpaqueId(),
|
||||
ref.GetPath(), user.GetId().GetOpaqueId(), aggPart)
|
||||
}
|
||||
Reference in new issue
Block a user