From ade670d7502af8b1b646d333782b67d602382415 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 12 Aug 2026 16:14:47 +0200 Subject: [PATCH 1/7] feat(search): add sortProperties plumbing (libregraph model, proto order_by) (cherry picked from commit 1b748fb757e2dde5b3cd1beaa543be4266342590) --- .../opencloud/services/search/v0/search.pb.go | 625 +++++++++++------- .../services/search/v0/search.swagger.json | 27 + .../opencloud/services/search/v0/search.proto | 20 + .../model_search_request.go | 2 + .../libre-graph-api-go/model_sort_property.go | 196 ++++++ 5 files changed, 614 insertions(+), 256 deletions(-) create mode 100644 vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go diff --git a/protogen/gen/opencloud/services/search/v0/search.pb.go b/protogen/gen/opencloud/services/search/v0/search.pb.go index 02e4f718b6..53630ca26f 100644 --- a/protogen/gen/opencloud/services/search/v0/search.pb.go +++ b/protogen/gen/opencloud/services/search/v0/search.pb.go @@ -98,6 +98,11 @@ type SearchRequest struct { // fragments (e.g. `audio.artist:"Pink Floyd"`). Combined with `query` via AND // and matched case-sensitively/exactly. Passed through from the graph layer. AggregationFilters []string `protobuf:"bytes,6,rep,name=aggregation_filters,json=aggregationFilters,proto3" json:"aggregation_filters,omitempty"` + // Optional. Fields to sort the matches by, in order of precedence. When + // empty, matches are sorted by relevance score. Only a subset of the + // indexed fields is sortable; the graph service validates this before + // forwarding the request. + OrderBy []*SortProperty `protobuf:"bytes,7,rep,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` } func (x *SearchRequest) Reset() { @@ -174,6 +179,13 @@ func (x *SearchRequest) GetAggregationFilters() []string { return nil } +func (x *SearchRequest) GetOrderBy() []*SortProperty { + if x != nil { + return x.OrderBy + } + return nil +} + type SearchResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -265,6 +277,10 @@ type SearchIndexRequest struct { // Optional. Decoded aggregation filters as KQL fragments; the engine parses // each, forces exact/case-sensitive matching, and ANDs them with `query`. AggregationFilters []string `protobuf:"bytes,6,rep,name=aggregation_filters,json=aggregationFilters,proto3" json:"aggregation_filters,omitempty"` + // Optional. Fields to sort the matches by, in order of precedence. When + // empty, matches are sorted by relevance score. Each backend translates + // this to its native sort (bleve: SortBy, OpenSearch: sort clause). + OrderBy []*SortProperty `protobuf:"bytes,7,rep,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` } func (x *SearchIndexRequest) Reset() { @@ -341,6 +357,13 @@ func (x *SearchIndexRequest) GetAggregationFilters() []string { return nil } +func (x *SearchIndexRequest) GetOrderBy() []*SortProperty { + if x != nil { + return x.OrderBy + } + return nil +} + type SearchIndexResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -508,6 +531,67 @@ func (x *AggregationOption) GetMetricKind() MetricKind { return MetricKind_METRIC_KIND_UNSPECIFIED } +type SortProperty struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Required. The field to sort on, in graph notation. Supported: "name", + // "size", "lastModifiedDateTime", "photo.takenDateTime". A field is only + // sortable if it is indexed as a sortable type in both backends AND + // present on the Match entity (the service layer needs the sort key to + // merge per-space result streams). + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Optional. Sort in descending order. Defaults to ascending. + IsDescending bool `protobuf:"varint,2,opt,name=is_descending,json=isDescending,proto3" json:"is_descending,omitempty"` +} + +func (x *SortProperty) Reset() { + *x = SortProperty{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SortProperty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SortProperty) ProtoMessage() {} + +func (x *SortProperty) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SortProperty.ProtoReflect.Descriptor instead. +func (*SortProperty) Descriptor() ([]byte, []int) { + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{5} +} + +func (x *SortProperty) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SortProperty) GetIsDescending() bool { + if x != nil { + return x.IsDescending + } + return false +} + type BucketDefinition struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -525,7 +609,7 @@ type BucketDefinition struct { func (x *BucketDefinition) Reset() { *x = BucketDefinition{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[5] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -538,7 +622,7 @@ func (x *BucketDefinition) String() string { func (*BucketDefinition) ProtoMessage() {} func (x *BucketDefinition) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[5] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -551,7 +635,7 @@ func (x *BucketDefinition) ProtoReflect() protoreflect.Message { // Deprecated: Use BucketDefinition.ProtoReflect.Descriptor instead. func (*BucketDefinition) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{5} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{6} } func (x *BucketDefinition) GetSortBy() string { @@ -596,7 +680,7 @@ type BucketRange struct { func (x *BucketRange) Reset() { *x = BucketRange{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[6] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -609,7 +693,7 @@ func (x *BucketRange) String() string { func (*BucketRange) ProtoMessage() {} func (x *BucketRange) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[6] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -622,7 +706,7 @@ func (x *BucketRange) ProtoReflect() protoreflect.Message { // Deprecated: Use BucketRange.ProtoReflect.Descriptor instead. func (*BucketRange) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{6} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{7} } func (x *BucketRange) GetFrom() string { @@ -664,7 +748,7 @@ type AggregationResult struct { func (x *AggregationResult) Reset() { *x = AggregationResult{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[7] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -677,7 +761,7 @@ func (x *AggregationResult) String() string { func (*AggregationResult) ProtoMessage() {} func (x *AggregationResult) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[7] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -690,7 +774,7 @@ func (x *AggregationResult) ProtoReflect() protoreflect.Message { // Deprecated: Use AggregationResult.ProtoReflect.Descriptor instead. func (*AggregationResult) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{7} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{8} } func (x *AggregationResult) GetField() string { @@ -751,7 +835,7 @@ type Bucket struct { func (x *Bucket) Reset() { *x = Bucket{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[8] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -764,7 +848,7 @@ func (x *Bucket) String() string { func (*Bucket) ProtoMessage() {} func (x *Bucket) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[8] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -777,7 +861,7 @@ func (x *Bucket) ProtoReflect() protoreflect.Message { // Deprecated: Use Bucket.ProtoReflect.Descriptor instead. func (*Bucket) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{8} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{9} } func (x *Bucket) GetKey() string { @@ -822,7 +906,7 @@ type IndexSpaceRequest struct { func (x *IndexSpaceRequest) Reset() { *x = IndexSpaceRequest{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[9] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -835,7 +919,7 @@ func (x *IndexSpaceRequest) String() string { func (*IndexSpaceRequest) ProtoMessage() {} func (x *IndexSpaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[9] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -848,7 +932,7 @@ func (x *IndexSpaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use IndexSpaceRequest.ProtoReflect.Descriptor instead. func (*IndexSpaceRequest) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{9} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{10} } func (x *IndexSpaceRequest) GetSpaceId() string { @@ -899,7 +983,7 @@ type IndexSpaceResponse struct { func (x *IndexSpaceResponse) Reset() { *x = IndexSpaceResponse{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[10] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -912,7 +996,7 @@ func (x *IndexSpaceResponse) String() string { func (*IndexSpaceResponse) ProtoMessage() {} func (x *IndexSpaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[10] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -925,7 +1009,7 @@ func (x *IndexSpaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use IndexSpaceResponse.ProtoReflect.Descriptor instead. func (*IndexSpaceResponse) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{10} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{11} } func (x *IndexSpaceResponse) GetSpaceId() string { @@ -984,7 +1068,7 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc0, 0x02, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x8d, 0x03, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0a, 0x70, 0x61, @@ -1004,207 +1088,221 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x73, 0x12, 0x35, 0x0a, 0x13, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x12, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x73, 0x22, 0xf1, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, 0x6d, - 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 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, 0x4d, 0x61, 0x74, 0x63, - 0x68, 0x52, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, - 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x74, 0x63, - 0x68, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, - 0x4d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, - 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, - 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, - 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0c, - 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xc5, 0x02, 0x0a, - 0x12, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x08, 0x70, 0x61, - 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, - 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x71, - 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, - 0x79, 0x12, 0x3f, 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x04, 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, 0x6e, 0x63, 0x65, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x03, 0x72, - 0x65, 0x66, 0x12, 0x59, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x73, 0x12, 0x4b, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, + 0x72, 0x5f, 0x62, 0x79, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x6f, 0x72, 0x74, 0x50, 0x72, + 0x6f, 0x70, 0x65, 0x72, 0x74, 0x79, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x07, 0x6f, 0x72, + 0x64, 0x65, 0x72, 0x42, 0x79, 0x22, 0xf1, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, 0x6d, 0x61, 0x74, 0x63, + 0x68, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 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, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x52, 0x07, + 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, + 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, + 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4d, 0x61, 0x74, + 0x63, 0x68, 0x65, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0c, 0x61, 0x67, 0x67, + 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x92, 0x03, 0x0a, 0x12, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x21, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, + 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x09, 0x70, + 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, + 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x3f, + 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x04, 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, 0x6e, 0x63, 0x65, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, + 0x59, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, + 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0c, 0x61, 0x67, + 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x13, 0x61, 0x67, + 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, + 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x12, 0x61, + 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, + 0x73, 0x12, 0x4b, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x79, 0x18, 0x07, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, + 0x76, 0x30, 0x2e, 0x53, 0x6f, 0x72, 0x74, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x79, 0x42, + 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x79, 0x22, 0xf6, + 0x01, 0x0a, 0x13, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 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, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x52, 0x07, 0x6d, 0x61, + 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, + 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, + 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, + 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4d, 0x61, 0x74, 0x63, 0x68, + 0x65, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, - 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x35, 0x0a, - 0x13, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, 0x69, 0x6c, - 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, - 0x52, 0x12, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, - 0x74, 0x65, 0x72, 0x73, 0x22, 0xf6, 0x01, 0x0a, 0x13, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, - 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 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, 0x4d, 0x61, 0x74, - 0x63, 0x68, 0x52, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, - 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x74, - 0x63, 0x68, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, - 0x6c, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, - 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, - 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, - 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xd9, 0x02, - 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x18, 0x0a, 0x04, 0x73, 0x69, 0x7a, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x04, 0x73, - 0x69, 0x7a, 0x65, 0x12, 0x61, 0x0a, 0x11, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x64, 0x65, - 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, - 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, - 0xe2, 0x41, 0x01, 0x01, 0x52, 0x10, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, - 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x60, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x5f, 0x61, 0x67, - 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, - 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x41, 0x67, 0x67, 0x72, - 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x4f, 0x0a, 0x0b, 0x6d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, - 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0a, 0x6d, - 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x22, 0xb8, 0x01, 0x0a, 0x10, 0x42, 0x75, - 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x17, - 0x0a, 0x07, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x64, 0x65, - 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, - 0x69, 0x73, 0x44, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x23, 0x0a, 0x0d, - 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x0c, 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x43, 0x6f, 0x75, 0x6e, - 0x74, 0x12, 0x41, 0x0a, 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, - 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x72, 0x61, - 0x6e, 0x67, 0x65, 0x73, 0x22, 0x31, 0x0a, 0x0b, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, - 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x6f, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x02, 0x74, 0x6f, 0x22, 0xf2, 0x01, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, - 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, + 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xd9, 0x02, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, + 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, - 0x65, 0x6c, 0x64, 0x12, 0x3e, 0x0a, 0x07, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x65, 0x6c, 0x64, 0x12, 0x18, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x61, 0x0a, + 0x11, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, + 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x10, + 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x60, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x5f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, + 0x01, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x12, 0x4f, 0x0a, 0x0b, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x5f, 0x6b, 0x69, 0x6e, + 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, + 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, + 0x64, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, + 0x69, 0x6e, 0x64, 0x22, 0x47, 0x0a, 0x0c, 0x53, 0x6f, 0x72, 0x74, 0x50, 0x72, 0x6f, 0x70, 0x65, + 0x72, 0x74, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x64, 0x65, + 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, + 0x69, 0x73, 0x44, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x22, 0xb8, 0x01, 0x0a, + 0x10, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x73, + 0x5f, 0x64, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0c, 0x69, 0x73, 0x44, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, + 0x23, 0x0a, 0x0d, 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x41, 0x0a, 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x04, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x07, 0x62, 0x75, 0x63, 0x6b, - 0x65, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x01, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x49, 0x0a, 0x0b, 0x6d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, - 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, - 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x75, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x01, 0x52, 0x03, 0x73, 0x75, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xc6, 0x01, 0x0a, - 0x06, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, - 0x38, 0x0a, 0x18, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, - 0x69, 0x6c, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x16, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x69, - 0x6c, 0x74, 0x65, 0x72, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x5a, 0x0a, 0x10, 0x73, 0x75, 0x62, - 0x5f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, - 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x11, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, - 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, - 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x52, 0x65, 0x69, - 0x6e, 0x64, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, - 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, - 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x22, 0xd1, 0x01, 0x0a, - 0x12, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x40, - 0x0a, 0x0e, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x52, 0x0d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x5f, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, - 0x64, 0x53, 0x70, 0x61, 0x63, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, - 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, - 0x6f, 0x74, 0x61, 0x6c, 0x53, 0x70, 0x61, 0x63, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x2a, 0x7d, 0x0a, 0x0a, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, - 0x0a, 0x17, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x55, 0x4e, - 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x4d, - 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x53, 0x55, 0x4d, 0x10, 0x01, - 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, - 0x4d, 0x49, 0x4e, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, - 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x4d, 0x41, 0x58, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, - 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x41, 0x56, 0x47, 0x10, 0x04, 0x32, - 0xb3, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x12, 0x85, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x2b, 0x2e, + 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, + 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x22, 0x31, 0x0a, 0x0b, 0x42, 0x75, 0x63, 0x6b, 0x65, + 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x6f, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x74, 0x6f, 0x22, 0xf2, 0x01, 0x0a, 0x11, 0x41, + 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x3e, 0x0a, 0x07, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, + 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x07, 0x62, + 0x75, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x49, 0x0a, 0x0b, + 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x0a, 0x6d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x75, 0x6d, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, 0x73, 0x75, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, + 0xc6, 0x01, 0x0a, 0x06, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x12, 0x38, 0x0a, 0x18, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x5a, 0x0a, 0x10, + 0x73, 0x75, 0x62, 0x5f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, + 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x41, 0x67, 0x67, 0x72, + 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x11, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, + 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x69, 0x6e, + 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x66, 0x6f, 0x72, 0x63, 0x65, + 0x52, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, + 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, + 0x01, 0x01, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x22, + 0xd1, 0x01, 0x0a, 0x12, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, + 0x64, 0x12, 0x40, 0x0a, 0x0e, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x44, 0x75, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x5f, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x69, 0x6e, 0x64, + 0x65, 0x78, 0x65, 0x64, 0x53, 0x70, 0x61, 0x63, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, + 0x74, 0x61, 0x6c, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x53, 0x70, 0x61, 0x63, 0x65, 0x73, 0x12, 0x14, 0x0a, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x2a, 0x7d, 0x0a, 0x0a, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, + 0x64, 0x12, 0x1b, 0x0a, 0x17, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x13, + 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x53, 0x55, + 0x4d, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, + 0x4e, 0x44, 0x5f, 0x4d, 0x49, 0x4e, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, + 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x4d, 0x41, 0x58, 0x10, 0x03, 0x12, 0x13, 0x0a, + 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x41, 0x56, 0x47, + 0x10, 0x04, 0x32, 0xb3, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x85, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x12, 0x2b, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, + 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x6f, 0x70, 0x65, - 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, - 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x98, 0x01, 0x0a, 0x0a, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x12, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, - 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, - 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x6f, 0x70, 0x65, - 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, - 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x82, 0xd3, - 0xe4, 0x93, 0x02, 0x1f, 0x3a, 0x01, 0x2a, 0x22, 0x1a, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, - 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2d, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x30, 0x01, 0x32, 0xa7, 0x01, 0x0a, 0x0d, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x95, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x12, 0x30, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, - 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x20, 0x3a, - 0x01, 0x2a, 0x22, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x42, - 0xf2, 0x02, 0x5a, 0x4a, 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, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x92, 0x41, - 0xa2, 0x02, 0x12, 0xb7, 0x01, 0x0a, 0x10, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, - 0x20, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x22, 0x51, 0x0a, 0x0e, 0x4f, 0x70, 0x65, 0x6e, 0x43, - 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x47, 0x6d, 0x62, 0x48, 0x12, 0x29, 0x68, 0x74, 0x74, 0x70, 0x73, - 0x3a, 0x2f, 0x2f, 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, 0x1a, 0x14, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x40, 0x6f, 0x70, - 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2a, 0x49, 0x0a, 0x0a, 0x41, 0x70, - 0x61, 0x63, 0x68, 0x65, 0x2d, 0x32, 0x2e, 0x30, 0x12, 0x3b, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, - 0x2f, 0x2f, 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, 0x62, 0x6c, 0x6f, 0x62, 0x2f, 0x6d, 0x61, 0x69, 0x6e, 0x2f, 0x4c, 0x49, - 0x43, 0x45, 0x4e, 0x53, 0x45, 0x32, 0x05, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x2a, 0x02, 0x01, 0x02, - 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, - 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, - 0x6a, 0x73, 0x6f, 0x6e, 0x72, 0x3e, 0x0a, 0x10, 0x44, 0x65, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, - 0x72, 0x20, 0x4d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x12, 0x2a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, - 0x2f, 0x2f, 0x64, 0x6f, 0x63, 0x73, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x65, 0x75, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, 0x82, 0xd3, 0xe4, + 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x98, 0x01, + 0x0a, 0x0a, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x12, 0x2f, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, + 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, + 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x25, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1f, 0x3a, 0x01, 0x2a, 0x22, 0x1a, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, + 0x2d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x30, 0x01, 0x32, 0xa7, 0x01, 0x0a, 0x0d, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x95, 0x01, 0x0a, 0x06, 0x53, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x30, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, + 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x82, 0xd3, 0xe4, 0x93, + 0x02, 0x20, 0x3a, 0x01, 0x2a, 0x22, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x42, 0xf2, 0x02, 0x5a, 0x4a, 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, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, + 0x30, 0x92, 0x41, 0xa2, 0x02, 0x12, 0xb7, 0x01, 0x0a, 0x10, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, + 0x6f, 0x75, 0x64, 0x20, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x22, 0x51, 0x0a, 0x0e, 0x4f, 0x70, + 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x47, 0x6d, 0x62, 0x48, 0x12, 0x29, 0x68, 0x74, + 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 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, 0x1a, 0x14, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, + 0x40, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2a, 0x49, 0x0a, + 0x0a, 0x41, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2d, 0x32, 0x2e, 0x30, 0x12, 0x3b, 0x68, 0x74, 0x74, + 0x70, 0x73, 0x3a, 0x2f, 0x2f, 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, 0x62, 0x6c, 0x6f, 0x62, 0x2f, 0x6d, 0x61, 0x69, 0x6e, + 0x2f, 0x4c, 0x49, 0x43, 0x45, 0x4e, 0x53, 0x45, 0x32, 0x05, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x2a, + 0x02, 0x01, 0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x72, 0x3e, 0x0a, 0x10, 0x44, 0x65, 0x76, 0x65, 0x6c, + 0x6f, 0x70, 0x65, 0x72, 0x20, 0x4d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x12, 0x2a, 0x68, 0x74, 0x74, + 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x64, 0x6f, 0x63, 0x73, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, + 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1220,7 +1318,7 @@ func file_opencloud_services_search_v0_search_proto_rawDescGZIP() []byte { } var file_opencloud_services_search_v0_search_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_opencloud_services_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_opencloud_services_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_opencloud_services_search_v0_search_proto_goTypes = []interface{}{ (MetricKind)(0), // 0: opencloud.services.search.v0.MetricKind (*SearchRequest)(nil), // 1: opencloud.services.search.v0.SearchRequest @@ -1228,44 +1326,47 @@ var file_opencloud_services_search_v0_search_proto_goTypes = []interface{}{ (*SearchIndexRequest)(nil), // 3: opencloud.services.search.v0.SearchIndexRequest (*SearchIndexResponse)(nil), // 4: opencloud.services.search.v0.SearchIndexResponse (*AggregationOption)(nil), // 5: opencloud.services.search.v0.AggregationOption - (*BucketDefinition)(nil), // 6: opencloud.services.search.v0.BucketDefinition - (*BucketRange)(nil), // 7: opencloud.services.search.v0.BucketRange - (*AggregationResult)(nil), // 8: opencloud.services.search.v0.AggregationResult - (*Bucket)(nil), // 9: opencloud.services.search.v0.Bucket - (*IndexSpaceRequest)(nil), // 10: opencloud.services.search.v0.IndexSpaceRequest - (*IndexSpaceResponse)(nil), // 11: opencloud.services.search.v0.IndexSpaceResponse - (*v0.Reference)(nil), // 12: opencloud.messages.search.v0.Reference - (*v0.Match)(nil), // 13: opencloud.messages.search.v0.Match - (*durationpb.Duration)(nil), // 14: google.protobuf.Duration + (*SortProperty)(nil), // 6: opencloud.services.search.v0.SortProperty + (*BucketDefinition)(nil), // 7: opencloud.services.search.v0.BucketDefinition + (*BucketRange)(nil), // 8: opencloud.services.search.v0.BucketRange + (*AggregationResult)(nil), // 9: opencloud.services.search.v0.AggregationResult + (*Bucket)(nil), // 10: opencloud.services.search.v0.Bucket + (*IndexSpaceRequest)(nil), // 11: opencloud.services.search.v0.IndexSpaceRequest + (*IndexSpaceResponse)(nil), // 12: opencloud.services.search.v0.IndexSpaceResponse + (*v0.Reference)(nil), // 13: opencloud.messages.search.v0.Reference + (*v0.Match)(nil), // 14: opencloud.messages.search.v0.Match + (*durationpb.Duration)(nil), // 15: google.protobuf.Duration } var file_opencloud_services_search_v0_search_proto_depIdxs = []int32{ - 12, // 0: opencloud.services.search.v0.SearchRequest.ref:type_name -> opencloud.messages.search.v0.Reference + 13, // 0: opencloud.services.search.v0.SearchRequest.ref:type_name -> opencloud.messages.search.v0.Reference 5, // 1: opencloud.services.search.v0.SearchRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption - 13, // 2: opencloud.services.search.v0.SearchResponse.matches:type_name -> opencloud.messages.search.v0.Match - 8, // 3: opencloud.services.search.v0.SearchResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult - 12, // 4: opencloud.services.search.v0.SearchIndexRequest.ref:type_name -> opencloud.messages.search.v0.Reference - 5, // 5: opencloud.services.search.v0.SearchIndexRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption - 13, // 6: opencloud.services.search.v0.SearchIndexResponse.matches:type_name -> opencloud.messages.search.v0.Match - 8, // 7: opencloud.services.search.v0.SearchIndexResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult - 6, // 8: opencloud.services.search.v0.AggregationOption.bucket_definition:type_name -> opencloud.services.search.v0.BucketDefinition - 5, // 9: opencloud.services.search.v0.AggregationOption.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationOption - 0, // 10: opencloud.services.search.v0.AggregationOption.metric_kind:type_name -> opencloud.services.search.v0.MetricKind - 7, // 11: opencloud.services.search.v0.BucketDefinition.ranges:type_name -> opencloud.services.search.v0.BucketRange - 9, // 12: opencloud.services.search.v0.AggregationResult.buckets:type_name -> opencloud.services.search.v0.Bucket - 0, // 13: opencloud.services.search.v0.AggregationResult.metric_kind:type_name -> opencloud.services.search.v0.MetricKind - 8, // 14: opencloud.services.search.v0.Bucket.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationResult - 14, // 15: opencloud.services.search.v0.IndexSpaceResponse.space_duration:type_name -> google.protobuf.Duration - 1, // 16: opencloud.services.search.v0.SearchProvider.Search:input_type -> opencloud.services.search.v0.SearchRequest - 10, // 17: opencloud.services.search.v0.SearchProvider.IndexSpace:input_type -> opencloud.services.search.v0.IndexSpaceRequest - 3, // 18: opencloud.services.search.v0.IndexProvider.Search:input_type -> opencloud.services.search.v0.SearchIndexRequest - 2, // 19: opencloud.services.search.v0.SearchProvider.Search:output_type -> opencloud.services.search.v0.SearchResponse - 11, // 20: opencloud.services.search.v0.SearchProvider.IndexSpace:output_type -> opencloud.services.search.v0.IndexSpaceResponse - 4, // 21: opencloud.services.search.v0.IndexProvider.Search:output_type -> opencloud.services.search.v0.SearchIndexResponse - 19, // [19:22] is the sub-list for method output_type - 16, // [16:19] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 6, // 2: opencloud.services.search.v0.SearchRequest.order_by:type_name -> opencloud.services.search.v0.SortProperty + 14, // 3: opencloud.services.search.v0.SearchResponse.matches:type_name -> opencloud.messages.search.v0.Match + 9, // 4: opencloud.services.search.v0.SearchResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 13, // 5: opencloud.services.search.v0.SearchIndexRequest.ref:type_name -> opencloud.messages.search.v0.Reference + 5, // 6: opencloud.services.search.v0.SearchIndexRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption + 6, // 7: opencloud.services.search.v0.SearchIndexRequest.order_by:type_name -> opencloud.services.search.v0.SortProperty + 14, // 8: opencloud.services.search.v0.SearchIndexResponse.matches:type_name -> opencloud.messages.search.v0.Match + 9, // 9: opencloud.services.search.v0.SearchIndexResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 7, // 10: opencloud.services.search.v0.AggregationOption.bucket_definition:type_name -> opencloud.services.search.v0.BucketDefinition + 5, // 11: opencloud.services.search.v0.AggregationOption.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationOption + 0, // 12: opencloud.services.search.v0.AggregationOption.metric_kind:type_name -> opencloud.services.search.v0.MetricKind + 8, // 13: opencloud.services.search.v0.BucketDefinition.ranges:type_name -> opencloud.services.search.v0.BucketRange + 10, // 14: opencloud.services.search.v0.AggregationResult.buckets:type_name -> opencloud.services.search.v0.Bucket + 0, // 15: opencloud.services.search.v0.AggregationResult.metric_kind:type_name -> opencloud.services.search.v0.MetricKind + 9, // 16: opencloud.services.search.v0.Bucket.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 15, // 17: opencloud.services.search.v0.IndexSpaceResponse.space_duration:type_name -> google.protobuf.Duration + 1, // 18: opencloud.services.search.v0.SearchProvider.Search:input_type -> opencloud.services.search.v0.SearchRequest + 11, // 19: opencloud.services.search.v0.SearchProvider.IndexSpace:input_type -> opencloud.services.search.v0.IndexSpaceRequest + 3, // 20: opencloud.services.search.v0.IndexProvider.Search:input_type -> opencloud.services.search.v0.SearchIndexRequest + 2, // 21: opencloud.services.search.v0.SearchProvider.Search:output_type -> opencloud.services.search.v0.SearchResponse + 12, // 22: opencloud.services.search.v0.SearchProvider.IndexSpace:output_type -> opencloud.services.search.v0.IndexSpaceResponse + 4, // 23: opencloud.services.search.v0.IndexProvider.Search:output_type -> opencloud.services.search.v0.SearchIndexResponse + 21, // [21:24] is the sub-list for method output_type + 18, // [18:21] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_opencloud_services_search_v0_search_proto_init() } @@ -1335,7 +1436,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BucketDefinition); i { + switch v := v.(*SortProperty); i { case 0: return &v.state case 1: @@ -1347,7 +1448,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BucketRange); i { + switch v := v.(*BucketDefinition); i { case 0: return &v.state case 1: @@ -1359,7 +1460,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AggregationResult); i { + switch v := v.(*BucketRange); i { case 0: return &v.state case 1: @@ -1371,7 +1472,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Bucket); i { + switch v := v.(*AggregationResult); i { case 0: return &v.state case 1: @@ -1383,7 +1484,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*IndexSpaceRequest); i { + switch v := v.(*Bucket); i { case 0: return &v.state case 1: @@ -1395,6 +1496,18 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IndexSpaceRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_services_search_v0_search_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*IndexSpaceResponse); i { case 0: return &v.state @@ -1413,7 +1526,7 @@ func file_opencloud_services_search_v0_search_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_opencloud_services_search_v0_search_proto_rawDesc, NumEnums: 1, - NumMessages: 11, + NumMessages: 12, NumExtensions: 0, NumServices: 2, }, diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index 54e0b20a07..f242ff3fb3 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -670,6 +670,13 @@ "type": "string" }, "description": "Optional. Decoded aggregation filters as KQL fragments; the engine parses\neach, forces exact/case-sensitive matching, and ANDs them with `query`." + }, + "orderBy": { + "type": "array", + "items": { + "$ref": "#/definitions/v0SortProperty" + }, + "description": "Optional. Fields to sort the matches by, in order of precedence. When\nempty, matches are sorted by relevance score. Each backend translates\nthis to its native sort (bleve: SortBy, OpenSearch: sort clause)." } } }, @@ -730,6 +737,13 @@ "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." + }, + "orderBy": { + "type": "array", + "items": { + "$ref": "#/definitions/v0SortProperty" + }, + "description": "Optional. Fields to sort the matches by, in order of precedence. When\nempty, matches are sorted by relevance score. Only a subset of the\nindexed fields is sortable; the graph service validates this before\nforwarding the request." } } }, @@ -759,6 +773,19 @@ } } }, + "v0SortProperty": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Required. The field to sort on, in graph notation. Supported: \"name\",\n\"size\", \"lastModifiedDateTime\", \"photo.takenDateTime\". A field is only\nsortable if it is indexed as a sortable type in both backends AND\npresent on the Match entity (the service layer needs the sort key to\nmerge per-space result streams)." + }, + "isDescending": { + "type": "boolean", + "description": "Optional. Sort in descending order. Defaults to ascending." + } + } + }, "v0Video": { "type": "object", "properties": { diff --git a/protogen/proto/opencloud/services/search/v0/search.proto b/protogen/proto/opencloud/services/search/v0/search.proto index 79e8a75f1e..e8bf9ee2e4 100644 --- a/protogen/proto/opencloud/services/search/v0/search.proto +++ b/protogen/proto/opencloud/services/search/v0/search.proto @@ -80,6 +80,11 @@ message SearchRequest { // 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]; + // Optional. Fields to sort the matches by, in order of precedence. When + // empty, matches are sorted by relevance score. Only a subset of the + // indexed fields is sortable; the graph service validates this before + // forwarding the request. + repeated SortProperty order_by = 7 [(google.api.field_behavior) = OPTIONAL]; } message SearchResponse { @@ -111,6 +116,10 @@ message SearchIndexRequest { // 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]; + // Optional. Fields to sort the matches by, in order of precedence. When + // empty, matches are sorted by relevance score. Each backend translates + // this to its native sort (bleve: SortBy, OpenSearch: sort clause). + repeated SortProperty order_by = 7 [(google.api.field_behavior) = OPTIONAL]; } message SearchIndexResponse { @@ -147,6 +156,17 @@ message AggregationOption { MetricKind metric_kind = 5 [(google.api.field_behavior) = OPTIONAL]; } +message SortProperty { + // Required. The field to sort on, in graph notation. Supported: "name", + // "size", "lastModifiedDateTime", "photo.takenDateTime". A field is only + // sortable if it is indexed as a sortable type in both backends AND + // present on the Match entity (the service layer needs the sort key to + // merge per-space result streams). + string name = 1; + // Optional. Sort in descending order. Defaults to ascending. + bool is_descending = 2; +} + enum MetricKind { METRIC_KIND_UNSPECIFIED = 0; METRIC_KIND_SUM = 1; diff --git a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_search_request.go b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_search_request.go index 9fdeca628a..46650451f2 100644 --- a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_search_request.go +++ b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_search_request.go @@ -32,6 +32,8 @@ type SearchRequest struct { Aggregations []AggregationOption `json:"aggregations,omitempty"` // Contains one or more filters to narrow search results to specific buckets of a prior aggregation. Build each filter from the response of a prior search that aggregated on the same field: take the `aggregationFilterToken` of the wanted `searchBucket` and combine it with the field as `{field}:{aggregationFilterToken}`, e.g. `audio.artist:\"ǂǂ50696e6b20466c6f7964\"` for a terms bucket or `audio.year:range(1980, 1990)` for a range bucket. Several buckets of the same field are combined with `{field}:or({aggregationFilterToken},{aggregationFilterToken})`. Whitespace after the commas of `range(...)` and `or(...)` is optional. Multiple filters can be provided as separate array items. This results in a logical AND between the filters. Filters that are not built from server-issued tokens are rejected with `invalidRequest`. AggregationFilters []string `json:"aggregationFilters,omitempty"` + // Contains the ordered collection of fields to sort the results on. If absent, the results are sorted by relevance. See `SortProperty.Name` for the set of sortable fields. Optional. + SortProperties []SortProperty `json:"sortProperties,omitempty"` } type _SearchRequest SearchRequest diff --git a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go new file mode 100644 index 0000000000..9a463a95f1 --- /dev/null +++ b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go @@ -0,0 +1,196 @@ +/* +Libre Graph API + +Libre Graph is a free API for cloud collaboration inspired by the MS Graph API. + +API version: v1.0.8 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package libregraph + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the SortProperty type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &SortProperty{} + +// SortProperty Indicates the order to sort search results in. Follows the [MS Graph sortProperty](https://learn.microsoft.com/en-us/graph/api/resources/sortproperty) resource type. +type SortProperty struct { + // The name of the property to sort the search results by. The sortable fields are `name`, `size`, `lastModifiedDateTime` and `photo.takenDateTime`. Sorting by any other field is rejected with `invalidRequest`. Required. + Name string `json:"name"` + // Set to `true` to sort the results in descending order. Optional, defaults to `false` (ascending). + IsDescending *bool `json:"isDescending,omitempty"` +} + +type _SortProperty SortProperty + +// NewSortProperty instantiates a new SortProperty object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewSortProperty(name string) *SortProperty { + this := SortProperty{} + this.Name = name + var isDescending bool = false + this.IsDescending = &isDescending + return &this +} + +// NewSortPropertyWithDefaults instantiates a new SortProperty object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewSortPropertyWithDefaults() *SortProperty { + this := SortProperty{} + var isDescending bool = false + this.IsDescending = &isDescending + return &this +} + +// GetName returns the Name field value +func (o *SortProperty) GetName() string { + if o == nil { + var ret string + return ret + } + + return o.Name +} + +// GetNameOk returns a tuple with the Name field value +// and a boolean to check if the value has been set. +func (o *SortProperty) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Name, true +} + +// SetName sets field value +func (o *SortProperty) SetName(v string) { + o.Name = v +} + +// GetIsDescending returns the IsDescending field value if set, zero value otherwise. +func (o *SortProperty) GetIsDescending() bool { + if o == nil || IsNil(o.IsDescending) { + var ret bool + return ret + } + return *o.IsDescending +} + +// GetIsDescendingOk returns a tuple with the IsDescending field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SortProperty) GetIsDescendingOk() (*bool, bool) { + if o == nil || IsNil(o.IsDescending) { + return nil, false + } + return o.IsDescending, true +} + +// HasIsDescending returns a boolean if a field has been set. +func (o *SortProperty) HasIsDescending() bool { + if o != nil && !IsNil(o.IsDescending) { + return true + } + + return false +} + +// SetIsDescending gets a reference to the given bool and assigns it to the IsDescending field. +func (o *SortProperty) SetIsDescending(v bool) { + o.IsDescending = &v +} + +func (o SortProperty) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o SortProperty) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["name"] = o.Name + if !IsNil(o.IsDescending) { + toSerialize["isDescending"] = o.IsDescending + } + return toSerialize, nil +} + +func (o *SortProperty) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "name", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varSortProperty := _SortProperty{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + err = decoder.Decode(&varSortProperty) + + if err != nil { + return err + } + + *o = SortProperty(varSortProperty) + + return err +} + +type NullableSortProperty struct { + value *SortProperty + isSet bool +} + +func (v NullableSortProperty) Get() *SortProperty { + return v.value +} + +func (v *NullableSortProperty) Set(val *SortProperty) { + v.value = val + v.isSet = true +} + +func (v NullableSortProperty) IsSet() bool { + return v.isSet +} + +func (v *NullableSortProperty) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSortProperty(val *SortProperty) *NullableSortProperty { + return &NullableSortProperty{value: val, isSet: true} +} + +func (v *NullableSortProperty) UnmarshalJSON(src []byte) error { + return json.Unmarshal(src, &v.value) +} + +func (v NullableSortProperty) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} From fa5790b26755091affb26cc8c3f45d38c36ed96c Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 12 Aug 2026 16:21:14 +0200 Subject: [PATCH 2/7] feat(graph): accept sortProperties on search requests Validated against the sortable-field whitelist (name, size, lastModifiedDateTime, photo.takenDateTime) and forwarded to the search service as order_by. Also fixes the stub search service's IndexSpace signature (streaming response) so the suite builds again. (cherry picked from commit 9aa6a3492de3468da7a8480a3b9ef5748cee1f5d) (cherry picked from commit 35a6cad8ea6a7890bdc0bc98c7ac97bdcbf477cc) --- services/graph/pkg/service/v0/searchquery.go | 42 +++++++++++++++++ .../graph/pkg/service/v0/searchquery_test.go | 45 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/services/graph/pkg/service/v0/searchquery.go b/services/graph/pkg/service/v0/searchquery.go index 16e6c825b1..b524dc02b9 100644 --- a/services/graph/pkg/service/v0/searchquery.go +++ b/services/graph/pkg/service/v0/searchquery.go @@ -43,6 +43,10 @@ func (g Graph) SearchQuery(w http.ResponseWriter, r *http.Request) { errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) return } + if err := validateSortProperties(sr.SortProperties); err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } } th := r.Header.Get(revaCtx.TokenHeader) @@ -80,6 +84,7 @@ func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest, PageSize: pageSize, Aggregations: libregraphAggregationsToSearch(sr.Aggregations), AggregationFilters: sr.AggregationFilters, + OrderBy: libregraphSortToSearch(sr.SortProperties), }) if err != nil { return libregraph.SearchResponse{}, err @@ -170,6 +175,43 @@ func validateAggregations(aggs []libregraph.AggregationOption) error { return nil } +// sortableFields is the set of fields accepted in sortProperties (mirrored in +// the openapi spec). A field qualifies only if it is indexed as a sortable +// type in both search backends AND carried on the Match entity: the service +// layer re-sorts matches when merging the per-space result streams and needs +// the sort key on the match itself. +var sortableFields = map[string]struct{}{ + "name": {}, + "size": {}, + "lastModifiedDateTime": {}, + "photo.takenDateTime": {}, +} + +// validateSortProperties rejects sorting by fields outside sortableFields. +func validateSortProperties(sortProperties []libregraph.SortProperty) error { + for _, sp := range sortProperties { + if _, ok := sortableFields[sp.Name]; !ok { + return fmt.Errorf("field %q is not sortable; sortable fields: name, size, lastModifiedDateTime, photo.takenDateTime", sp.Name) + } + } + return nil +} + +func libregraphSortToSearch(in []libregraph.SortProperty) []*searchsvc.SortProperty { + if len(in) == 0 { + return nil + } + out := make([]*searchsvc.SortProperty, 0, len(in)) + for _, sp := range in { + p := &searchsvc.SortProperty{Name: sp.Name} + if sp.IsDescending != nil { + p.IsDescending = *sp.IsDescending + } + out = append(out, p) + } + return out +} + func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*searchsvc.AggregationOption { if len(in) == 0 { return nil diff --git a/services/graph/pkg/service/v0/searchquery_test.go b/services/graph/pkg/service/v0/searchquery_test.go index 2817f7188d..0db86ef2c0 100644 --- a/services/graph/pkg/service/v0/searchquery_test.go +++ b/services/graph/pkg/service/v0/searchquery_test.go @@ -198,6 +198,51 @@ var _ = ginkgo.Describe("SearchQuery", func() { ginkgo.Entry("from+size overflow collapses", int32Ptr(1<<31-1), int32Ptr(500), int32(1<<31-1-500), int32(500)), ) + ginkgo.It("forwards sortProperties to the search service as order_by", func() { + var captured *searchsvc.SearchRequest + g := graphWithSearch(stubSearchService{ + search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + captured = req + return &searchsvc.SearchResponse{}, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "mediatype:image"}, + "sortProperties": [ + {"name": "photo.takenDateTime", "isDescending": true}, + {"name": "name"} + ] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + Expect(captured).ToNot(BeNil()) + Expect(captured.OrderBy).To(HaveLen(2)) + Expect(captured.OrderBy[0].Name).To(Equal("photo.takenDateTime")) + Expect(captured.OrderBy[0].IsDescending).To(BeTrue()) + Expect(captured.OrderBy[1].Name).To(Equal("name")) + Expect(captured.OrderBy[1].IsDescending).To(BeFalse()) + }) + + ginkgo.It("rejects sorting by an unsupported field with 400", func() { + g := graphWithSearch(stubSearchService{ + search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + ginkgo.Fail("search service must not be called when validation fails") + return nil, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "mediatype:image"}, + "sortProperties": [{"name": "photo.iso"}] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String()) + Expect(rr.Body.String()).To(ContainSubstring("photo.iso")) + }) + ginkgo.It("rejects a terms aggregation on a numeric field with 400", func() { g := graphWithSearch(stubSearchService{ search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { From 4af2138fecd2069b4d42e97bf1371b354a6e8615 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 12 Aug 2026 16:29:55 +0200 Subject: [PATCH 3/7] feat(search): allow sorting by any scalar hit field Sortable is every field that is indexed as a scalar and carried on the match entity: name, size, lastModifiedDateTime, mimeType and the scalar facet fields (photo.*, audio.*, image.*, location.*, ...). Both sets are derived by reflection, so new facet fields become sortable automatically. Multivalued fields (tags), bare facets and internal index fields are rejected with invalidRequest at the graph layer. CompareMatches provides the merge comparator for the service layer. (cherry picked from commit bd32795194cf3945e8416ede496a147ce8d6cb5c) --- .../opencloud/services/search/v0/search.pb.go | 11 +- .../services/search/v0/search.swagger.json | 2 +- .../opencloud/services/search/v0/search.proto | 11 +- services/graph/pkg/service/v0/searchquery.go | 21 +- .../graph/pkg/service/v0/searchquery_test.go | 67 +++-- services/search/pkg/search/sort.go | 257 ++++++++++++++++++ services/search/pkg/search/sort_test.go | 125 +++++++++ .../libre-graph-api-go/model_sort_property.go | 2 +- 8 files changed, 452 insertions(+), 44 deletions(-) create mode 100644 services/search/pkg/search/sort.go create mode 100644 services/search/pkg/search/sort_test.go diff --git a/protogen/gen/opencloud/services/search/v0/search.pb.go b/protogen/gen/opencloud/services/search/v0/search.pb.go index 53630ca26f..492f88021c 100644 --- a/protogen/gen/opencloud/services/search/v0/search.pb.go +++ b/protogen/gen/opencloud/services/search/v0/search.pb.go @@ -536,11 +536,12 @@ type SortProperty struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Required. The field to sort on, in graph notation. Supported: "name", - // "size", "lastModifiedDateTime", "photo.takenDateTime". A field is only - // sortable if it is indexed as a sortable type in both backends AND - // present on the Match entity (the service layer needs the sort key to - // merge per-space result streams). + // Required. The field to sort on, in graph notation ("name", "size", + // "lastModifiedDateTime", "mimeType" or a scalar facet field such as + // "photo.takenDateTime" or "audio.artist"). A field is sortable when it is + // indexed as a scalar in both backends AND carried on the Match entity + // (the service layer needs the sort key to merge per-space result + // streams); see the search package's IsSortableField. Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Optional. Sort in descending order. Defaults to ascending. IsDescending bool `protobuf:"varint,2,opt,name=is_descending,json=isDescending,proto3" json:"is_descending,omitempty"` diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index f242ff3fb3..ef667bc2b1 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -778,7 +778,7 @@ "properties": { "name": { "type": "string", - "description": "Required. The field to sort on, in graph notation. Supported: \"name\",\n\"size\", \"lastModifiedDateTime\", \"photo.takenDateTime\". A field is only\nsortable if it is indexed as a sortable type in both backends AND\npresent on the Match entity (the service layer needs the sort key to\nmerge per-space result streams)." + "description": "Required. The field to sort on, in graph notation (\"name\", \"size\",\n\"lastModifiedDateTime\", \"mimeType\" or a scalar facet field such as\n\"photo.takenDateTime\" or \"audio.artist\"). A field is sortable when it is\nindexed as a scalar in both backends AND carried on the Match entity\n(the service layer needs the sort key to merge per-space result\nstreams); see the search package's IsSortableField." }, "isDescending": { "type": "boolean", diff --git a/protogen/proto/opencloud/services/search/v0/search.proto b/protogen/proto/opencloud/services/search/v0/search.proto index e8bf9ee2e4..68acdf1f6e 100644 --- a/protogen/proto/opencloud/services/search/v0/search.proto +++ b/protogen/proto/opencloud/services/search/v0/search.proto @@ -157,11 +157,12 @@ message AggregationOption { } message SortProperty { - // Required. The field to sort on, in graph notation. Supported: "name", - // "size", "lastModifiedDateTime", "photo.takenDateTime". A field is only - // sortable if it is indexed as a sortable type in both backends AND - // present on the Match entity (the service layer needs the sort key to - // merge per-space result streams). + // Required. The field to sort on, in graph notation ("name", "size", + // "lastModifiedDateTime", "mimeType" or a scalar facet field such as + // "photo.takenDateTime" or "audio.artist"). A field is sortable when it is + // indexed as a scalar in both backends AND carried on the Match entity + // (the service layer needs the sort key to merge per-space result + // streams); see the search package's IsSortableField. string name = 1; // Optional. Sort in descending order. Defaults to ascending. bool is_descending = 2; diff --git a/services/graph/pkg/service/v0/searchquery.go b/services/graph/pkg/service/v0/searchquery.go index b524dc02b9..fd48333d65 100644 --- a/services/graph/pkg/service/v0/searchquery.go +++ b/services/graph/pkg/service/v0/searchquery.go @@ -175,23 +175,14 @@ func validateAggregations(aggs []libregraph.AggregationOption) error { return nil } -// sortableFields is the set of fields accepted in sortProperties (mirrored in -// the openapi spec). A field qualifies only if it is indexed as a sortable -// type in both search backends AND carried on the Match entity: the service -// layer re-sorts matches when merging the per-space result streams and needs -// the sort key on the match itself. -var sortableFields = map[string]struct{}{ - "name": {}, - "size": {}, - "lastModifiedDateTime": {}, - "photo.takenDateTime": {}, -} - -// validateSortProperties rejects sorting by fields outside sortableFields. +// validateSortProperties rejects sorting by unknown or multivalued fields. +// Sortable are scalar fields carried on the search hit: name, size, +// lastModifiedDateTime, mimeType and the facet fields (photo.takenDateTime, +// audio.artist, image.width, ...); see search.IsSortableField. func validateSortProperties(sortProperties []libregraph.SortProperty) error { for _, sp := range sortProperties { - if _, ok := sortableFields[sp.Name]; !ok { - return fmt.Errorf("field %q is not sortable; sortable fields: name, size, lastModifiedDateTime, photo.takenDateTime", sp.Name) + if !search.IsSortableField(sp.Name) { + return fmt.Errorf("field %q is not sortable; sortable are scalar hit fields such as name, size, lastModifiedDateTime, mimeType or photo.takenDateTime", sp.Name) } } return nil diff --git a/services/graph/pkg/service/v0/searchquery_test.go b/services/graph/pkg/service/v0/searchquery_test.go index 0db86ef2c0..c89fc9d881 100644 --- a/services/graph/pkg/service/v0/searchquery_test.go +++ b/services/graph/pkg/service/v0/searchquery_test.go @@ -225,23 +225,56 @@ var _ = ginkgo.Describe("SearchQuery", func() { Expect(captured.OrderBy[1].IsDescending).To(BeFalse()) }) - ginkgo.It("rejects sorting by an unsupported field with 400", func() { - g := graphWithSearch(stubSearchService{ - search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { - ginkgo.Fail("search service must not be called when validation fails") - return nil, nil - }, - }) - rr := postSearchQuery(g, `{ - "requests": [{ - "entityTypes": ["driveItem"], - "query": {"queryString": "mediatype:image"}, - "sortProperties": [{"name": "photo.iso"}] - }] - }`) - Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String()) - Expect(rr.Body.String()).To(ContainSubstring("photo.iso")) - }) + ginkgo.DescribeTable("accepts sorting by scalar hit fields", + func(field string) { + g := graphWithSearch(stubSearchService{ + search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + return &searchsvc.SearchResponse{}, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "*"}, + "sortProperties": [{"name": "`+field+`"}] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + }, + ginkgo.Entry("name", "name"), + ginkgo.Entry("size", "size"), + ginkgo.Entry("lastModifiedDateTime", "lastModifiedDateTime"), + ginkgo.Entry("mimeType", "mimeType"), + ginkgo.Entry("photo.takenDateTime", "photo.takenDateTime"), + ginkgo.Entry("photo.iso", "photo.iso"), + ginkgo.Entry("audio.artist", "audio.artist"), + ginkgo.Entry("image.width", "image.width"), + ) + + ginkgo.DescribeTable("rejects sorting by unsortable fields with 400", + func(field string) { + g := graphWithSearch(stubSearchService{ + search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + ginkgo.Fail("search service must not be called when validation fails") + return nil, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "mediatype:image"}, + "sortProperties": [{"name": "`+field+`"}] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String()) + Expect(rr.Body.String()).To(ContainSubstring(field)) + }, + ginkgo.Entry("unknown field", "definitelyNotAField"), + ginkgo.Entry("multivalued field", "tags"), + ginkgo.Entry("internal index field name", "Mtime"), + ginkgo.Entry("bare audio facet", "audio"), + ginkgo.Entry("bare location facet", "location"), + ) ginkgo.It("rejects a terms aggregation on a numeric field with 400", func() { g := graphWithSearch(stubSearchService{ diff --git a/services/search/pkg/search/sort.go b/services/search/pkg/search/sort.go new file mode 100644 index 0000000000..af61fd99d7 --- /dev/null +++ b/services/search/pkg/search/sort.go @@ -0,0 +1,257 @@ +package search + +import ( + "reflect" + "strings" + + "google.golang.org/protobuf/reflect/protoreflect" + + searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" + searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" +) + +// Sorting support for search results (graph sortProperties / proto order_by). +// +// A field is sortable when both of the following hold: +// - it is indexed as a scalar (string, number, bool or time), so the +// engines can sort on it natively; multivalued fields like Tags are not +// sortable +// - it is carried on the match entity, so the service layer can read the +// sort key when merging the per-space result streams +// +// Both sets are derived by reflection (index side: the Resource type, match +// side: the Entity proto), so new facet fields become sortable automatically. + +// sortIndexAliases maps the graph-facing names of top-level fields to their +// index field names. Facet fields (photo.*, audio.*, ...) share the same +// dotted names in both worlds and need no alias. Top-level fields are only +// exposed under these graph names; internal fields like RootID or Deleted +// stay unsortable. +var sortIndexAliases = map[string]string{ + "name": "Name", + "size": "Size", + "lastModifiedDateTime": "Mtime", + "mimeType": "MimeType", +} + +// entityJSONAliases maps graph-facing names to the Entity proto's JSON names +// where the two disagree. +var entityJSONAliases = map[string]string{ + "lastModifiedDateTime": "lastModifiedTime", +} + +// IsSortableField reports whether results can be sorted by the field. +func IsSortableField(name string) bool { + _, ok := SortIndexField(name) + return ok +} + +// SortIndexField translates a graph sortProperties name into the index field +// name to sort on, reporting whether the field is sortable at all. +func SortIndexField(name string) (string, bool) { + field := name + if alias, ok := sortIndexAliases[name]; ok { + field = alias + } else if !strings.Contains(name, ".") { + return "", false + } + if !sortableIndexFields[field] { + return "", false + } + if !entityFieldResolvable(name) { + return "", false + } + return field, true +} + +// CompareMatches orders match a relative to b according to orderBy: -1 when a +// comes first, 1 when b comes first, 0 when the sort keys tie (callers fall +// back to the score). Matches missing a sort key sort after those that have +// it, regardless of direction. +func CompareMatches(a, b *searchmsg.Match, orderBy []*searchsvc.SortProperty) int { + for _, sp := range orderBy { + ka := matchSortKey(a, sp.GetName()) + kb := matchSortKey(b, sp.GetName()) + if !ka.present && !kb.present { + continue + } + if !ka.present { + return 1 + } + if !kb.present { + return -1 + } + c := 0 + switch { + case ka.isString: + c = strings.Compare(ka.str, kb.str) + case ka.num < kb.num: + c = -1 + case ka.num > kb.num: + c = 1 + } + if c == 0 { + continue + } + if sp.GetIsDescending() { + c = -c + } + return c + } + return 0 +} + +// sortableIndexFields is the set of scalar indexed fields, keyed by index +// field name. +var sortableIndexFields = buildSortableFieldSet() + +func buildSortableFieldSet() map[string]bool { + out := map[string]bool{} + collectScalarFields(out, "", reflect.TypeOf(Resource{})) + return out +} + +func collectScalarFields(out map[string]bool, prefix string, t reflect.Type) { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + if f.Anonymous { + collectScalarFields(out, prefix, f.Type) + continue + } + path := prefix + jsonFieldName(f) + ft := f.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + switch ft.Kind() { + case reflect.String, reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + out[path] = true + case reflect.Struct: + if ft == timeType { + out[path] = true + continue + } + collectScalarFields(out, path+".", ft) + } + } +} + +func entityPath(name string) []string { + if alias, ok := entityJSONAliases[name]; ok { + name = alias + } + return strings.Split(name, ".") +} + +const timestampFullName = protoreflect.FullName("google.protobuf.Timestamp") + +// entityFieldResolvable reports whether the graph field name resolves to a +// scalar (or timestamp) field on the match entity. +func entityFieldResolvable(name string) bool { + md := (&searchmsg.Entity{}).ProtoReflect().Descriptor() + segments := entityPath(name) + for i, seg := range segments { + fd := md.Fields().ByJSONName(seg) + if fd == nil || fd.IsList() || fd.IsMap() { + return false + } + if i < len(segments)-1 { + if fd.Kind() != protoreflect.MessageKind { + return false + } + md = fd.Message() + continue + } + switch fd.Kind() { + case protoreflect.StringKind, protoreflect.BoolKind, + protoreflect.Int32Kind, protoreflect.Int64Kind, + protoreflect.Sint32Kind, protoreflect.Sint64Kind, + protoreflect.Sfixed32Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint32Kind, protoreflect.Uint64Kind, + protoreflect.Fixed32Kind, protoreflect.Fixed64Kind, + protoreflect.FloatKind, protoreflect.DoubleKind: + return true + case protoreflect.MessageKind: + return fd.Message().FullName() == timestampFullName + } + return false + } + return false +} + +// sortKey is the comparable value of a sort field on a concrete match. +type sortKey struct { + present bool + isString bool + str string + num float64 +} + +// matchSortKey extracts the sort key for the graph field name from a match by +// walking the entity proto along the field's JSON names. +func matchSortKey(m *searchmsg.Match, name string) sortKey { + entity := m.GetEntity() + if entity == nil { + return sortKey{} + } + msg := entity.ProtoReflect() + segments := entityPath(name) + for i, seg := range segments { + fd := msg.Descriptor().Fields().ByJSONName(seg) + if fd == nil || fd.IsList() || fd.IsMap() { + return sortKey{} + } + if i < len(segments)-1 { + if fd.Kind() != protoreflect.MessageKind || !msg.Has(fd) { + return sortKey{} + } + msg = msg.Get(fd).Message() + continue + } + if fd.HasPresence() && !msg.Has(fd) { + return sortKey{} + } + v := msg.Get(fd) + switch fd.Kind() { + case protoreflect.StringKind: + return sortKey{present: true, isString: true, str: v.String()} + case protoreflect.BoolKind: + num := 0.0 + if v.Bool() { + num = 1.0 + } + return sortKey{present: true, num: num} + case protoreflect.Int32Kind, protoreflect.Int64Kind, + protoreflect.Sint32Kind, protoreflect.Sint64Kind, + protoreflect.Sfixed32Kind, protoreflect.Sfixed64Kind: + return sortKey{present: true, num: float64(v.Int())} + case protoreflect.Uint32Kind, protoreflect.Uint64Kind, + protoreflect.Fixed32Kind, protoreflect.Fixed64Kind: + return sortKey{present: true, num: float64(v.Uint())} + case protoreflect.FloatKind, protoreflect.DoubleKind: + return sortKey{present: true, num: v.Float()} + case protoreflect.MessageKind: + if fd.Message().FullName() != timestampFullName { + return sortKey{} + } + ts := v.Message() + seconds := ts.Get(ts.Descriptor().Fields().ByName("seconds")).Int() + nanos := ts.Get(ts.Descriptor().Fields().ByName("nanos")).Int() + return sortKey{present: true, num: float64(seconds) + float64(nanos)/1e9} + } + return sortKey{} + } + return sortKey{} +} diff --git a/services/search/pkg/search/sort_test.go b/services/search/pkg/search/sort_test.go new file mode 100644 index 0000000000..dd294d65e2 --- /dev/null +++ b/services/search/pkg/search/sort_test.go @@ -0,0 +1,125 @@ +package search_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/protobuf/types/known/timestamppb" + + 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/search/pkg/search" +) + +var _ = Describe("SortIndexField", func() { + DescribeTable("maps graph field names to index fields", + func(name string, wantField string, wantOK bool) { + field, ok := search.SortIndexField(name) + Expect(ok).To(Equal(wantOK)) + Expect(field).To(Equal(wantField)) + }, + // top-level fields are exposed under their graph names + Entry("name", "name", "Name", true), + Entry("size", "size", "Size", true), + Entry("lastModifiedDateTime", "lastModifiedDateTime", "Mtime", true), + Entry("mimeType", "mimeType", "MimeType", true), + // facet fields keep their dotted names + Entry("photo.takenDateTime", "photo.takenDateTime", "photo.takenDateTime", true), + Entry("photo.iso", "photo.iso", "photo.iso", true), + Entry("photo.cameraModel", "photo.cameraModel", "photo.cameraModel", true), + Entry("audio.artist", "audio.artist", "audio.artist", true), + Entry("audio.year", "audio.year", "audio.year", true), + Entry("image.width", "image.width", "image.width", true), + Entry("location.latitude", "location.latitude", "location.latitude", true), + // bare facets (message-typed, no scalar value) are not sortable + Entry("audio (bare facet)", "audio", "", false), + Entry("photo (bare facet)", "photo", "", false), + Entry("location (bare facet)", "location", "", false), + Entry("location (dotted but not scalar)", "location.", "", false), + // multivalued fields are not sortable + Entry("tags (repeated)", "tags", "", false), + Entry("Tags (index name, repeated)", "Tags", "", false), + // internal index fields are not exposed under their index names + Entry("Name (index name)", "Name", "", false), + Entry("Mtime (index name)", "Mtime", "", false), + Entry("RootID", "RootID", "", false), + Entry("Deleted", "Deleted", "", false), + // unknown fields + Entry("unknown", "definitelyNotAField", "", false), + Entry("unknown facet field", "photo.definitelyNotAField", "", false), + Entry("empty", "", "", false), + ) +}) + +var _ = Describe("CompareMatches", func() { + match := func(mutate func(e *searchmsg.Entity)) *searchmsg.Match { + e := &searchmsg.Entity{} + mutate(e) + return &searchmsg.Match{Entity: e} + } + asc := func(name string) []*searchsvc.SortProperty { + return []*searchsvc.SortProperty{{Name: name}} + } + desc := func(name string) []*searchsvc.SortProperty { + return []*searchsvc.SortProperty{{Name: name, IsDescending: true}} + } + + It("compares string fields lexicographically", func() { + a := match(func(e *searchmsg.Entity) { e.Name = "a.jpg" }) + b := match(func(e *searchmsg.Entity) { e.Name = "b.jpg" }) + Expect(search.CompareMatches(a, b, asc("name"))).To(Equal(-1)) + Expect(search.CompareMatches(b, a, asc("name"))).To(Equal(1)) + Expect(search.CompareMatches(a, b, desc("name"))).To(Equal(1)) + }) + + It("compares numeric fields numerically", func() { + small := match(func(e *searchmsg.Entity) { e.Size = 9 }) + big := match(func(e *searchmsg.Entity) { e.Size = 10 }) + Expect(search.CompareMatches(small, big, asc("size"))).To(Equal(-1)) + Expect(search.CompareMatches(small, big, desc("size"))).To(Equal(1)) + }) + + It("compares timestamps", func() { + older := match(func(e *searchmsg.Entity) { + e.Photo = &searchmsg.Photo{TakenDateTime: ×tamppb.Timestamp{Seconds: 100}} + }) + newer := match(func(e *searchmsg.Entity) { + e.Photo = &searchmsg.Photo{TakenDateTime: ×tamppb.Timestamp{Seconds: 200}} + }) + Expect(search.CompareMatches(older, newer, asc("photo.takenDateTime"))).To(Equal(-1)) + Expect(search.CompareMatches(older, newer, desc("photo.takenDateTime"))).To(Equal(1)) + }) + + It("compares lastModifiedDateTime via the entity's lastModifiedTime", func() { + older := match(func(e *searchmsg.Entity) { + e.LastModifiedTime = ×tamppb.Timestamp{Seconds: 100} + }) + newer := match(func(e *searchmsg.Entity) { + e.LastModifiedTime = ×tamppb.Timestamp{Seconds: 200} + }) + Expect(search.CompareMatches(older, newer, asc("lastModifiedDateTime"))).To(Equal(-1)) + }) + + It("sorts matches missing the field after those that have it, in both directions", func() { + has := match(func(e *searchmsg.Entity) { + e.Photo = &searchmsg.Photo{TakenDateTime: ×tamppb.Timestamp{Seconds: 100}} + }) + missing := match(func(e *searchmsg.Entity) {}) + Expect(search.CompareMatches(has, missing, asc("photo.takenDateTime"))).To(Equal(-1)) + Expect(search.CompareMatches(missing, has, asc("photo.takenDateTime"))).To(Equal(1)) + Expect(search.CompareMatches(has, missing, desc("photo.takenDateTime"))).To(Equal(-1)) + }) + + It("falls through to the next sort property on ties", func() { + a := match(func(e *searchmsg.Entity) { e.Size = 5; e.Name = "a" }) + b := match(func(e *searchmsg.Entity) { e.Size = 5; e.Name = "b" }) + orderBy := []*searchsvc.SortProperty{{Name: "size"}, {Name: "name"}} + Expect(search.CompareMatches(a, b, orderBy)).To(Equal(-1)) + }) + + It("returns 0 for full ties and empty orderBy", func() { + a := match(func(e *searchmsg.Entity) { e.Size = 5 }) + b := match(func(e *searchmsg.Entity) { e.Size = 5 }) + Expect(search.CompareMatches(a, b, asc("size"))).To(Equal(0)) + Expect(search.CompareMatches(a, b, nil)).To(Equal(0)) + }) +}) diff --git a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go index 9a463a95f1..ff7b63e42a 100644 --- a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go +++ b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go @@ -21,7 +21,7 @@ var _ MappedNullable = &SortProperty{} // SortProperty Indicates the order to sort search results in. Follows the [MS Graph sortProperty](https://learn.microsoft.com/en-us/graph/api/resources/sortproperty) resource type. type SortProperty struct { - // The name of the property to sort the search results by. The sortable fields are `name`, `size`, `lastModifiedDateTime` and `photo.takenDateTime`. Sorting by any other field is rejected with `invalidRequest`. Required. + // The name of the property to sort the search results by. Sortable are the scalar properties carried on the search hit's resource: `name`, `size`, `lastModifiedDateTime`, `mimeType` and the scalar facet properties such as `photo.takenDateTime`, `photo.iso`, `audio.artist`, `audio.year` or `image.width`. Strings sort lexicographically, numbers and dates by value. Multivalued properties (e.g. `tags`) and unknown properties are rejected with `invalidRequest`. Required. Name string `json:"name"` // Set to `true` to sort the results in descending order. Optional, defaults to `false` (ascending). IsDescending *bool `json:"isDescending,omitempty"` From 2ad0c6e23fce6ca5530c34a376aafb9c46d1957e Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 12 Aug 2026 16:33:33 +0200 Subject: [PATCH 4/7] feat(search): merge per-space results in order_by order The fan-out passes order_by through to each engine query; the merge re-sorts the combined matches with CompareMatches and keeps the score as tiebreaker (and as the sole criterion when no order_by is given). (cherry picked from commit 66af40fc34fd9daffc028ab06f83f7ade8a360d1) --- services/search/pkg/search/search.go | 10 --- services/search/pkg/search/service.go | 24 +++++- services/search/pkg/search/service_test.go | 98 ++++++++++++++++++++++ 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/services/search/pkg/search/search.go b/services/search/pkg/search/search.go index fb81135f2f..3c4af1f0cb 100644 --- a/services/search/pkg/search/search.go +++ b/services/search/pkg/search/search.go @@ -128,16 +128,6 @@ func ResolveReference(ctx context.Context, ref *provider.Reference, ri *provider type matchArray []*searchmsg.Match -func (ma matchArray) Len() int { - return len(ma) -} -func (ma matchArray) Swap(i, j int) { - ma[i], ma[j] = ma[j], ma[i] -} -func (ma matchArray) Less(i, j int) bool { - return ma[i].GetScore() > ma[j].GetScore() -} - func logDocCount(engine Engine, logger log.Logger) { c, err := engine.DocCount() if err != nil { diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index e3058684dd..07ad6df5bb 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -354,7 +354,28 @@ func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*se } // compile one sorted list of matches from all spaces and apply the limit if needed - sort.Sort(matches) + // + // NOTE(perf): every space was searched with the caller's full page size, + // so serving one page costs O(spaces x page_size) fetched matches. With + // offset pagination (the graph layer maps from/size onto a single + // page_size) each deeper page re-fetches everything before it on top. + // Accepted for now. The known fix is field-sorted cursor pagination via + // the currently unused page_token request/response fields: each space + // then serves "sort key < cursor, limit size" and page cost becomes + // independent of depth. Pushing plain offsets down into the engines + // would only trim the transfer, not the per-space overfetch, so it is + // not worth doing on its own. + // + // Each engine already returns its matches in order_by order (or by score + // when no order_by is given); this merge re-establishes that order across + // spaces, with the score as tiebreaker. + orderBy := req.GetOrderBy() + sort.SliceStable(matches, func(i, j int) bool { + if c := CompareMatches(matches[i], matches[j], orderBy); c != 0 { + return c < 0 + } + return matches[i].GetScore() > matches[j].GetScore() + }) limit := req.PageSize if limit == 0 { limit = 200 @@ -620,6 +641,7 @@ func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest, Query: req.Query, Aggregations: req.GetAggregations(), AggregationFilters: req.GetAggregationFilters(), + OrderBy: req.GetOrderBy(), Ref: &searchmsg.Reference{ ResourceId: searchRootID, Path: searchPathPrefix, diff --git a/services/search/pkg/search/service_test.go b/services/search/pkg/search/service_test.go index 433817b3ca..c07acc89d2 100644 --- a/services/search/pkg/search/service_test.go +++ b/services/search/pkg/search/service_test.go @@ -16,6 +16,7 @@ import ( cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks" "github.com/stretchr/testify/mock" "google.golang.org/grpc" + typespb "google.golang.org/protobuf/types/known/timestamppb" "github.com/opencloud-eu/opencloud/pkg/log" searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" @@ -275,6 +276,103 @@ var _ = Describe("Searchprovider", func() { }) }) + Context("with two personal spaces returning matches", func() { + var ( + spaceA = &sprovider.StorageSpace{ + Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$a!a"}, + Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "a", OpaqueId: "a"}, + Name: "space-a", + SpaceType: "personal", + } + spaceB = &sprovider.StorageSpace{ + Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$b!b"}, + Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "b", OpaqueId: "b"}, + Name: "space-b", + SpaceType: "personal", + } + + photoMatch = func(space string, name string, taken int64, score float32) *searchmsg.Match { + return &searchmsg.Match{ + Score: score, + Entity: &searchmsg.Entity{ + Ref: &searchmsg.Reference{ + ResourceId: &searchmsg.ResourceID{StorageId: "storageid", SpaceId: space, OpaqueId: space}, + Path: "./" + name, + }, + Id: &searchmsg.ResourceID{StorageId: "storageid", SpaceId: space, OpaqueId: name}, + Name: name, + Photo: &searchmsg.Photo{ + TakenDateTime: &typespb.Timestamp{Seconds: taken}, + }, + }, + } + } + ) + + BeforeEach(func() { + gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*sprovider.StorageSpace{spaceA, spaceB}, + }, nil) + indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool { + return req.Ref != nil && req.Ref.ResourceId.SpaceId == "a" + })).Return(&searchsvc.SearchIndexResponse{ + TotalMatches: 2, + Matches: []*searchmsg.Match{ + photoMatch("a", "a-old.jpg", 100, 0.9), + photoMatch("a", "a-new.jpg", 300, 0.1), + }, + }, nil) + indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool { + return req.Ref != nil && req.Ref.ResourceId.SpaceId == "b" + })).Return(&searchsvc.SearchIndexResponse{ + TotalMatches: 2, + Matches: []*searchmsg.Match{ + photoMatch("b", "b-newest.jpg", 400, 0.5), + photoMatch("b", "b-mid.jpg", 200, 0.4), + }, + }, nil) + }) + + It("forwards order_by to the engine", func() { + _, err := s.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}}, + }) + Expect(err).ToNot(HaveOccurred()) + indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool { + return len(req.OrderBy) == 1 && + req.OrderBy[0].Name == "photo.takenDateTime" && + req.OrderBy[0].IsDescending + })) + }) + + It("merges matches across spaces in sort order", func() { + res, err := s.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}}, + }) + Expect(err).ToNot(HaveOccurred()) + names := []string{} + for _, m := range res.Matches { + names = append(names, m.Entity.Name) + } + Expect(names).To(Equal([]string{"b-newest.jpg", "a-new.jpg", "b-mid.jpg", "a-old.jpg"})) + }) + + It("merges matches by score when no order_by is given", func() { + res, err := s.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + }) + Expect(err).ToNot(HaveOccurred()) + names := []string{} + for _, m := range res.Matches { + names = append(names, m.Entity.Name) + } + Expect(names).To(Equal([]string{"a-old.jpg", "b-newest.jpg", "b-mid.jpg", "a-new.jpg"})) + }) + }) + Context("with two personal spaces returning aggregations", func() { var ( spaceA = &sprovider.StorageSpace{ From 14361f867ea8734323b2ed15efce98823fdb053f Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 12 Aug 2026 16:35:49 +0200 Subject: [PATCH 5/7] feat(search): native order_by sorting in the bleve backend (cherry picked from commit 66a0755ad69a6a1af07e0b8335b08d63793777ce) --- services/search/pkg/bleve/backend.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index 6dfef81568..218d49e9a5 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -2,6 +2,7 @@ package bleve import ( "context" + "fmt" "math" "time" @@ -85,6 +86,24 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ bleveReq := bleve.NewSearchRequest(q) bleveReq.Highlight = bleve.NewHighlight() + // Sort natively in the index; the service layer re-establishes this order + // when merging matches across spaces. Score sorting (bleve's default) + // stays in place when no order_by is given. + if orderBy := sir.GetOrderBy(); len(orderBy) > 0 { + sortOrder := make([]string, 0, len(orderBy)+1) + for _, sp := range orderBy { + field, ok := search.SortIndexField(sp.GetName()) + if !ok { + return nil, errtypes.BadRequest(fmt.Sprintf("field %q is not sortable", sp.GetName())) + } + if sp.GetIsDescending() { + field = "-" + field + } + sortOrder = append(sortOrder, field) + } + bleveReq.SortBy(append(sortOrder, "-_score")) + } + switch { case sir.PageSize == -1: bleveReq.Size = math.MaxInt From f271bfb7efa92e9a667b821fc227b43c95a9ecd0 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 12 Aug 2026 16:45:06 +0200 Subject: [PATCH 6/7] feat(search): native order_by sorting in the opensearch backend Name is analyzed (lowercaseKeyword) and therefore a text field, which OpenSearch refuses to sort on without fielddata; enable it in the index template. The keyword tokenizer emits one term per document, so the fielddata cache stays small. The schema version has not shipped yet, so no reindex is needed. (cherry picked from commit f1d0e7b773b170b119dbd59fa176d656237bed96) --- services/search/pkg/opensearch/backend.go | 21 +++++++++++++++++++ services/search/pkg/opensearch/index.go | 1 - .../pkg/opensearch/internal/osu/request.go | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/services/search/pkg/opensearch/backend.go b/services/search/pkg/opensearch/backend.go index c3d7a3e710..96f0d0ce18 100644 --- a/services/search/pkg/opensearch/backend.go +++ b/services/search/pkg/opensearch/backend.go @@ -127,6 +127,26 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ if err != nil { return nil, err } + // Sort natively in the index; the service layer re-establishes this order + // when merging matches across spaces. Score sorting (the default) stays in + // place when no order_by is given. Missing values sort last in both + // directions, matching the cross-space merge. + var sortClause []map[string]any + if orderBy := sir.GetOrderBy(); len(orderBy) > 0 { + sortClause = make([]map[string]any, 0, len(orderBy)+1) + for _, sp := range orderBy { + field, ok := search.SortIndexField(sp.GetName()) + if !ok { + return nil, errtypes.BadRequest(fmt.Sprintf("field %q is not sortable", sp.GetName())) + } + order := "asc" + if sp.GetIsDescending() { + order = "desc" + } + sortClause = append(sortClause, map[string]any{field: map[string]any{"order": order, "missing": "_last"}}) + } + sortClause = append(sortClause, map[string]any{"_score": map[string]any{"order": "desc"}}) + } req, err := osu.BuildSearchReq(&opensearchgoAPI.SearchReq{ Indices: []string{b.index}, @@ -147,6 +167,7 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ }, }, Aggs: builtAggs, + Sort: sortClause, }, ) if err != nil { diff --git a/services/search/pkg/opensearch/index.go b/services/search/pkg/opensearch/index.go index b68b71f302..79b8a98ec3 100644 --- a/services/search/pkg/opensearch/index.go +++ b/services/search/pkg/opensearch/index.go @@ -74,7 +74,6 @@ func buildResourceMapping() ([]byte, error) { if err != nil { return nil, err } - index := map[string]any{ "settings": map[string]any{ "number_of_shards": "1", diff --git a/services/search/pkg/opensearch/internal/osu/request.go b/services/search/pkg/opensearch/internal/osu/request.go index f6f4e238ee..608773c165 100644 --- a/services/search/pkg/opensearch/internal/osu/request.go +++ b/services/search/pkg/opensearch/internal/osu/request.go @@ -96,6 +96,7 @@ func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyPa type SearchBodyParams struct { Highlight *BodyParamHighlight `json:"highlight,omitempty"` Aggs map[string]any `json:"aggs,omitempty"` + Sort []map[string]any `json:"sort,omitempty"` } //----------------------------------------------------------------------------// From 3f2c98886aea4e18e30b7ab74ea928e783398b40 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 12 Aug 2026 16:51:57 +0200 Subject: [PATCH 7/7] fix(search): pass order_by through the grpc service and cache key The grpc layer rebuilt the searcher request field by field and dropped order_by; the response cache also ignored it, so differently sorted searches collided on the same cache entry. (cherry picked from commit 97626bb26bd4fe8cdb74c19b519b2230c492681c) --- .../search/pkg/service/grpc/v0/service.go | 21 ++-- .../pkg/service/grpc/v0/service_test.go | 111 ++++++++++++++++++ 2 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 services/search/pkg/service/grpc/v0/service_test.go diff --git a/services/search/pkg/service/grpc/v0/service.go b/services/search/pkg/service/grpc/v0/service.go index 72f02ffa95..2e49e1551f 100644 --- a/services/search/pkg/service/grpc/v0/service.go +++ b/services/search/pkg/service/grpc/v0/service.go @@ -95,7 +95,7 @@ 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, in.Aggregations) + key := cacheKey(in.Query, in.PageSize, in.Ref, u, in.Aggregations, in.OrderBy) res, ok := s.FromCache(key) if !ok { var err error @@ -104,6 +104,7 @@ func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *s PageSize: in.PageSize, Ref: in.Ref, Aggregations: in.Aggregations, + OrderBy: in.OrderBy, }) if err != nil { switch err.(type) { @@ -246,17 +247,17 @@ func (s Service) Cache(key string, res *searchsvc.SearchResponse) { } // 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) +// must be in the key, including aggregations and order_by (serialised via +// deterministic proto marshalling). If those protos ever gain 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, orderBy []*searchsvc.SortProperty) string { + protoPart := "" + if len(aggs) > 0 || len(orderBy) > 0 { + b, _ := proto.MarshalOptions{Deterministic: true}.Marshal(&searchsvc.SearchRequest{Aggregations: aggs, OrderBy: orderBy}) + protoPart = 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) + ref.GetPath(), user.GetId().GetOpaqueId(), protoPart) } diff --git a/services/search/pkg/service/grpc/v0/service_test.go b/services/search/pkg/service/grpc/v0/service_test.go new file mode 100644 index 0000000000..7116bf6677 --- /dev/null +++ b/services/search/pkg/service/grpc/v0/service_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "context" + "testing" + "time" + + user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/jellydator/ttlcache/v2" + "github.com/opencloud-eu/reva/v2/pkg/auth/scope" + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" + "github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go-micro.dev/v4/metadata" + + "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" + searchmocks "github.com/opencloud-eu/opencloud/services/search/pkg/search/mocks" +) + +func newTestService(t *testing.T, searcher *searchmocks.Searcher) (Service, context.Context) { + t.Helper() + + tm, err := jwt.New(map[string]interface{}{"secret": "test-secret"}) + require.NoError(t, err) + + u := &user.User{Id: &user.UserId{OpaqueId: "test-user", Idp: "idp"}, Username: "test"} + scopes, err := scope.AddOwnerScope(nil) + require.NoError(t, err) + tok, err := tm.MintToken(context.Background(), u, scopes) + require.NoError(t, err) + ctx := metadata.Set(context.Background(), revactx.TokenHeader, tok) + + cache := ttlcache.NewCache() + require.NoError(t, cache.SetTTL(30*time.Second)) + + logger := log.NopLogger() + return Service{ + log: &logger, + searcher: searcher, + cache: cache, + tokenManager: tm, + }, ctx +} + +func TestServiceSearchForwardsOrderBy(t *testing.T) { + searcher := searchmocks.NewSearcher(t) + svc, ctx := newTestService(t, searcher) + + var captured *searchsvc.SearchRequest + searcher.EXPECT(). + Search(mock.Anything, mock.Anything). + Run(func(_ context.Context, req *searchsvc.SearchRequest) { + captured = req + }). + Return(&searchsvc.SearchResponse{}, nil). + Once() + + err := svc.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{ + {Name: "photo.takenDateTime", IsDescending: true}, + }, + }, &searchsvc.SearchResponse{}) + require.NoError(t, err) + require.NotNil(t, captured) + require.Len(t, captured.OrderBy, 1) + require.Equal(t, "photo.takenDateTime", captured.OrderBy[0].Name) + require.True(t, captured.OrderBy[0].IsDescending) +} + +func TestServiceSearchCacheDistinguishesOrderBy(t *testing.T) { + searcher := searchmocks.NewSearcher(t) + svc, ctx := newTestService(t, searcher) + + responseFor := func(name string) *searchsvc.SearchResponse { + return &searchsvc.SearchResponse{ + TotalMatches: 1, + Matches: []*searchmsg.Match{{Entity: &searchmsg.Entity{Name: name}}}, + } + } + searcher.EXPECT(). + Search(mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchRequest) bool { + return len(req.OrderBy) > 0 && req.OrderBy[0].IsDescending + })). + Return(responseFor("newest.jpg"), nil). + Once() + searcher.EXPECT(). + Search(mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchRequest) bool { + return len(req.OrderBy) > 0 && !req.OrderBy[0].IsDescending + })). + Return(responseFor("oldest.jpg"), nil). + Once() + + descOut := &searchsvc.SearchResponse{} + require.NoError(t, svc.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}}, + }, descOut)) + + ascOut := &searchsvc.SearchResponse{} + require.NoError(t, svc.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime"}}, + }, ascOut)) + + require.Equal(t, "newest.jpg", descOut.Matches[0].Entity.Name) + require.Equal(t, "oldest.jpg", ascOut.Matches[0].Entity.Name) +}