Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
package bleve
import (
"context"
"math"
"strings"
"time"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/search/query"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/search"
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchService "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
searchQuery "github.com/qsfera/server/services/search/pkg/query"
)
const defaultBatchSize = 50
var _ search.Engine = (*Backend)(nil) // ensure Backend implements Engine
type Backend struct {
index bleve.Index
queryCreator searchQuery.Creator[query.Query]
log log.Logger
}
func NewBackend(index bleve.Index, queryCreator searchQuery.Creator[query.Query], log log.Logger) *Backend {
return &Backend{
index: index,
queryCreator: queryCreator,
log: log,
}
}
// Search executes a search request operation within the index.
// Returns a SearchIndexResponse object or an error.
func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
createdQuery, err := b.queryCreator.Create(sir.Query)
if err != nil {
if searchQuery.IsValidationError(err) {
return nil, errtypes.BadRequest(err.Error())
}
return nil, err
}
q := bleve.NewConjunctionQuery(
// Skip documents that have been marked as deleted
&query.BoolFieldQuery{
Bool: false,
FieldVal: "Deleted",
},
createdQuery,
)
if sir.Ref != nil {
q.Conjuncts = append(
q.Conjuncts,
&query.TermQuery{
FieldVal: "RootID",
Term: storagespace.FormatResourceID(
&storageProvider.ResourceId{
StorageId: sir.Ref.GetResourceId().GetStorageId(),
SpaceId: sir.Ref.GetResourceId().GetSpaceId(),
OpaqueId: sir.Ref.GetResourceId().GetOpaqueId(),
},
),
},
)
}
bleveReq := bleve.NewSearchRequest(q)
bleveReq.Highlight = bleve.NewHighlight()
switch {
case sir.PageSize == -1:
bleveReq.Size = math.MaxInt
case sir.PageSize == 0:
bleveReq.Size = 200
default:
bleveReq.Size = int(sir.PageSize)
}
bleveReq.Fields = []string{"*"}
res, err := b.index.Search(bleveReq)
if err != nil {
return nil, err
}
matches := make([]*searchMessage.Match, 0, len(res.Hits))
totalMatches := res.Total
for _, hit := range res.Hits {
if sir.Ref != nil {
hitPath := strings.TrimSuffix(getFieldValue[string](hit.Fields, "Path"), "/")
requestedPath := utils.MakeRelativePath(sir.Ref.Path)
isRoot := hitPath == requestedPath
if !isRoot && requestedPath != "." && !strings.HasPrefix(hitPath, requestedPath+"/") {
totalMatches--
continue
}
}
rootID, err := storagespace.ParseID(getFieldValue[string](hit.Fields, "RootID"))
if err != nil {
return nil, err
}
rID, err := storagespace.ParseID(getFieldValue[string](hit.Fields, "ID"))
if err != nil {
return nil, err
}
pID, _ := storagespace.ParseID(getFieldValue[string](hit.Fields, "ParentID"))
match := &searchMessage.Match{
Score: float32(hit.Score),
Entity: &searchMessage.Entity{
Ref: &searchMessage.Reference{
ResourceId: resourceIDtoSearchID(rootID),
Path: getFieldValue[string](hit.Fields, "Path"),
},
Id: resourceIDtoSearchID(rID),
Name: getFieldValue[string](hit.Fields, "Name"),
ParentId: resourceIDtoSearchID(pID),
Size: uint64(getFieldValue[float64](hit.Fields, "Size")),
Type: uint64(getFieldValue[float64](hit.Fields, "Type")),
MimeType: getFieldValue[string](hit.Fields, "MimeType"),
Deleted: getFieldValue[bool](hit.Fields, "Deleted"),
Tags: getFieldSliceValue[string](hit.Fields, "Tags"),
Favorites: getFieldSliceValue[string](hit.Fields, "Favorites"),
Highlights: getFragmentValue(hit.Fragments, "Content", 0),
Audio: getAudioValue[searchMessage.Audio](hit.Fields),
Image: getImageValue[searchMessage.Image](hit.Fields),
Location: getLocationValue[searchMessage.GeoCoordinates](hit.Fields),
Photo: getPhotoValue[searchMessage.Photo](hit.Fields),
},
}
if mtime, err := time.Parse(time.RFC3339, getFieldValue[string](hit.Fields, "Mtime")); err == nil {
match.Entity.LastModifiedTime = &timestamppb.Timestamp{Seconds: mtime.Unix(), Nanos: int32(mtime.Nanosecond())}
}
matches = append(matches, match)
}
return &searchService.SearchIndexResponse{
Matches: matches,
TotalMatches: int32(totalMatches),
}, nil
}
func (b *Backend) DocCount() (uint64, error) {
return b.index.DocCount()
}
func (b *Backend) Upsert(id string, r search.Resource) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Upsert(id, r); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) Move(rootID, parentID, location string) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Move(rootID, parentID, location); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) Delete(id string) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Delete(id); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) Restore(id string) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Restore(id); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) Purge(id string, onlyDeleted bool) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Purge(id, onlyDeleted); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) NewBatch(size int) (search.BatchOperator, error) {
return NewBatch(b.index, size)
}
@@ -0,0 +1,710 @@
package bleve_test
import (
"context"
"fmt"
bleveSearch "github.com/blevesearch/bleve/v2"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/qsfera/server/pkg/log"
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/bleve"
"github.com/qsfera/server/services/search/pkg/content"
bleveQuery "github.com/qsfera/server/services/search/pkg/query/bleve"
"github.com/qsfera/server/services/search/pkg/search"
)
var _ = Describe("Bleve", func() {
var (
eng *bleve.Backend
idx bleveSearch.Index
doSearch = func(id string, query, path string) (*searchsvc.SearchIndexResponse, error) {
rID, err := storagespace.ParseID(id)
if err != nil {
return nil, err
}
return eng.Search(context.Background(), &searchsvc.SearchIndexRequest{
Query: query,
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: rID.StorageId,
SpaceId: rID.SpaceId,
OpaqueId: rID.OpaqueId,
},
Path: path,
},
})
}
assertDocCount = func(id string, query string, expectedCount int) []*searchmsg.Match {
res, err := doSearch(id, query, "")
ExpectWithOffset(1, err).ToNot(HaveOccurred())
ExpectWithOffset(1, len(res.Matches)).To(Equal(expectedCount), "query returned unexpected number of results: "+query)
return res.Matches
}
rootResource search.Resource
parentResource search.Resource
childResource search.Resource
childResource2 search.Resource
)
BeforeEach(func() {
mapping, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
idx, err = bleveSearch.NewMemOnly(mapping)
Expect(err).ToNot(HaveOccurred())
eng = bleve.NewBackend(idx, bleveQuery.DefaultCreator, log.Logger{})
Expect(err).ToNot(HaveOccurred())
rootResource = search.Resource{
ID: "1$2!2",
RootID: "1$2!2",
Path: ".",
Document: content.Document{},
}
parentResource = search.Resource{
ID: "1$2!3",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./parent d!r",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER),
Document: content.Document{Name: "parent d!r"},
}
childResource = search.Resource{
ID: "1$2!4",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./parent d!r/child.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "child.pdf"},
}
childResource2 = search.Resource{
ID: "1$2!5",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./parent d!r/child2.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "child2.pdf"},
}
})
Describe("New", func() {
It("returns a new index instance", func() {
b := bleve.NewBackend(idx, bleveQuery.DefaultCreator, log.Logger{})
Expect(b).ToNot(BeNil())
})
})
Describe("Search", func() {
Context("by other fields than filename", func() {
It("finds files by tags", func() {
parentResource.Document.Tags = []string{"foo", "bar"}
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Tags:foo", 1)
assertDocCount(rootResource.ID, "Tags:bar", 1)
assertDocCount(rootResource.ID, "Tags:foo Tags:bar", 1)
assertDocCount(rootResource.ID, "Tags:foo Tags:bar Tags:baz", 1)
assertDocCount(rootResource.ID, "Tags:foo Tags:bar Tags:baz", 1)
assertDocCount(rootResource.ID, "Tags:baz", 0)
})
It("finds files by size", func() {
parentResource.Document.Size = 12345
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Size:12345", 1)
assertDocCount(rootResource.ID, "Size:>1000", 1)
assertDocCount(rootResource.ID, "Size:<100000", 1)
assertDocCount(rootResource.ID, "Size:12344", 0)
assertDocCount(rootResource.ID, "Size:<1000", 0)
assertDocCount(rootResource.ID, "Size:>100000", 0)
})
It("preserves value case for fields not explicitly marked lowercase", func() {
parentResource.Document.Audio = &libregraph.Audio{
Artist: libregraph.PtrString("Some Artist"),
}
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `audio.artist:"Some Artist"`, 1)
assertDocCount(rootResource.ID, `audio.artist:"some artist"`, 0)
})
})
Context("by filename", func() {
It("finds files with spaces in the filename", func() {
parentResource.Document.Name = "Foo oo.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `name:"foo o*"`, 1)
})
It("finds files by digits in the filename", func() {
parentResource.Document.Name = "12345.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:1234*", 1)
})
It("filters hidden files", func() {
childResource.Hidden = true
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Hidden:T", 1)
assertDocCount(rootResource.ID, "Hidden:F", 0)
})
Context("with a file in the root of the space", func() {
It("scopes the search to the specified space", func() {
parentResource.Document.Name = "foo.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:foo.pdf", 1)
assertDocCount("9$8!7", "Name:foo.pdf", 0)
})
})
It("limits the search to the specified fields", func() {
parentResource.Document.Name = "bar.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:bar.pdf", 1)
assertDocCount(rootResource.ID, "Unknown:field", 0)
})
It("returns the total number of hits", func() {
parentResource.Document.Name = "bar.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
res, err := doSearch(rootResource.ID, "Name:bar*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(1)))
})
It("returns all desired fields", func() {
parentResource.Document.Name = "bar.pdf"
parentResource.Type = 3
parentResource.MimeType = "application/pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
matches := assertDocCount(rootResource.ID, fmt.Sprintf("Name:%s", parentResource.Name), 1)
match := matches[0]
Expect(match.Entity.Ref.Path).To(Equal(parentResource.Path))
Expect(match.Entity.Name).To(Equal(parentResource.Name))
Expect(match.Entity.Size).To(Equal(parentResource.Size))
Expect(match.Entity.Type).To(Equal(parentResource.Type))
Expect(match.Entity.MimeType).To(Equal(parentResource.MimeType))
Expect(match.Entity.Deleted).To(BeFalse())
Expect(match.Score > 0).To(BeTrue())
})
It("finds files by name, prefix or substring match", func() {
parentResource.Document.Name = "foo.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
queries := []string{"foo.pdf", "foo*", "*oo.p*"}
for _, query := range queries {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, query, 1)
}
})
It("does a case-insensitive search", func() {
parentResource.Document.Name = "foo.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:foo*", 1)
assertDocCount(rootResource.ID, "Name:Foo*", 1)
})
Context("and an additional file in a subdirectory", func() {
BeforeEach(func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
})
It("finds files living deeper in the tree by filename, prefix or substring match", func() {
queries := []string{"child.pdf", "child*", "*ld.*"}
for _, query := range queries {
assertDocCount(rootResource.ID, query, 1)
}
})
})
})
Context("Highlights", func() {
It("highlights only for content searches", func() {
parentResource.Document.Name = "baz.pdf"
parentResource.Document.Content = "foo bar baz"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
res, err := doSearch(rootResource.ID, "Name:baz*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(1)))
Expect(res.Matches[0].Entity.Highlights).To(Equal(""))
})
It("highlights search terms", func() {
parentResource.Document.Name = "baz.pdf"
parentResource.Document.Content = "foo bar baz"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
res, err := doSearch(rootResource.ID, "Content:bar", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(1)))
Expect(res.Matches[0].Entity.Highlights).To(Equal("foo <mark>bar</mark> baz"))
})
})
Context("with a file in the root of the space and folder with a file. all of them have the same name", func() {
BeforeEach(func() {
parentResource := search.Resource{
ID: "1$2!3",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./doc",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER),
Document: content.Document{Name: "doc"},
}
childResource := search.Resource{
ID: "1$2!4",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./doc/doc.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "doc.pdf"},
}
childResource2 := search.Resource{
ID: "1$2!7",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./doc/file.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "file.pdf"},
}
rootChildResource := search.Resource{
ID: "1$2!5",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./doc.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "doc.pdf"},
}
rootChildResource2 := search.Resource{
ID: "1$2!6",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./file.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "file.pdf"},
}
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(rootChildResource.ID, rootChildResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(rootChildResource2.ID, rootChildResource2)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource2.ID, childResource2)
Expect(err).ToNot(HaveOccurred())
})
It("search *doc* in a root", func() {
res, err := doSearch(rootResource.ID, "Name:*doc*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(3)))
})
It("search *doc* in a subfolder", func() {
res, err := doSearch(rootResource.ID, "Name:*doc*", "./doc")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(2)))
})
It("search *file* in a root", func() {
res, err := doSearch(rootResource.ID, "Name:*file*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(2)))
})
It("search *file* in a subfolder", func() {
res, err := doSearch(rootResource.ID, "Name:*file*", "./doc")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(1)))
})
})
})
Describe("Upsert", func() {
It("adds a resourceInfo to the index", func() {
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)))
query := bleveSearch.NewMatchQuery("child.pdf")
res, err := idx.Search(bleveSearch.NewSearchRequest(query))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits.Len()).To(Equal(1))
})
It("updates an existing resource in the index", func() {
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
countA, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(countA).To(Equal(uint64(1)))
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
countB, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(countB).To(Equal(uint64(1)))
})
})
Describe("Delete", func() {
It("marks a resource as deleted", func() {
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:*child*", 1)
err = eng.Delete(childResource.ID)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:*child*", 0)
})
It("marks a child resources as deleted", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
err = eng.Delete(parentResource.ID)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 0)
})
})
Describe("Restore", func() {
It("also marks child resources as restored", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Delete(parentResource.ID)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Name+`"`, 0)
assertDocCount(rootResource.ID, `"`+childResource.Name+`"`, 0)
err = eng.Restore(parentResource.ID)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Name+`"`, 1)
assertDocCount(rootResource.ID, `"`+childResource.Name+`"`, 1)
})
})
Describe("Purge", func() {
It("removes a resource from the index", func() {
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:child.pdf", 1)
err = eng.Purge(childResource.ID, false)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:child.pdf", 0)
})
It("removes a resource and its children from the index", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
err = eng.Purge(parentResource.ID, false)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 0)
})
It("removes a resource and ignores its children from the index", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
err = eng.Delete(parentResource.ID)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
err = eng.Purge(parentResource.ID, true)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
})
})
Describe("Move", func() {
It("renames the parent and its child resources", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
parentResource.Path = "newname"
err = eng.Move(parentResource.ID, parentResource.ParentID, "./my/newname")
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, parentResource.Name, 0)
matches := assertDocCount(rootResource.ID, "Name:child.pdf", 1)
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("3"))
Expect(matches[0].Entity.Ref.Path).To(Equal("./my/newname/child.pdf"))
})
It("moves the parent and its child resources", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
parentResource.Path = " "
parentResource.ParentID = "1$2!somewhereopaqueid"
err = eng.Move(parentResource.ID, parentResource.ParentID, "./somewhere/else/newname")
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `parent d!r`, 0)
matches := assertDocCount(rootResource.ID, "Name:child.pdf", 1)
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("3"))
Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname/child.pdf"))
matches = assertDocCount(rootResource.ID, `newname`, 1)
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("somewhereopaqueid"))
Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname"))
})
})
Describe("StartBatch", func() {
It("starts a new batch", func() {
b, err := eng.NewBatch(100)
Expect(err).ToNot(HaveOccurred())
err = b.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(0)))
err = b.Push()
Expect(err).ToNot(HaveOccurred())
count, err = idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)))
query := bleveSearch.NewMatchQuery("child.pdf")
res, err := idx.Search(bleveSearch.NewSearchRequest(query))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits.Len()).To(Equal(1))
})
It("doesn't intertwine different batches", func() {
b, err := eng.NewBatch(100)
Expect(err).ToNot(HaveOccurred())
err = b.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(0)))
b2, err := eng.NewBatch(100)
Expect(err).ToNot(HaveOccurred())
err = b2.Upsert(childResource2.ID, childResource2)
Expect(err).ToNot(HaveOccurred())
Expect(b.Push()).To(Succeed())
count, err = idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)))
Expect(b2.Push()).To(Succeed())
count, err = idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(2)))
})
})
Describe("File type specific metadata", func() {
Context("with audio metadata", func() {
BeforeEach(func() {
resource := search.Resource{
ID: "1$2!7",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./some_song.mp3",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{
Name: "some_song.mp3",
MimeType: "audio/mpeg",
Audio: &libregraph.Audio{
Album: libregraph.PtrString("Some Album"),
AlbumArtist: libregraph.PtrString("Some AlbumArtist"),
Artist: libregraph.PtrString("Some Artist"),
Bitrate: libregraph.PtrInt64(192),
Composers: libregraph.PtrString("Some Composers"),
Copyright: libregraph.PtrString(""),
Disc: libregraph.PtrInt32(2),
DiscCount: libregraph.PtrInt32(5),
Duration: libregraph.PtrInt64(225000),
Genre: libregraph.PtrString("Some Genre"),
HasDrm: libregraph.PtrBool(false),
IsVariableBitrate: libregraph.PtrBool(true),
Title: libregraph.PtrString("Some Title"),
Track: libregraph.PtrInt32(34),
TrackCount: libregraph.PtrInt32(99),
Year: libregraph.PtrInt32(2004),
},
},
}
err := eng.Upsert(resource.ID, resource)
Expect(err).ToNot(HaveOccurred())
})
It("returns audio metadata for search", func() {
matches := assertDocCount(rootResource.ID, `*song*`, 1)
audio := matches[0].Entity.Audio
Expect(audio).ToNot(BeNil())
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
Expect(audio.Copyright).To(Equal(libregraph.PtrString("")))
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(2)))
Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225000)))
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
Expect(audio.Track).To(Equal(libregraph.PtrInt32(34)))
Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(99)))
Expect(audio.Year).To(Equal(libregraph.PtrInt32(2004)))
})
})
Context("with location metadata", func() {
BeforeEach(func() {
resource := search.Resource{
ID: "1$2!7",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./team.jpg",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{
Name: "team.jpg",
MimeType: "image/jpeg",
Location: &libregraph.GeoCoordinates{
Altitude: libregraph.PtrFloat64(1047.7),
Latitude: libregraph.PtrFloat64(49.48675890884328),
Longitude: libregraph.PtrFloat64(11.103870357204285),
},
},
}
err := eng.Upsert(resource.ID, resource)
Expect(err).ToNot(HaveOccurred())
})
It("returns audio metadata for search", func() {
matches := assertDocCount(rootResource.ID, `*team*`, 1)
location := matches[0].Entity.Location
Expect(location).ToNot(BeNil())
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(1047.7)))
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
})
})
})
})
+195
View File
@@ -0,0 +1,195 @@
package bleve
import (
"errors"
"path"
"strings"
"github.com/blevesearch/bleve/v2"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/search"
)
var _ search.BatchOperator = (*Batch)(nil) // ensure Batch implements BatchOperator
type Batch struct {
batch *bleve.Batch
index bleve.Index
size int
log log.Logger
}
func NewBatch(index bleve.Index, size int) (*Batch, error) {
if size <= 0 {
return nil, errors.New("batch size must be greater than 0")
}
return &Batch{
batch: index.NewBatch(),
index: index,
size: size,
}, nil
}
func (b *Batch) Upsert(id string, r search.Resource) error {
return b.withSizeLimit(func() error {
return b.batch.Index(id, r)
})
}
func (b *Batch) Move(id, parentID, location string) error {
return b.withSizeLimit(func() error {
rootResource, err := searchResourceByID(id, b.index)
if err != nil {
return err
}
currentPath := rootResource.Path
nextPath := utils.MakeRelativePath(location)
rootResource.Path = nextPath
rootResource.Name = path.Base(nextPath)
rootResource.ParentID = parentID
resources := []*search.Resource{rootResource}
if rootResource.Type == uint64(storageProvider.ResourceType_RESOURCE_TYPE_CONTAINER) {
descendantResources, err := searchResourcesByPath(rootResource.RootID, currentPath, b.index)
if err != nil {
return err
}
for _, descendantResource := range descendantResources {
descendantResource.Path = strings.Replace(descendantResource.Path, currentPath, nextPath, 1)
resources = append(resources, descendantResource)
}
}
for _, resource := range resources {
if err := b.batch.Index(resource.ID, resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
if err := b.Push(); err != nil {
return err
}
}
}
return nil
})
}
func (b *Batch) Delete(id string) error {
return b.withSizeLimit(func() error {
affectedResources, err := searchAndUpdateResourcesDeletionState(id, true, b.index)
if err != nil {
return err
}
for _, resource := range affectedResources {
if err := b.batch.Index(resource.ID, resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
if err := b.Push(); err != nil {
return err
}
}
}
return nil
})
}
func (b *Batch) Restore(id string) error {
return b.withSizeLimit(func() error {
affectedResources, err := searchAndUpdateResourcesDeletionState(id, false, b.index)
if err != nil {
return err
}
for _, resource := range affectedResources {
if err := b.batch.Index(resource.ID, resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
if err := b.Push(); err != nil {
return err
}
}
}
return nil
})
}
func (b *Batch) Purge(id string, onlyDeleted bool) error {
return b.withSizeLimit(func() error {
rootResource, err := searchResourceByID(id, b.index)
if err != nil {
return err
}
var affectResources []*search.Resource
add := func(resource *search.Resource) {
if onlyDeleted && !resource.Deleted {
return
}
affectResources = append(affectResources, resource)
}
add(rootResource)
if rootResource.Type == uint64(storageProvider.ResourceType_RESOURCE_TYPE_CONTAINER) {
descendantResources, err := searchResourcesByPath(rootResource.RootID, rootResource.Path, b.index)
if err != nil {
return err
}
for _, descendantResource := range descendantResources {
add(descendantResource)
}
}
for _, resource := range affectResources {
b.batch.Delete(resource.ID)
if b.batch.Size() >= b.size {
if err := b.Push(); err != nil {
return err
}
}
}
return nil
})
}
func (b *Batch) Push() error {
if b.batch.Size() == 0 {
return nil
}
if err := b.index.Batch(b.batch); err != nil {
return err
}
b.batch.Reset()
return nil
}
func (b *Batch) withSizeLimit(f func() error) error {
if err := f(); err != nil {
return err
}
if b.batch.Size() >= b.size {
return b.Push()
}
return nil
}
+207
View File
@@ -0,0 +1,207 @@
package bleve
import (
"reflect"
"regexp"
"strings"
"time"
bleveSearch "github.com/blevesearch/bleve/v2/search"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"google.golang.org/protobuf/types/known/timestamppb"
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
"github.com/qsfera/server/services/search/pkg/content"
"github.com/qsfera/server/services/search/pkg/search"
)
var queryEscape = regexp.MustCompile(`([` + regexp.QuoteMeta(`+=&|><!(){}[]^\"~*?:\/`) + `\-\s])`)
func getFieldValue[T any](m map[string]any, key string) (out T) {
val, ok := m[key]
if !ok {
return
}
out, _ = val.(T)
return
}
func resourceIDtoSearchID(id storageProvider.ResourceId) *searchMessage.ResourceID {
return &searchMessage.ResourceID{
StorageId: id.GetStorageId(),
SpaceId: id.GetSpaceId(),
OpaqueId: id.GetOpaqueId()}
}
func getFieldSliceValue[T any](m map[string]any, key string) (out []T) {
iv := getFieldValue[any](m, key)
add := func(v any) {
cv, ok := v.(T)
if !ok {
return
}
out = append(out, cv)
}
// bleve tend to convert []string{"foo"} to type string if slice contains only one value
// bleve: []string{"foo"} -> "foo"
// bleve: []string{"foo", "bar"} -> []string{"foo", "bar"}
switch v := iv.(type) {
case T:
add(v)
case []any:
for _, rv := range v {
add(rv)
}
}
return
}
func getFragmentValue(m bleveSearch.FieldFragmentMap, key string, idx int) string {
val, ok := m[key]
if !ok {
return ""
}
if len(val) <= idx {
return ""
}
return val[idx]
}
func getAudioValue[T any](fields map[string]any) *T {
if !strings.HasPrefix(getFieldValue[string](fields, "MimeType"), "audio/") {
return nil
}
var audio = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(audio, fields, "audio."); ok {
return audio
}
return nil
}
func getImageValue[T any](fields map[string]any) *T {
var image = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(image, fields, "image."); ok {
return image
}
return nil
}
func getLocationValue[T any](fields map[string]any) *T {
var location = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(location, fields, "location."); ok {
return location
}
return nil
}
func getPhotoValue[T any](fields map[string]any) *T {
var photo = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(photo, fields, "photo."); ok {
return photo
}
return nil
}
func newPointerOfType[T any]() *T {
t := reflect.TypeOf((*T)(nil)).Elem()
ptr := reflect.New(t).Interface()
return ptr.(*T)
}
func unmarshalInterfaceMap(out any, flatMap map[string]any, prefix string) bool {
nonEmpty := false
obj := reflect.ValueOf(out).Elem()
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
structField := obj.Type().Field(i)
mapKey := prefix + getFieldName(structField)
if value, ok := flatMap[mapKey]; ok {
if field.Kind() == reflect.Ptr {
alloc := reflect.New(field.Type().Elem())
elemType := field.Type().Elem()
// convert time strings from index for search requests
if elemType == reflect.TypeOf(timestamppb.Timestamp{}) {
if strValue, ok := value.(string); ok {
if parsedTime, err := time.Parse(time.RFC3339, strValue); err == nil {
alloc.Elem().Set(reflect.ValueOf(*timestamppb.New(parsedTime)))
field.Set(alloc)
nonEmpty = true
}
}
continue
}
// convert time strings from index for libregraph structs when updating resources
if elemType == reflect.TypeOf(time.Time{}) {
if strValue, ok := value.(string); ok {
if parsedTime, err := time.Parse(time.RFC3339, strValue); err == nil {
alloc.Elem().Set(reflect.ValueOf(parsedTime))
field.Set(alloc)
nonEmpty = true
}
}
continue
}
alloc.Elem().Set(reflect.ValueOf(value).Convert(elemType))
field.Set(alloc)
nonEmpty = true
}
}
}
return nonEmpty
}
func getFieldName(structField reflect.StructField) string {
tag := structField.Tag.Get("json")
if tag == "" {
return structField.Name
}
return strings.Split(tag, ",")[0]
}
func matchToResource(match *bleveSearch.DocumentMatch) *search.Resource {
return &search.Resource{
ID: getFieldValue[string](match.Fields, "ID"),
RootID: getFieldValue[string](match.Fields, "RootID"),
Path: getFieldValue[string](match.Fields, "Path"),
ParentID: getFieldValue[string](match.Fields, "ParentID"),
Type: uint64(getFieldValue[float64](match.Fields, "Type")),
Deleted: getFieldValue[bool](match.Fields, "Deleted"),
Document: content.Document{
Name: getFieldValue[string](match.Fields, "Name"),
Title: getFieldValue[string](match.Fields, "Title"),
Size: uint64(getFieldValue[float64](match.Fields, "Size")),
Mtime: getFieldValue[string](match.Fields, "Mtime"),
MimeType: getFieldValue[string](match.Fields, "MimeType"),
Content: getFieldValue[string](match.Fields, "Content"),
Tags: getFieldSliceValue[string](match.Fields, "Tags"),
Favorites: getFieldSliceValue[string](match.Fields, "Favorites"),
Audio: getAudioValue[libregraph.Audio](match.Fields),
Image: getImageValue[libregraph.Image](match.Fields),
Location: getLocationValue[libregraph.GeoCoordinates](match.Fields),
Photo: getPhotoValue[libregraph.Photo](match.Fields),
},
}
}
func escapeQuery(s string) string {
return queryEscape.ReplaceAllString(s, "\\$1")
}
@@ -0,0 +1,13 @@
package bleve_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestEngine(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Bleve Suite")
}
+148
View File
@@ -0,0 +1,148 @@
package bleve
import (
"errors"
"math"
"path/filepath"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/porter"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/single"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/mapping"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/services/search/pkg/search"
)
func NewIndex(root string) (bleve.Index, error) {
destination := filepath.Join(root, "bleve")
index, err := bleve.Open(destination)
if errors.Is(bleve.ErrorIndexPathDoesNotExist, err) {
indexMapping, err := NewMapping()
if err != nil {
return nil, err
}
index, err = bleve.New(destination, indexMapping)
if err != nil {
return nil, err
}
return index, nil
}
return index, err
}
func NewMapping() (mapping.IndexMapping, error) {
nameMapping := bleve.NewTextFieldMapping()
nameMapping.Analyzer = "lowercaseKeyword"
lowercaseMapping := bleve.NewTextFieldMapping()
lowercaseMapping.IncludeInAll = false
lowercaseMapping.Analyzer = "lowercaseKeyword"
fulltextFieldMapping := bleve.NewTextFieldMapping()
fulltextFieldMapping.Analyzer = "fulltext"
fulltextFieldMapping.IncludeInAll = false
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("Name", nameMapping)
docMapping.AddFieldMappingsAt("Tags", lowercaseMapping)
docMapping.AddFieldMappingsAt("Favorites", lowercaseMapping)
docMapping.AddFieldMappingsAt("Content", fulltextFieldMapping)
indexMapping := bleve.NewIndexMapping()
indexMapping.DefaultAnalyzer = keyword.Name
indexMapping.DefaultMapping = docMapping
err := indexMapping.AddCustomAnalyzer("lowercaseKeyword",
map[string]any{
"type": custom.Name,
"tokenizer": single.Name,
"token_filters": []string{
lowercase.Name,
},
},
)
if err != nil {
return nil, err
}
err = indexMapping.AddCustomAnalyzer("fulltext",
map[string]any{
"type": custom.Name,
"tokenizer": unicode.Name,
"token_filters": []string{
lowercase.Name,
porter.Name,
},
},
)
if err != nil {
return nil, err
}
return indexMapping, nil
}
func searchResourceByID(id string, index bleve.Index) (*search.Resource, error) {
req := bleve.NewSearchRequest(bleve.NewDocIDQuery([]string{id}))
req.Fields = []string{"*"}
res, err := index.Search(req)
if err != nil {
return nil, err
}
if res.Hits.Len() == 0 {
return nil, errors.New("entity not found")
}
return matchToResource(res.Hits[0]), nil
}
func searchResourcesByPath(rootId, lookupPath string, index bleve.Index) ([]*search.Resource, error) {
q := bleve.NewConjunctionQuery(
bleve.NewQueryStringQuery("RootID:"+rootId),
bleve.NewQueryStringQuery("Path:"+escapeQuery(lookupPath+"/*")),
)
bleveReq := bleve.NewSearchRequest(q)
bleveReq.Size = math.MaxInt
bleveReq.Fields = []string{"*"}
res, err := index.Search(bleveReq)
if err != nil {
return nil, err
}
resources := make([]*search.Resource, 0, res.Hits.Len())
for _, match := range res.Hits {
resources = append(resources, matchToResource(match))
}
return resources, nil
}
func searchAndUpdateResourcesDeletionState(id string, state bool, index bleve.Index) ([]*search.Resource, error) {
rootResource, err := searchResourceByID(id, index)
if err != nil {
return nil, err
}
rootResource.Deleted = state
resources := []*search.Resource{rootResource}
if rootResource.Type == uint64(storageProvider.ResourceType_RESOURCE_TYPE_CONTAINER) {
descendantResources, err := searchResourcesByPath(rootResource.RootID, rootResource.Path, index)
if err != nil {
return nil, err
}
for _, descendantResource := range descendantResources {
descendantResource.Deleted = state
resources = append(resources, descendantResource)
}
}
return resources, nil
}
@@ -0,0 +1,53 @@
package command
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/config/parser"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "check health status",
PreRunE: func(cmd *cobra.Command, args []string) error {
return parser.ParseConfig(cfg)
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
resp, err := http.Get(
fmt.Sprintf(
"http://%s/healthz",
cfg.Debug.Addr,
),
)
if err != nil {
logger.Fatal().
Err(err).
Msg("Failed to request health check")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Fatal().
Int("code", resp.StatusCode).
Msg("Health seems to be in bad state")
}
logger.Debug().
Int("code", resp.StatusCode).
Msg("Health got a good state")
return nil
},
}
}
@@ -0,0 +1,99 @@
package command
import (
"context"
"crypto/tls"
"errors"
"fmt"
"time"
"github.com/qsfera/server/pkg/config/configlog"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/config/parser"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
// Index is the entrypoint for the server command.
func Index(cfg *config.Config) *cobra.Command {
indexCmd := &cobra.Command{
Use: "index",
Short: "index the files for one one more users",
Aliases: []string{"i"},
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
allSpacesFlag, _ := cmd.Flags().GetBool("all-spaces")
spaceFlag, _ := cmd.Flags().GetString("space")
forceRescanFlag, _ := cmd.Flags().GetBool("force-rescan")
endpointFlag, _ := cmd.Flags().GetString("endpoint")
insecureFlag, _ := cmd.Flags().GetBool("insecure")
if spaceFlag == "" && !allSpacesFlag {
return errors.New("either --space or --all-spaces is required")
}
var dialOpts []grpc.DialOption
if cfg.GRPCClientTLS.Mode == "insecure" || insecureFlag {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS12,
})))
}
conn, err := grpc.NewClient(endpointFlag, dialOpts...)
if err != nil {
return fmt.Errorf("failed to dial %s: %w", endpointFlag, err)
}
defer conn.Close()
c := searchsvc.NewSearchProviderClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
_, err = c.IndexSpace(ctx, &searchsvc.IndexSpaceRequest{
SpaceId: spaceFlag,
ForceReindex: forceRescanFlag,
})
if err != nil {
fmt.Println("failed to index space: " + err.Error())
return err
}
return nil
},
}
indexCmd.Flags().StringP(
"space",
"s",
"",
"the id of the space to travers and index the files of. This or --all-spaces is required.")
indexCmd.Flags().Bool(
"all-spaces",
false,
"index all spaces instead. This or --space is required.",
)
indexCmd.Flags().Bool(
"force-rescan",
false,
"force a rescan of all files, even if they are already indexed. This will make the indexing process much slower, but ensures that the index is up-to-date using the current search service configuration.",
)
indexCmd.Flags().String(
"endpoint",
"127.0.0.1:9220",
"the address of the search service gRPC endpoint.",
)
indexCmd.Flags().Bool(
"insecure",
false,
"disable TLS for the gRPC connection.",
)
return indexCmd
}
@@ -0,0 +1,37 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return []*cobra.Command{
// start this service
Server(cfg),
// interaction with this service
Index(cfg),
// infos about this service
Health(cfg),
Version(cfg),
}
}
// Execute is the entry point for the qsfera-search command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "search",
Short: "Serve search API for КуСфера",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
@@ -0,0 +1,240 @@
package command
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"os"
"os/signal"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/generators"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/runner"
ogrpc "github.com/qsfera/server/pkg/service/grpc"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/search/pkg/bleve"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/config/parser"
"github.com/qsfera/server/services/search/pkg/content"
"github.com/qsfera/server/services/search/pkg/metrics"
"github.com/qsfera/server/services/search/pkg/opensearch"
bleveQuery "github.com/qsfera/server/services/search/pkg/query/bleve"
"github.com/qsfera/server/services/search/pkg/search"
"github.com/qsfera/server/services/search/pkg/server/debug"
"github.com/qsfera/server/services/search/pkg/server/grpc"
svcEvent "github.com/qsfera/server/services/search/pkg/service/event"
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/spf13/cobra"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
cfg.GrpcClient, err = ogrpc.NewClient(
append(ogrpc.GetClientOptions(cfg.GRPCClientTLS), ogrpc.WithTraceProvider(traceProvider))...,
)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
ctx := cfg.Context
mtrcs := metrics.New()
mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1)
// initialize search engine
var eng search.Engine
switch cfg.Engine.Type {
case "bleve":
idx, err := bleve.NewIndex(cfg.Engine.Bleve.Datapath)
if err != nil {
return err
}
defer func() {
if err = idx.Close(); err != nil {
logger.Error().Err(err).Msg("could not close bleve index")
}
}()
eng = bleve.NewBackend(idx, bleveQuery.DefaultCreator, logger)
case "open-search":
clientConfig := opensearchgo.Config{
Addresses: cfg.Engine.OpenSearch.Client.Addresses,
Username: cfg.Engine.OpenSearch.Client.Username,
Password: cfg.Engine.OpenSearch.Client.Password,
Header: cfg.Engine.OpenSearch.Client.Header,
RetryOnStatus: cfg.Engine.OpenSearch.Client.RetryOnStatus,
DisableRetry: cfg.Engine.OpenSearch.Client.DisableRetry,
EnableRetryOnTimeout: cfg.Engine.OpenSearch.Client.EnableRetryOnTimeout,
MaxRetries: cfg.Engine.OpenSearch.Client.MaxRetries,
CompressRequestBody: cfg.Engine.OpenSearch.Client.CompressRequestBody,
DiscoverNodesOnStart: cfg.Engine.OpenSearch.Client.DiscoverNodesOnStart,
DiscoverNodesInterval: cfg.Engine.OpenSearch.Client.DiscoverNodesInterval,
EnableMetrics: cfg.Engine.OpenSearch.Client.EnableMetrics,
EnableDebugLogger: cfg.Engine.OpenSearch.Client.EnableDebugLogger,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.Engine.OpenSearch.Client.Insecure,
},
},
}
if cfg.Engine.OpenSearch.Client.CACert != "" {
certBytes, err := os.ReadFile(cfg.Engine.OpenSearch.Client.CACert)
if err != nil {
return fmt.Errorf("failed to read CA cert: %w", err)
}
clientConfig.CACert = certBytes
}
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{Client: clientConfig})
if err != nil {
return fmt.Errorf("failed to create OpenSearch client: %w", err)
}
openSearchBackend, err := opensearch.NewBackend(cfg.Engine.OpenSearch.ResourceIndex.Name, client)
if err != nil {
return fmt.Errorf("failed to create OpenSearch backend: %w", err)
}
eng = openSearchBackend
default:
return fmt.Errorf("unknown search engine: %s", cfg.Engine.Type)
}
// initialize gateway selector
selector, err := pool.GatewaySelector(cfg.Reva.Address, pool.WithRegistry(registry.GetRegistry()), pool.WithTracerProvider(traceProvider))
if err != nil {
logger.Fatal().Err(err).Msg("could not get reva gateway selector")
return err
}
// initialize search content extractor
var extractor content.Extractor
switch cfg.Extractor.Type {
case "basic":
if extractor, err = content.NewBasicExtractor(logger); err != nil {
return err
}
case "tika":
if extractor, err = content.NewTikaExtractor(selector, logger, cfg); err != nil {
return err
}
default:
return fmt.Errorf("unknown search extractor: %s", cfg.Extractor.Type)
}
ss := search.NewService(selector, eng, extractor, mtrcs, logger, cfg)
// setup the servers
gr := runner.NewGroup()
if !cfg.GRPC.Disabled {
grpcServer, err := grpc.Server(
grpc.Config(cfg),
grpc.Logger(logger),
grpc.Name(cfg.Service.Name),
grpc.Context(ctx),
grpc.Metrics(mtrcs),
grpc.JWTSecret(cfg.TokenManager.JWTSecret),
grpc.TraceProvider(traceProvider),
grpc.GatewaySelector(selector),
grpc.Searcher(ss),
)
if err != nil {
logger.Error().Err(err).Str("transport", "grpc").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGoMicroGrpcServerRunner(cfg.Service.Name+".grpc", grpcServer))
} else {
logger.Info().Msg("gRPC server disabled, not starting gRPC service")
}
if !cfg.Events.Disabled {
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
bus, err := raw.FromConfig(context.Background(), connName, raw.Config{
Endpoint: cfg.Events.Endpoint,
Cluster: cfg.Events.Cluster,
EnableTLS: cfg.Events.EnableTLS,
TLSInsecure: cfg.Events.TLSInsecure,
TLSRootCACertificate: cfg.Events.TLSRootCACertificate,
AuthUsername: cfg.Events.AuthUsername,
AuthPassword: cfg.Events.AuthPassword,
MaxAckPending: cfg.Events.MaxAckPending,
AckWait: cfg.Events.AckWait,
})
if err != nil {
logger.Error().Err(err).Msg("Failed to create event bus client")
return err
}
eventSvc, err := svcEvent.New(ctx, bus, logger, traceProvider, mtrcs, ss, cfg.Events.DebounceDuration, cfg.Events.NumConsumers, cfg.Events.AsyncUploads)
if err != nil {
logger.Error().Err(err).Str("transport", "event").Msg("Failed to initialize server")
return err
}
gr.Add(runner.New(cfg.Service.Name+".svc", func() error {
return eventSvc.Run()
}, func() {
eventSvc.Close()
}))
} else {
logger.Info().Msg("event listening disabled, not starting event service")
}
// always start a debug server
{
debugServer, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Error().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
}
grResults := gr.Run(ctx)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
@@ -0,0 +1,49 @@
package command
import (
"fmt"
"os"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Version: " + version.GetString())
fmt.Printf("Compiled: %s\n", version.Compiled())
fmt.Println("")
reg := registry.GetRegistry()
services, err := reg.GetService(cfg.GRPC.Namespace + "." + cfg.Service.Name)
if err != nil {
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
return err
}
if len(services) == 0 {
fmt.Println("No running " + cfg.Service.Name + " service found.")
return nil
}
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
table.Header([]string{"Version", "Address", "Id"})
for _, s := range services {
for _, n := range s.Nodes {
table.Append([]string{s.Version, n.Address, n.Id})
}
}
table.Render()
return nil
},
}
}
@@ -0,0 +1,41 @@
package config
import (
"context"
"github.com/qsfera/server/pkg/shared"
"go-micro.dev/v4/client"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;SEARCH_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug"`
GRPC GRPCConfig `yaml:"grpc"`
GrpcClient client.Client `yaml:"-"`
TokenManager *TokenManager `yaml:"token_manager"`
Reva *shared.Reva `yaml:"reva"`
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
Events Events `yaml:"events"`
Engine Engine `yaml:"engine"`
Extractor Extractor `yaml:"extractor"`
ContentExtractionSizeLimit uint64 `yaml:"content_extraction_size_limit" env:"SEARCH_CONTENT_EXTRACTION_SIZE_LIMIT" desc:"Maximum file size in bytes that is allowed for content extraction." introductionVersion:"1.0.0"`
BatchSize int `yaml:"batch_size" env:"SEARCH_BATCH_SIZE" desc:"The number of documents to process in a single batch. Defaults to 500." introductionVersion:"1.0.0"`
ServiceAccount ServiceAccount `yaml:"service_account"`
Context context.Context `yaml:"-"`
}
// ServiceAccount is the configuration for the used service account
type ServiceAccount struct {
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;SEARCH_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"1.0.0"`
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;SEARCH_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,14 @@
package config
// Extractor defines which extractor to use
type Extractor struct {
Type string `yaml:"type" env:"SEARCH_EXTRACTOR_TYPE" desc:"Defines the content extraction engine. Defaults to 'basic'. Supported values are: 'basic' and 'tika'." introductionVersion:"1.0.0"`
CS3AllowInsecure bool `yaml:"cs3_allow_insecure" env:"OC_INSECURE;SEARCH_EXTRACTOR_CS3SOURCE_INSECURE" desc:"Ignore untrusted SSL certificates when connecting to the CS3 source." introductionVersion:"1.0.0"`
Tika ExtractorTika `yaml:"tika"`
}
// ExtractorTika configures the Tika extractor
type ExtractorTika struct {
TikaURL string `yaml:"tika_url" env:"SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"`
CleanStopWords bool `yaml:"clean_stop_words" env:"SEARCH_EXTRACTOR_TIKA_CLEAN_STOP_WORDS" desc:"Defines if stop words should be cleaned or not. See the documentation for more details." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"SEARCH_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"SEARCH_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"SEARCH_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"SEARCH_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,99 @@
package defaults
import (
"path/filepath"
"time"
"github.com/qsfera/server/pkg/config/defaults"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/structs"
"github.com/qsfera/server/services/search/pkg/config"
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
return cfg
}
// DefaultConfig returns a basic default configuration
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:9224",
Token: "",
},
GRPC: config.GRPCConfig{
Addr: "127.0.0.1:9220",
Namespace: "qsfera.api",
},
Service: config.Service{
Name: "search",
},
Reva: shared.DefaultRevaConfig(),
Engine: config.Engine{
Type: "bleve",
Bleve: config.EngineBleve{
Datapath: filepath.Join(defaults.BaseDataPath(), "search"),
},
OpenSearch: config.EngineOpenSearch{
ResourceIndex: config.EngineOpenSearchResourceIndex{
Name: "qsfera-resource",
},
},
},
Extractor: config.Extractor{
Type: "basic",
CS3AllowInsecure: false,
Tika: config.ExtractorTika{
TikaURL: "http://127.0.0.1:9998",
CleanStopWords: false,
},
},
Events: config.Events{
Endpoint: "127.0.0.1:9233",
Cluster: "qsfera-cluster",
DebounceDuration: 1000,
AsyncUploads: true,
NumConsumers: 1,
EnableTLS: false,
MaxAckPending: 1000,
AckWait: 1 * time.Minute,
},
ContentExtractionSizeLimit: 20 * 1024 * 1024, // Limit content extraction to <20MB files by default
BatchSize: 50,
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
cfg.TokenManager = &config.TokenManager{
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
}
} else if cfg.TokenManager == nil {
cfg.TokenManager = &config.TokenManager{}
}
if cfg.Reva == nil && cfg.Commons != nil {
cfg.Reva = structs.CopyOrZeroValue(cfg.Commons.Reva)
}
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
}
if cfg.GRPC.TLS == nil && cfg.Commons != nil {
cfg.GRPC.TLS = structs.CopyOrZeroValue(cfg.Commons.GRPCServiceTLS)
}
}
// Sanitize sanitizes the configuration
func Sanitize(cfg *config.Config) {
// no http endpoint to be sanitized
}
@@ -0,0 +1,48 @@
package config
import (
"net/http"
"time"
)
// Engine defines which search engine to use
type Engine struct {
Type string `yaml:"type" env:"SEARCH_ENGINE_TYPE" desc:"Defines which search engine to use. Defaults to 'bleve'. Supported values are: 'bleve'." introductionVersion:"1.0.0"`
Bleve EngineBleve `yaml:"bleve"`
OpenSearch EngineOpenSearch `yaml:"open_search"`
}
// EngineBleve configures the bleve engine
type EngineBleve struct {
Datapath string `yaml:"data_path" env:"SEARCH_ENGINE_BLEVE_DATA_PATH" desc:"The directory where the filesystem will store search data. If not defined, the root directory derives from $OC_BASE_DATA_PATH/search." introductionVersion:"1.0.0"`
}
// EngineOpenSearch configures the OpenSearch engine
type EngineOpenSearch struct {
Client EngineOpenSearchClient `yaml:"client"`
ResourceIndex EngineOpenSearchResourceIndex `yaml:"resource_index"`
}
// EngineOpenSearchResourceIndex defines the OpenSearch index for resources
type EngineOpenSearchResourceIndex struct {
Name string `yaml:"name" env:"SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME" desc:"The name of the OpenSearch index for resources." introductionVersion:"4.0.0"`
}
// EngineOpenSearchClient configures the OpenSearch client
type EngineOpenSearchClient struct {
Addresses []string `yaml:"addresses" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ADDRESSES" desc:"The addresses of the OpenSearch nodes.." introductionVersion:"4.0.0"`
Username string `yaml:"username" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_USERNAME" desc:"Username for HTTP Basic Authentication." introductionVersion:"4.0.0"`
Password string `yaml:"password" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_PASSWORD" desc:"Password for HTTP Basic Authentication." introductionVersion:"4.0.0"`
Header http.Header `yaml:"header" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_HEADER" desc:"HTTP headers to include in requests." introductionVersion:"4.0.0"`
CACert string `yaml:"ca_cert" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_CA_CERT" desc:"Path/File name for the root CA certificate (in PEM format) used to validate TLS server certificates of the opensearch server." introductionVersion:"4.0.0"`
RetryOnStatus []int `yaml:"retry_on_status" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_RETRY_ON_STATUS" desc:"HTTP status codes that trigger a retry." introductionVersion:"4.0.0"`
DisableRetry bool `yaml:"disable_retry" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_DISABLE_RETRY" desc:"Disable retries on errors." introductionVersion:"4.0.0"`
EnableRetryOnTimeout bool `yaml:"enable_retry_on_timeout" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ENABLE_RETRY_ON_TIMEOUT" desc:"Enable retries on timeout." introductionVersion:"4.0.0"`
MaxRetries int `yaml:"max_retries" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_MAX_RETRIES" desc:"Maximum number of retries for requests." introductionVersion:"4.0.0"`
CompressRequestBody bool `yaml:"compress_request_body" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_COMPRESS_REQUEST_BODY" desc:"Compress request bodies." introductionVersion:"4.0.0"`
DiscoverNodesOnStart bool `yaml:"discover_nodes_on_start" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_DISCOVER_NODES_ON_START" desc:"Discover nodes on service start." introductionVersion:"4.0.0"`
DiscoverNodesInterval time.Duration `yaml:"discover_nodes_interval" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_DISCOVER_NODES_INTERVAL" desc:"Interval for discovering nodes." introductionVersion:"4.0.0"`
EnableMetrics bool `yaml:"enable_metrics" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ENABLE_METRICS" desc:"Enable metrics collection." introductionVersion:"4.0.0"`
EnableDebugLogger bool `yaml:"enable_debug_logger" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ENABLE_DEBUG_LOGGER" desc:"Enable debug logging." introductionVersion:"4.0.0"`
Insecure bool `yaml:"insecure" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_INSECURE" desc:"Skip TLS certificate verification." introductionVersion:"4.0.0"`
}
+11
View File
@@ -0,0 +1,11 @@
package config
import "github.com/qsfera/server/pkg/shared"
// GRPCConfig defines the available grpc configuration.
type GRPCConfig struct {
Disabled bool `yaml:"disabled" env:"SEARCH_GRPC_DISABLED" desc:"Disables the GRPC service. Set this to true if the service should only handle events." introductionVersion:"4.0.0"`
Addr string `yaml:"addr" env:"SEARCH_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
TLS *shared.GRPCServiceTLS `yaml:"tls"`
}
@@ -0,0 +1,49 @@
package parser
import (
"errors"
occfg "github.com/qsfera/server/pkg/config"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/config/defaults"
"github.com/qsfera/server/pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
defaults.Sanitize(cfg)
return Validate(cfg)
}
func Validate(cfg *config.Config) error {
if cfg.TokenManager.JWTSecret == "" {
return shared.MissingJWTTokenError(cfg.Service.Name)
}
if cfg.ServiceAccount.ServiceAccountID == "" {
return shared.MissingServiceAccountID(cfg.Service.Name)
}
if cfg.ServiceAccount.ServiceAccountSecret == "" {
return shared.MissingServiceAccountSecret(cfg.Service.Name)
}
return nil
}
+11
View File
@@ -0,0 +1,11 @@
package config
// Reva defines all available REVA configuration.
type Reva struct {
Address string `yaml:"address" env:"OC_REVA_GATEWAY" desc:"The CS3 gateway endpoint." introductionVersion:"1.0.0"`
}
// TokenManager is the config for using the reva token manager
type TokenManager struct {
JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;SEARCH_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,22 @@
package config
import "time"
// Events combines the configuration options for the event bus.
type Events struct {
Disabled bool `yaml:"disabled" env:"SEARCH_EVENTS_DISABLED" desc:"Disables listening for events. Set this to true if the service should only handle GRPC requests." introductionVersion:"4.0.0"`
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;SEARCH_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"1.0.0"`
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;SEARCH_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Mandatory when using NATS as event system." introductionVersion:"1.0.0"`
AsyncUploads bool `yaml:"async_uploads" env:"OC_ASYNC_UPLOADS;SEARCH_EVENTS_ASYNC_UPLOADS" desc:"Enable asynchronous file uploads." introductionVersion:"1.0.0"`
NumConsumers int `yaml:"num_consumers" env:"SEARCH_EVENTS_NUM_CONSUMERS" desc:"The amount of concurrent event consumers to start. Event consumers are used for searching files. Multiple consumers increase parallelisation, but will also increase CPU and memory demands." introductionVersion:"1.0.0"`
DebounceDuration int `yaml:"debounce_duration" env:"SEARCH_EVENTS_REINDEX_DEBOUNCE_DURATION" desc:"The duration in milliseconds the reindex debouncer waits before triggering a reindex of a space that was modified." introductionVersion:"1.0.0"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;SEARCH_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;SEARCH_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided SEARCH_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"1.0.0"`
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;SEARCH_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;SEARCH_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;SEARCH_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
MaxAckPending int `yaml:"max_ack_pending" env:"SEARCH_EVENTS_MAX_ACK_PENDING" desc:"The maximum number of unacknowledged messages. This is used to limit the number of messages that can be in flight at the same time." introductionVersion:"4.0.0"`
AckWait time.Duration `yaml:"ack_wait" env:"SEARCH_EVENTS_ACK_WAIT" desc:"The time to wait for an ack before the message is redelivered. This is used to ensure that messages are not lost if the consumer crashes." introductionVersion:"4.0.0"`
}
@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
@@ -0,0 +1,61 @@
package content
import (
"context"
"encoding/json"
"time"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/opencloud-eu/reva/v2/pkg/tags"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// Basic is the simplest Extractor implementation.
type Basic struct {
logger log.Logger
}
// NewBasicExtractor creates a new Basic instance.
func NewBasicExtractor(logger log.Logger) (*Basic, error) {
return &Basic{logger: logger}, nil
}
// Extract literally just rearranges the inputs and processes them into a Document.
func (b Basic) Extract(_ context.Context, ri *storageProvider.ResourceInfo) (Document, error) {
doc := Document{
Name: ri.Name,
Size: ri.Size,
MimeType: ri.MimeType,
}
if m := ri.ArbitraryMetadata.GetMetadata(); m != nil {
if t, ok := m["tags"]; ok {
doc.Tags = tags.New(t).AsSlice()
}
}
if m := ri.Opaque.GetMap(); m != nil && m["favorites"] != nil {
favEntry := m["favorites"]
switch favEntry.Decoder {
case "json":
favorites := []string{}
err := json.Unmarshal(favEntry.Value, &favorites)
if err != nil {
b.logger.Error().Err(err).Msg("failed to unmarshal favorites")
break
}
doc.Favorites = favorites
default:
b.logger.Error().Msgf("unsupported decoder for favorites: %s", favEntry.Decoder)
}
}
if ri.Mtime != nil {
doc.Mtime = utils.TSToTime(ri.Mtime).UTC().Format(time.RFC3339Nano)
}
return doc, nil
}
@@ -0,0 +1,112 @@
package content_test
import (
"context"
"encoding/json"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
cs3Types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/content"
)
var _ = Describe("Basic", func() {
var (
basic content.Extractor
logger = log.NewLogger()
ctx = context.TODO()
)
BeforeEach(func() {
basic, _ = content.NewBasicExtractor(logger)
})
Describe("extract", func() {
It("basic fields", func() {
ri := &storageProvider.ResourceInfo{
Name: "bar.pdf",
Path: "./foo/bar.pdf",
Size: 1024,
MimeType: "application/pdf",
}
doc, err := basic.Extract(ctx, ri)
Expect(err).To(BeNil())
Expect(doc).ToNot(BeNil())
Expect(doc.Name).To(Equal(ri.Name))
Expect(doc.Size).To(Equal(ri.Size))
Expect(doc.MimeType).To(Equal(ri.MimeType))
})
It("adds tags", func() {
for _, data := range []struct {
tags string
expect []string
}{
{tags: "", expect: []string{}},
{tags: ",,,", expect: []string{}},
{tags: ",foo,,", expect: []string{"foo"}},
{tags: ",foo,,bar,", expect: []string{"foo", "bar"}},
} {
ri := &storageProvider.ResourceInfo{
ArbitraryMetadata: &storageProvider.ArbitraryMetadata{
Metadata: map[string]string{
"tags": data.tags,
},
},
}
doc, err := basic.Extract(ctx, ri)
Expect(err).To(BeNil())
Expect(doc).ToNot(BeNil())
Expect(doc.Tags).To(Equal(data.expect))
}
})
It("RFC3339 mtime", func() {
for _, data := range []struct {
second uint64
expect string
}{
{second: 4000, expect: "1970-01-01T01:06:40Z"},
{second: 3000, expect: "1970-01-01T00:50:00Z"},
{expect: ""},
} {
ri := &storageProvider.ResourceInfo{}
if data.second != 0 {
ri.Mtime = &cs3Types.Timestamp{Seconds: data.second}
}
doc, err := basic.Extract(ctx, ri)
Expect(err).To(BeNil())
Expect(doc).ToNot(BeNil())
Expect(doc.Mtime).To(Equal(data.expect))
}
})
It("extracts favorites", func() {
favorites := []string{"foo", "bar"}
favBytes, _ := json.Marshal(favorites)
ri := &storageProvider.ResourceInfo{
Opaque: &cs3Types.Opaque{
Map: map[string]*cs3Types.OpaqueEntry{
"favorites": {
Decoder: "json",
Value: favBytes,
},
},
},
}
doc, err := basic.Extract(ctx, ri)
Expect(err).To(BeNil())
Expect(doc).ToNot(BeNil())
Expect(doc.Favorites).To(Equal(favorites))
})
})
})
@@ -0,0 +1,33 @@
package content
import (
"strings"
"github.com/bbalet/stopwords"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
func init() {
stopwords.OverwriteWordSegmenter(`[^ ]+`)
}
// Document wraps all resource meta fields,
// it is used as a content extraction result.
type Document struct {
Title string
Name string
Content string
Size uint64
Mtime string
MimeType string
Tags []string
Favorites []string
Audio *libregraph.Audio `json:"audio,omitempty"`
Image *libregraph.Image `json:"image,omitempty"`
Location *libregraph.GeoCoordinates `json:"location,omitempty"`
Photo *libregraph.Photo `json:"photo,omitempty"`
}
func CleanString(content, langCode string) string {
return strings.TrimSpace(stopwords.CleanString(content, langCode, true))
}
@@ -0,0 +1,13 @@
package content_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestContent(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Content Suite")
}
@@ -0,0 +1,35 @@
package content_test
import (
"testing"
. "github.com/stretchr/testify/assert"
"github.com/qsfera/server/services/search/pkg/content"
)
func TestCleanContent(t *testing.T) {
tests := []struct {
given string
expect string
}{
{
given: "find can keeper should keeper will",
expect: "keeper keeper",
},
{
given: "user1 shares the file to Mary",
expect: "user1 shares file mary",
},
{
given: "content contains https://localhost/remote.php/dav/files/admin/Photos/San%20Francisco.jpg and stop word",
expect: "content contains https://localhost/remote.php/dav/files/admin/photos/san%20francisco.jpg stop word",
},
}
for _, tc := range tests {
t.Run(tc.given, func(t *testing.T) {
Equal(t, tc.expect, content.CleanString(tc.given, "en"))
})
}
}
+87
View File
@@ -0,0 +1,87 @@
package content
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/pkg/log"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
type cs3 struct {
httpClient http.Client
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
logger log.Logger
}
func newCS3Retriever(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], logger log.Logger, insecure bool) cs3 {
return cs3{
httpClient: http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, //nolint:gosec
},
},
gatewaySelector: gatewaySelector,
logger: logger,
}
}
// Retrieve downloads the file from a cs3 service
// The caller MUST make sure to close the returned ReadCloser
func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) {
at, ok := contextGet(ctx, revactx.TokenHeader)
if !ok {
return nil, fmt.Errorf("context without %s", revactx.TokenHeader)
}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
s.logger.Error().Err(err).Msg("could not get reva gatewayClient")
return nil, err
}
res, err := gatewayClient.InitiateFileDownload(ctx, &provider.InitiateFileDownloadRequest{Ref: &provider.Reference{ResourceId: rID, Path: "."}})
if err != nil {
return nil, err
}
if res.Status.Code != rpc.Code_CODE_OK {
return nil, fmt.Errorf("could not load resoure: %s", res.Status.Message)
}
var ep, tt string
for _, p := range res.Protocols {
if p.Protocol == "spaces" {
ep, tt = p.DownloadEndpoint, p.Token
break
}
}
if (ep == "" || tt == "") && len(res.Protocols) > 0 {
ep, tt = res.Protocols[0].DownloadEndpoint, res.Protocols[0].Token
}
req, err := http.NewRequest(http.MethodGet, ep, nil)
if err != nil {
return nil, err
}
req.Header.Set(revactx.TokenHeader, at)
req.Header.Set("X-Reva-Transfer", tt)
cres, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
if cres.StatusCode != http.StatusOK {
return nil, fmt.Errorf("could not download resource. Request returned with statuscode %d ", cres.StatusCode)
}
return cres.Body, nil
}
@@ -0,0 +1,31 @@
package content
import (
"context"
"errors"
"fmt"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
)
// Extractor is responsible to extract content and meta information from documents.
type Extractor interface {
Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, error)
}
func getFirstValue(m map[string][]string, key string) (string, error) {
if m == nil {
return "", errors.New("undefined map")
}
v, ok := m[key]
if !ok {
return "", fmt.Errorf("unknown key: %v", key)
}
if len(m) == 0 {
return "", fmt.Errorf("no values for: %v", key)
}
return v[0], nil
}
@@ -0,0 +1,106 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
"github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/services/search/pkg/content"
mock "github.com/stretchr/testify/mock"
)
// NewExtractor creates a new instance of Extractor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewExtractor(t interface {
mock.TestingT
Cleanup(func())
}) *Extractor {
mock := &Extractor{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Extractor is an autogenerated mock type for the Extractor type
type Extractor struct {
mock.Mock
}
type Extractor_Expecter struct {
mock *mock.Mock
}
func (_m *Extractor) EXPECT() *Extractor_Expecter {
return &Extractor_Expecter{mock: &_m.Mock}
}
// Extract provides a mock function for the type Extractor
func (_mock *Extractor) Extract(ctx context.Context, ri *providerv1beta1.ResourceInfo) (content.Document, error) {
ret := _mock.Called(ctx, ri)
if len(ret) == 0 {
panic("no return value specified for Extract")
}
var r0 content.Document
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceInfo) (content.Document, error)); ok {
return returnFunc(ctx, ri)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceInfo) content.Document); ok {
r0 = returnFunc(ctx, ri)
} else {
r0 = ret.Get(0).(content.Document)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceInfo) error); ok {
r1 = returnFunc(ctx, ri)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Extractor_Extract_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Extract'
type Extractor_Extract_Call struct {
*mock.Call
}
// Extract is a helper method to define mock.On call
// - ctx context.Context
// - ri *providerv1beta1.ResourceInfo
func (_e *Extractor_Expecter) Extract(ctx interface{}, ri interface{}) *Extractor_Extract_Call {
return &Extractor_Extract_Call{Call: _e.mock.On("Extract", ctx, ri)}
}
func (_c *Extractor_Extract_Call) Run(run func(ctx context.Context, ri *providerv1beta1.ResourceInfo)) *Extractor_Extract_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *providerv1beta1.ResourceInfo
if args[1] != nil {
arg1 = args[1].(*providerv1beta1.ResourceInfo)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Extractor_Extract_Call) Return(document content.Document, err error) *Extractor_Extract_Call {
_c.Call.Return(document, err)
return _c
}
func (_c *Extractor_Extract_Call) RunAndReturn(run func(ctx context.Context, ri *providerv1beta1.ResourceInfo) (content.Document, error)) *Extractor_Extract_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,108 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
"io"
"github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
mock "github.com/stretchr/testify/mock"
)
// NewRetriever creates a new instance of Retriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewRetriever(t interface {
mock.TestingT
Cleanup(func())
}) *Retriever {
mock := &Retriever{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Retriever is an autogenerated mock type for the Retriever type
type Retriever struct {
mock.Mock
}
type Retriever_Expecter struct {
mock *mock.Mock
}
func (_m *Retriever) EXPECT() *Retriever_Expecter {
return &Retriever_Expecter{mock: &_m.Mock}
}
// Retrieve provides a mock function for the type Retriever
func (_mock *Retriever) Retrieve(ctx context.Context, rID *providerv1beta1.ResourceId) (io.ReadCloser, error) {
ret := _mock.Called(ctx, rID)
if len(ret) == 0 {
panic("no return value specified for Retrieve")
}
var r0 io.ReadCloser
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId) (io.ReadCloser, error)); ok {
return returnFunc(ctx, rID)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId) io.ReadCloser); ok {
r0 = returnFunc(ctx, rID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(io.ReadCloser)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId) error); ok {
r1 = returnFunc(ctx, rID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Retriever_Retrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retrieve'
type Retriever_Retrieve_Call struct {
*mock.Call
}
// Retrieve is a helper method to define mock.On call
// - ctx context.Context
// - rID *providerv1beta1.ResourceId
func (_e *Retriever_Expecter) Retrieve(ctx interface{}, rID interface{}) *Retriever_Retrieve_Call {
return &Retriever_Retrieve_Call{Call: _e.mock.On("Retrieve", ctx, rID)}
}
func (_c *Retriever_Retrieve_Call) Run(run func(ctx context.Context, rID *providerv1beta1.ResourceId)) *Retriever_Retrieve_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *providerv1beta1.ResourceId
if args[1] != nil {
arg1 = args[1].(*providerv1beta1.ResourceId)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Retriever_Retrieve_Call) Return(readCloser io.ReadCloser, err error) *Retriever_Retrieve_Call {
_c.Call.Return(readCloser, err)
return _c
}
func (_c *Retriever_Retrieve_Call) RunAndReturn(run func(ctx context.Context, rID *providerv1beta1.ResourceId) (io.ReadCloser, error)) *Retriever_Retrieve_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,29 @@
package content
import (
"context"
"io"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"google.golang.org/grpc/metadata"
)
// Retriever is the interface that wraps the basic Retrieve method. 🐕
// It requests and then returns a resource from the underlying storage.
type Retriever interface {
Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error)
}
func contextGet(ctx context.Context, k string) (string, bool) {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
return "", false
}
token, ok := md[k]
if len(token) == 0 || !ok {
return "", false
}
return token[0], ok
}
+304
View File
@@ -0,0 +1,304 @@
package content
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/google/go-tika/tika"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/config"
)
// Tika is used to extract content from a resource,
// it uses apache tika to retrieve all the data.
type Tika struct {
*Basic
Retriever
tika *tika.Client
ContentExtractionSizeLimit uint64
CleanStopWords bool
}
// NewTikaExtractor creates a new Tika instance.
func NewTikaExtractor(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], logger log.Logger, cfg *config.Config) (*Tika, error) {
basic, err := NewBasicExtractor(logger)
if err != nil {
return nil, err
}
tk := tika.NewClient(nil, cfg.Extractor.Tika.TikaURL)
tkv, err := tk.Version(context.Background())
if err != nil {
return nil, err
}
logger.Info().Msgf("Tika version: %s", tkv)
return &Tika{
Basic: basic,
Retriever: newCS3Retriever(gatewaySelector, logger, cfg.Extractor.CS3AllowInsecure),
tika: tika.NewClient(nil, cfg.Extractor.Tika.TikaURL),
ContentExtractionSizeLimit: cfg.ContentExtractionSizeLimit,
CleanStopWords: cfg.Extractor.Tika.CleanStopWords,
}, nil
}
// Extract loads a resource from its underlying storage, passes it to tika and processes the result into a Document.
func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, error) {
doc, err := t.Basic.Extract(ctx, ri)
if err != nil {
return doc, err
}
if ri.Size == 0 {
return doc, nil
}
if ri.Size > t.ContentExtractionSizeLimit {
t.logger.Info().Interface("ResourceID", ri.Id).Str("Name", ri.Name).Msg("file exceeds content extraction size limit. skipping.")
return doc, nil
}
if ri.Type != provider.ResourceType_RESOURCE_TYPE_FILE {
return doc, nil
}
data, err := t.Retrieve(ctx, ri.Id)
if err != nil {
return doc, err
}
defer data.Close()
metas, err := t.tika.MetaRecursive(ctx, data)
if err != nil {
return doc, err
}
for _, meta := range metas {
if title, err := getFirstValue(meta, "title"); err == nil {
doc.Title = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Title, title))
}
if content, err := getFirstValue(meta, "X-TIKA:content"); err == nil {
doc.Content = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Content, content))
}
doc.Location = t.getLocation(meta)
doc.Image = t.getImage(meta)
doc.Photo = t.getPhoto(meta)
if contentType, err := getFirstValue(meta, "Content-Type"); err == nil && strings.HasPrefix(contentType, "audio/") {
doc.Audio = t.getAudio(meta)
}
}
if langCode, _ := t.tika.LanguageString(ctx, doc.Content); langCode != "" && t.CleanStopWords {
doc.Content = CleanString(doc.Content, langCode)
}
return doc, nil
}
func (t Tika) getImage(meta map[string][]string) *libregraph.Image {
var image *libregraph.Image
initImage := func() {
if image == nil {
image = libregraph.NewImage()
}
}
if v, err := getFirstValue(meta, "tiff:ImageWidth"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initImage()
image.SetWidth(int32(i))
}
}
if v, err := getFirstValue(meta, "tiff:ImageLength"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initImage()
image.SetHeight(int32(i))
}
}
return image
}
func (t Tika) getLocation(meta map[string][]string) *libregraph.GeoCoordinates {
var location *libregraph.GeoCoordinates
initLocation := func() {
if location == nil {
location = libregraph.NewGeoCoordinates()
}
}
// TODO: location.Altitute: transform the following data to … feet above sea level.
// "GPS:GPS Altitude": []string{"227.4 metres"},
// "GPS:GPS Altitude Ref": []string{"Sea level"},
if v, err := getFirstValue(meta, "geo:lat"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initLocation()
location.SetLatitude(i)
}
}
if v, err := getFirstValue(meta, "geo:long"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initLocation()
location.SetLongitude(i)
}
}
return location
}
func (t Tika) getPhoto(meta map[string][]string) *libregraph.Photo {
var photo *libregraph.Photo
initPhoto := func() {
if photo == nil {
photo = libregraph.NewPhoto()
}
}
if v, err := getFirstValue(meta, "tiff:Make"); err == nil {
initPhoto()
photo.SetCameraMake(v)
}
if v, err := getFirstValue(meta, "tiff:Model"); err == nil {
initPhoto()
photo.SetCameraModel(v)
}
if v, err := getFirstValue(meta, "exif:FNumber"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetFNumber(i)
}
}
if v, err := getFirstValue(meta, "exif:FocalLength"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetFocalLength(i)
}
}
if v, err := getFirstValue(meta, "Base ISO"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initPhoto()
photo.SetIso(int32(i))
}
}
if v, err := getFirstValue(meta, "tiff:Orientation"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initPhoto()
photo.SetOrientation(int32(i))
}
}
if v, err := getFirstValue(meta, "exif:DateTimeOriginal"); err == nil {
layout := "2006-01-02T15:04:05"
if t, err := time.Parse(layout, v); err == nil {
initPhoto()
photo.SetTakenDateTime(t)
}
}
if v, err := getFirstValue(meta, "exif:ExposureTime"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetExposureNumerator(1)
photo.SetExposureDenominator(math.Round(1 / i))
}
}
return photo
}
func (t Tika) getAudio(meta map[string][]string) *libregraph.Audio {
var audio *libregraph.Audio
initAudio := func() {
if audio == nil {
audio = libregraph.NewAudio()
}
}
if v, err := getFirstValue(meta, "xmpDM:album"); err == nil {
initAudio()
audio.SetAlbum(v)
}
if v, err := getFirstValue(meta, "xmpDM:albumArtist"); err == nil {
initAudio()
audio.SetAlbumArtist(v)
}
if v, err := getFirstValue(meta, "xmpDM:artist"); err == nil {
initAudio()
audio.SetArtist(v)
}
// TODO: audio.Bitrate: not provided by tika
// TODO: audio.Composers: not provided by tika
// TODO: audio.Copyright: not provided by tika for audio files?
if v, err := getFirstValue(meta, "xmpDM:discNumber"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetDisc(int32(i))
}
}
// TODO: audio.DiscCount: not provided by tika
if v, err := getFirstValue(meta, "xmpDM:duration"); err == nil {
// Tika emits fractional seconds.
if f, err := strconv.ParseFloat(v, 64); err == nil {
initAudio()
audio.SetDuration(int64(math.Round(f * 1000)))
}
}
if v, err := getFirstValue(meta, "xmpDM:genre"); err == nil {
initAudio()
audio.SetGenre(v)
}
// TODO: audio.HasDrm: not provided by tika
// TODO: audio.IsVariableBitrate: not provided by tika
if v, err := getFirstValue(meta, "dc:title"); err == nil {
initAudio()
audio.SetTitle(v)
}
if v, err := getFirstValue(meta, "xmpDM:trackNumber"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetTrack(int32(i))
}
}
// TODO: audio.TrackCount: not provided by tika
if v, err := getFirstValue(meta, "xmpDM:releaseDate"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetYear(int32(i))
}
}
return audio
}
@@ -0,0 +1,244 @@
package content_test
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/stretchr/testify/mock"
"github.com/qsfera/server/pkg/log"
conf "github.com/qsfera/server/services/search/pkg/config/defaults"
"github.com/qsfera/server/services/search/pkg/content"
contentMocks "github.com/qsfera/server/services/search/pkg/content/mocks"
)
var _ = Describe("Tika", func() {
Describe("extract", func() {
var (
body string
fullResponse string
language string
version string
srv *httptest.Server
tika *content.Tika
)
BeforeEach(func() {
body = ""
language = ""
version = ""
fullResponse = ""
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
out := ""
switch req.URL.Path {
case "/version":
out = version
case "/language/string":
out = language
case "/rmeta/text":
if fullResponse != "" {
out = fullResponse
} else {
out = fmt.Sprintf(`[{"X-TIKA:content":"%s"}]`, body)
}
}
_, _ = w.Write([]byte(out))
}))
cfg := conf.DefaultConfig()
cfg.Extractor.Tika.TikaURL = srv.URL
cfg.Extractor.Tika.CleanStopWords = true
var err error
tika, err = content.NewTikaExtractor(nil, log.NewLogger(), cfg)
Expect(err).ToNot(HaveOccurred())
Expect(tika).ToNot(BeNil())
retriever := &contentMocks.Retriever{}
retriever.On("Retrieve", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader(body)), nil)
tika.Retriever = retriever
})
AfterEach(func() {
srv.Close()
})
It("skips non file resources", func() {
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{})
Expect(err).ToNot(HaveOccurred())
Expect(doc.Content).To(Equal(""))
})
It("adds content", func() {
body = "any body"
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
Expect(doc.Content).To(Equal(body))
})
It("adds audio content", func() {
fullResponse = `[
{
"xmpDM:genre": "Some Genre",
"xmpDM:album": "Some Album",
"xmpDM:trackNumber": "7",
"xmpDM:discNumber": "4",
"xmpDM:releaseDate": "2004",
"xmpDM:artist": "Some Artist",
"xmpDM:albumArtist": "Some AlbumArtist",
"xmpDM:audioCompressor": "MP3",
"xmpDM:audioChannelType": "Stereo",
"version": "MPEG 3 Layer III Version 1",
"xmpDM:logComment": "some comment",
"xmpDM:audioSampleRate": "44100",
"channels": "2",
"dc:title": "Some Title",
"xmpDM:duration": "225.5",
"Content-Type": "audio/mpeg",
"samplerate": "44100"
}
]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
audio := doc.Audio
Expect(audio).ToNot(BeNil())
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
// Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
// Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
// Expect(audio.Copyright).To(Equal(libregraph.PtrString("Some Copyright")))
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(4)))
// Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225500)))
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
// Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
// Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
Expect(audio.Track).To(Equal(libregraph.PtrInt32(7)))
// Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(9)))
Expect(audio.Year).To(Equal(libregraph.PtrInt32(2004)))
})
It("adds location content", func() {
fullResponse = `[
{
"geo:lat": "49.48675890884328",
"geo:long": "11.103870357204285"
}
]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
location := doc.Location
Expect(location).ToNot(BeNil())
// TODO: Altitude is not supported right now
Expect(location.Altitude).To(BeNil())
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
})
It("adds image content", func() {
fullResponse = `[
{
"tiff:ImageWidth": "100",
"tiff:ImageLength": "100"
}
]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
image := doc.Image
Expect(image).ToNot(BeNil())
Expect(image.Width).To(Equal(libregraph.PtrInt32(100)))
Expect(image.Height).To(Equal(libregraph.PtrInt32(100)))
})
It("adds photo content", func() {
fullResponse = `[
{
"tiff:Make": "Canon",
"tiff:Model": "Canon EOS 5D",
"exif:ExposureTime": "0.001",
"exif:FNumber": "1.8",
"exif:FocalLength": "50",
"Base ISO": "100",
"tiff:Orientation": "1",
"exif:DateTimeOriginal": "2018-01-01T12:34:56"
}
]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
photo := doc.Photo
Expect(photo).ToNot(BeNil())
Expect(photo.CameraMake).To(Equal(libregraph.PtrString("Canon")))
Expect(photo.CameraModel).To(Equal(libregraph.PtrString("Canon EOS 5D")))
Expect(photo.ExposureNumerator).To(Equal(libregraph.PtrFloat64(1)))
Expect(photo.ExposureDenominator).To(Equal(libregraph.PtrFloat64(1000)))
Expect(photo.FNumber).To(Equal(libregraph.PtrFloat64(1.8)))
Expect(photo.FocalLength).To(Equal(libregraph.PtrFloat64(50)))
Expect(photo.Iso).To(Equal(libregraph.PtrInt32(100)))
Expect(photo.Orientation).To(Equal(libregraph.PtrInt32(1)))
Expect(photo.TakenDateTime).To(Equal(libregraph.PtrTime(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))))
})
It("removes stop words", func() {
body = "body to test stop words!!! against almost everyone"
language = "en"
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
Expect(doc.Content).To(Equal("body test stop words!!!"))
})
It("keeps stop words", func() {
body = "body to test stop words!!! against almost everyone"
language = "en"
tika.CleanStopWords = false
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
Expect(doc.Content).To(Equal(body))
})
})
})
@@ -0,0 +1,78 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "qsfera"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "search"
buildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build information",
}, []string{"version"})
eventsOutstandingAcks = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "events_outstanding_acks",
Help: "Number of outstanding acks for events",
})
eventsUnprocessed = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "events_unprocessed",
Help: "Number of unprocessed events",
})
eventsRedelivered = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "events_redelivered",
Help: "Number of redelivered events",
})
searchDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "search_duration_seconds",
Help: "Duration of search operations in seconds",
Buckets: []float64{0.1, 0.5, 1, 2.5, 5, 10, 30, 60},
}, []string{"status"})
indexDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "index_duration_seconds",
Help: "Duration of indexing operations in seconds",
Buckets: []float64{0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600, 1200},
}, []string{"status"})
)
// Metrics defines the available metrics of this service.
type Metrics struct {
// Counter *prometheus.CounterVec
BuildInfo *prometheus.GaugeVec
EventsOutstandingAcks prometheus.Gauge
EventsUnprocessed prometheus.Gauge
EventsRedelivered prometheus.Gauge
SearchDuration *prometheus.HistogramVec
IndexDuration *prometheus.HistogramVec
}
// New initializes the available metrics.
func New() *Metrics {
m := &Metrics{
BuildInfo: buildInfo,
EventsOutstandingAcks: eventsOutstandingAcks,
EventsUnprocessed: eventsUnprocessed,
EventsRedelivered: eventsRedelivered,
SearchDuration: searchDuration,
IndexDuration: indexDuration,
}
return m
}
@@ -0,0 +1,251 @@
package opensearch
import (
"context"
"fmt"
"strings"
"time"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/conversions"
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchService "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/convert"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/search"
)
const defaultBatchSize = 50
var (
ErrUnhealthyCluster = fmt.Errorf("cluster is not healthy")
)
type Backend struct {
index string
client *opensearchgoAPI.Client
}
func NewBackend(index string, client *opensearchgoAPI.Client) (*Backend, error) {
pingResp, err := client.Ping(context.TODO(), &opensearchgoAPI.PingReq{})
switch {
case err != nil:
return nil, fmt.Errorf("%w, failed to ping opensearch: %w", ErrUnhealthyCluster, err)
case pingResp.IsError():
return nil, fmt.Errorf("%w, failed to ping opensearch", ErrUnhealthyCluster)
}
// apply the index template
if err := IndexManagerLatest.Apply(context.TODO(), index, client); err != nil {
return nil, fmt.Errorf("failed to apply index template: %w", err)
}
// first check if the cluster is healthy
resp, err := client.Cluster.Health(context.TODO(), &opensearchgoAPI.ClusterHealthReq{
Indices: []string{index},
Params: opensearchgoAPI.ClusterHealthParams{
Local: opensearchgoAPI.ToPointer(true),
Timeout: 5 * time.Second,
},
})
switch {
case err != nil:
return nil, fmt.Errorf("%w, failed to get cluster health: %w", ErrUnhealthyCluster, err)
case resp.TimedOut:
return nil, fmt.Errorf("%w, cluster health request timed out", ErrUnhealthyCluster)
case resp.Status != "green" && resp.Status != "yellow":
return nil, fmt.Errorf("%w, cluster health is not green or yellow: %s", ErrUnhealthyCluster, resp.Status)
}
return &Backend{index: index, client: client}, nil
}
func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
boolQuery, err := convert.KQLToOpenSearchBoolQuery(sir.Query)
if err != nil {
return nil, fmt.Errorf("failed to convert KQL query to OpenSearch bool query: %w", err)
}
// filter out deleted resources
boolQuery.Filter(
osu.NewTermQuery[bool]("Deleted").Value(false),
)
if sir.Ref != nil {
// if a reference is provided, filter by the root ID
boolQuery.Filter(
osu.NewTermQuery[string]("RootID").Value(
storagespace.FormatResourceID(
&storageProvider.ResourceId{
StorageId: sir.Ref.GetResourceId().GetStorageId(),
SpaceId: sir.Ref.GetResourceId().GetSpaceId(),
OpaqueId: sir.Ref.GetResourceId().GetOpaqueId(),
},
),
),
)
}
searchParams := opensearchgoAPI.SearchParams{
SourceExcludes: []string{"Content"}, // Do not send back the full content in the search response, as it is only needed for highlighting and can be large. The highlighted snippets will be sent back in the response instead.
}
switch {
case sir.PageSize == -1:
searchParams.Size = conversions.ToPointer(1000)
case sir.PageSize == 0:
searchParams.Size = conversions.ToPointer(200)
default:
searchParams.Size = conversions.ToPointer(int(sir.PageSize))
}
req, err := osu.BuildSearchReq(&opensearchgoAPI.SearchReq{
Indices: []string{b.index},
Params: searchParams,
},
boolQuery,
osu.SearchBodyParams{
Highlight: &osu.BodyParamHighlight{
HighlightOptions: osu.HighlightOptions{
NumberOfFragments: 2,
PreTags: []string{"<mark>"},
PostTags: []string{"</mark>"},
},
Fields: map[string]osu.HighlightOptions{
"Content": {
Type: osu.HighlightTypeFvh,
},
},
},
},
)
if err != nil {
return nil, fmt.Errorf("failed to build search request: %w", err)
}
resp, err := b.client.Search(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to search: %w", err)
}
matches := make([]*searchMessage.Match, 0, len(resp.Hits.Hits))
totalMatches := resp.Hits.Total.Value
for _, hit := range resp.Hits.Hits {
match, err := convert.OpenSearchHitToMatch(hit)
if err != nil {
return nil, fmt.Errorf("failed to convert hit to match: %w", err)
}
if sir.Ref != nil {
hitPath := strings.TrimSuffix(match.GetEntity().GetRef().GetPath(), "/")
requestedPath := utils.MakeRelativePath(sir.Ref.Path)
isRoot := hitPath == requestedPath
if !isRoot && requestedPath != "." && !strings.HasPrefix(hitPath, requestedPath+"/") {
totalMatches--
continue
}
}
matches = append(matches, match)
}
return &searchService.SearchIndexResponse{
Matches: matches,
TotalMatches: int32(totalMatches),
}, nil
}
func (b *Backend) DocCount() (uint64, error) {
req, err := osu.BuildIndicesCountReq(
&opensearchgoAPI.IndicesCountReq{
Indices: []string{b.index},
},
osu.NewTermQuery[bool]("Deleted").Value(false),
)
if err != nil {
return 0, fmt.Errorf("failed to build count request: %w", err)
}
resp, err := b.client.Indices.Count(context.TODO(), req)
if err != nil {
return 0, fmt.Errorf("failed to count documents: %w", err)
}
return uint64(resp.Count), nil
}
func (b *Backend) Upsert(id string, r search.Resource) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Upsert(id, r); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) Move(id string, parentID string, target string) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Move(id, parentID, target); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) Delete(id string) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Delete(id); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) Restore(id string) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Restore(id); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) Purge(id string, onlyDeleted bool) error {
batch, err := b.NewBatch(defaultBatchSize)
if err != nil {
return err
}
if err := batch.Purge(id, onlyDeleted); err != nil {
return err
}
return batch.Push()
}
func (b *Backend) NewBatch(size int) (search.BatchOperator, error) {
return NewBatch(b.client, b.index, size)
}
@@ -0,0 +1,283 @@
package opensearch_test
import (
"fmt"
"strings"
"testing"
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/require"
searchService "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/opensearch"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
"github.com/qsfera/server/services/search/pkg/search"
)
func TestNewBackend(t *testing.T) {
t.Run("fails to create if the cluster is not healthy", func(t *testing.T) {
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{
Client: opensearchgo.Config{
Addresses: []string{"http://localhost:1025"},
},
})
require.NoError(t, err, "failed to create OpenSearch client")
backend, err := opensearch.NewBackend("test-engine-new-engine", client)
require.Nil(t, backend)
require.ErrorIs(t, err, opensearch.ErrUnhealthyCluster)
})
}
func TestEngine_Search(t *testing.T) {
indexName := "qsfera-test-engine-search"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
defer tc.Require.IndicesDelete([]string{indexName})
backend, err := opensearch.NewBackend(indexName, tc.Client())
require.NoError(t, err)
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
t.Run("most simple search", func(t *testing.T) {
resp, err := backend.Search(t.Context(), &searchService.SearchIndexRequest{
Query: fmt.Sprintf(`"%s"`, document.Name),
})
require.NoError(t, err)
require.Len(t, resp.Matches, 1)
require.Equal(t, int32(1), resp.TotalMatches)
require.Equal(t, document.ID, fmt.Sprintf("%s$%s!%s", resp.Matches[0].Entity.Id.StorageId, resp.Matches[0].Entity.Id.SpaceId, resp.Matches[0].Entity.Id.OpaqueId))
})
t.Run("ignores files that are marked as deleted", func(t *testing.T) {
deletedDocument := opensearchtest.Testdata.Resources.File
deletedDocument.ID = "1$2!4"
deletedDocument.Deleted = true
tc.Require.DocumentCreate(indexName, deletedDocument.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, deletedDocument)))
tc.Require.IndicesCount([]string{indexName}, nil, 2)
resp, err := backend.Search(t.Context(), &searchService.SearchIndexRequest{
Query: fmt.Sprintf(`"%s"`, document.Name),
})
require.NoError(t, err)
require.Len(t, resp.Matches, 1)
require.Equal(t, int32(1), resp.TotalMatches)
require.Equal(t, document.ID, fmt.Sprintf("%s$%s!%s", resp.Matches[0].Entity.Id.StorageId, resp.Matches[0].Entity.Id.SpaceId, resp.Matches[0].Entity.Id.OpaqueId))
})
}
func TestEngine_Upsert(t *testing.T) {
indexName := "qsfera-test-engine-upsert"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
defer tc.Require.IndicesDelete([]string{indexName})
backend, err := opensearch.NewBackend(indexName, tc.Client())
require.NoError(t, err)
t.Run("upsert with full document", func(t *testing.T) {
document := opensearchtest.Testdata.Resources.File
require.NoError(t, backend.Upsert(document.ID, document))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
})
}
func TestEngine_Move(t *testing.T) {
indexName := "qsfera-test-engine-move"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
defer tc.Require.IndicesDelete([]string{indexName})
backend, err := opensearch.NewBackend(indexName, tc.Client())
require.NoError(t, err)
t.Run("moves the document to a new path", func(t *testing.T) {
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
body := opensearchtest.JSONMustMarshal(t, map[string]any{
"query": map[string]any{
"ids": map[string]any{
"values": []string{document.ID},
},
},
})
resources := opensearchtest.SearchHitsMustBeConverted[search.Resource](t, tc.Require.Search(indexName, strings.NewReader(body)).Hits)
require.Len(t, resources, 1)
require.Equal(t, document.Path, resources[0].Path)
document.Path = "./new/path/to/resource"
require.NoError(t, backend.Move(document.ID, document.ParentID, document.Path))
resources = opensearchtest.SearchHitsMustBeConverted[search.Resource](t, tc.Require.Search(indexName, strings.NewReader(body)).Hits)
require.Len(t, resources, 1)
require.Equal(t, document.Path, resources[0].Path)
})
}
func TestEngine_Delete(t *testing.T) {
indexName := "qsfera-test-engine-delete"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
defer tc.Require.IndicesDelete([]string{indexName})
backend, err := opensearch.NewBackend(indexName, tc.Client())
require.NoError(t, err)
t.Run("mark document as deleted", func(t *testing.T) {
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
body := opensearchtest.JSONMustMarshal(t, map[string]any{
"query": map[string]any{
"term": map[string]any{
"Deleted": map[string]any{
"value": true,
},
},
},
})
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 0)
require.NoError(t, backend.Delete(document.ID))
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 1)
})
}
func TestEngine_Restore(t *testing.T) {
indexName := "qsfera-test-engine-restore"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
defer tc.Require.IndicesDelete([]string{indexName})
backend, err := opensearch.NewBackend(indexName, tc.Client())
require.NoError(t, err)
t.Run("mark document as not deleted", func(t *testing.T) {
document := opensearchtest.Testdata.Resources.File
document.Deleted = true
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
body := opensearchtest.JSONMustMarshal(t, map[string]any{
"query": map[string]any{
"term": map[string]any{
"Deleted": map[string]any{
"value": true,
},
},
},
})
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 1)
require.NoError(t, backend.Restore(document.ID))
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 0)
})
}
func TestEngine_Purge(t *testing.T) {
indexName := "qsfera-test-engine-purge"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
defer tc.Require.IndicesDelete([]string{indexName})
backend, err := opensearch.NewBackend(indexName, tc.Client())
require.NoError(t, err)
t.Run("purge with full document", func(t *testing.T) {
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
require.NoError(t, backend.Purge(document.ID, false))
tc.Require.IndicesCount([]string{indexName}, nil, 0)
})
t.Run("purge resource trees", func(t *testing.T) {
resourceFolder := opensearchtest.Testdata.Resources.Folder
tc.Require.DocumentCreate(indexName, resourceFolder.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, resourceFolder)))
resourceFile := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, resourceFile.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, resourceFile)))
tc.Require.IndicesCount([]string{indexName}, nil, 2)
require.NoError(t, backend.Purge(resourceFolder.ID, false))
tc.Require.IndicesCount([]string{indexName}, nil, 0)
})
t.Run("purge resource trees and ignores undeleted resources", func(t *testing.T) {
resourceFolder := opensearchtest.Testdata.Resources.Folder
tc.Require.DocumentCreate(indexName, resourceFolder.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, resourceFolder)))
resourceFile := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, resourceFile.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, resourceFile)))
tc.Require.IndicesCount([]string{indexName}, nil, 2)
require.NoError(t, backend.Delete(resourceFile.ID))
tc.Require.IndicesRefresh([]string{indexName}, nil)
require.NoError(t, backend.Purge(resourceFolder.ID, true))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
})
}
func TestEngine_DocCount(t *testing.T) {
indexName := "qsfera-test-engine-doc-count"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
defer tc.Require.IndicesDelete([]string{indexName})
backend, err := opensearch.NewBackend(indexName, tc.Client())
require.NoError(t, err)
t.Run("ignore deleted documents", func(t *testing.T) {
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
count, err := backend.DocCount()
require.NoError(t, err)
require.Equal(t, uint64(1), count)
tc.Require.Update(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, map[string]any{
"doc": map[string]any{
"Deleted": true,
},
})))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
count, err = backend.DocCount()
require.NoError(t, err)
require.Equal(t, uint64(0), count)
})
}
@@ -0,0 +1,248 @@
package opensearch
import (
"context"
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"sync"
"github.com/opencloud-eu/reva/v2/pkg/utils"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/qsfera/server/pkg/conversions"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/search"
)
var _ search.BatchOperator = (*Batch)(nil) // ensure Batch implements BatchOperator
type Batch struct {
client *opensearchgoAPI.Client
index string
size int
log log.Logger
operations []any
mu sync.Mutex
}
func NewBatch(client *opensearchgoAPI.Client, index string, size int) (*Batch, error) {
if size <= 0 {
return nil, errors.New("batch size must be greater than 0")
}
return &Batch{
client: client,
size: size,
index: index,
}, nil
}
func (b *Batch) Upsert(id string, r search.Resource) error {
return b.withSizeLimit(func() error {
body, err := conversions.To[map[string]any](r)
if err != nil {
return fmt.Errorf("failed to marshal resource: %w", err)
}
op := func() []map[string]any {
return []map[string]any{
{"index": map[string]any{"_index": b.index, "_id": id}},
body,
}
}
b.mu.Lock()
b.operations = append(b.operations, op)
b.mu.Unlock()
return nil
})
}
func (b *Batch) Move(id, parentID, location string) error {
return b.withSizeLimit(func() error {
op := func() error {
return updateSelfAndDescendants(context.Background(), b.client, b.index, id, func(rootResource search.Resource) *osu.BodyParamScript {
return &osu.BodyParamScript{
Source: `
if (ctx._source.ID == params.id ) { ctx._source.Name = params.newName; ctx._source.ParentID = params.parentID; }
ctx._source.Path = ctx._source.Path.replace(params.oldPath, params.newPath)
`,
Lang: "painless",
Params: map[string]any{
"id": id,
"parentID": parentID,
"oldPath": rootResource.Path,
"newPath": utils.MakeRelativePath(location),
"newName": path.Base(utils.MakeRelativePath(location)),
},
}
})
}
b.mu.Lock()
b.operations = append(b.operations, op)
b.mu.Unlock()
return nil
})
}
func (b *Batch) Delete(id string) error {
return b.withSizeLimit(func() error {
op := func() error {
return updateSelfAndDescendants(context.Background(), b.client, b.index, id, func(_ search.Resource) *osu.BodyParamScript {
return &osu.BodyParamScript{
Source: "ctx._source.Deleted = params.deleted",
Lang: "painless",
Params: map[string]any{
"deleted": true,
},
}
})
}
b.mu.Lock()
b.operations = append(b.operations, op)
b.mu.Unlock()
return nil
})
}
func (b *Batch) Restore(id string) error {
return b.withSizeLimit(func() error {
op := func() error {
return updateSelfAndDescendants(context.Background(), b.client, b.index, id, func(_ search.Resource) *osu.BodyParamScript {
return &osu.BodyParamScript{
Source: "ctx._source.Deleted = params.deleted",
Lang: "painless",
Params: map[string]any{
"deleted": false,
},
}
})
}
b.mu.Lock()
b.operations = append(b.operations, op)
b.mu.Unlock()
return nil
})
}
func (b *Batch) Purge(id string, onlyDeleted bool) error {
return b.withSizeLimit(func() error {
resource, err := searchResourceByID(context.Background(), b.client, b.index, id)
if err != nil {
return fmt.Errorf("failed to get resource: %w", err)
}
query := osu.NewBoolQuery().Must(osu.NewTermQuery[string]("Path").Value(resource.Path))
if onlyDeleted {
query.Must(osu.NewTermQuery[bool]("Deleted").Value(true))
}
req, err := osu.BuildDocumentDeleteByQueryReq(
opensearchgoAPI.DocumentDeleteByQueryReq{
Indices: []string{b.index},
Params: opensearchgoAPI.DocumentDeleteByQueryParams{
WaitForCompletion: conversions.ToPointer(true),
},
},
query,
)
if err != nil {
return fmt.Errorf("failed to build delete by query request: %w", err)
}
op := func() error {
resp, err := b.client.Document.DeleteByQuery(context.TODO(), req)
switch {
case err != nil:
return fmt.Errorf("failed to delete by query: %w", err)
case len(resp.Failures) != 0:
return fmt.Errorf("failed to delete by query, failures: %v", resp.Failures)
}
return nil
}
b.mu.Lock()
b.operations = append(b.operations, op)
b.mu.Unlock()
return nil
})
}
func (b *Batch) Push() error {
b.mu.Lock()
defer b.mu.Unlock()
defer func() { // cleanup
b.operations = nil
}()
var bulkOperations []map[string]any
pushBulkOperations := func() error {
if len(bulkOperations) == 0 {
return nil
}
var body strings.Builder
for _, operation := range bulkOperations {
part, err := json.Marshal(operation)
if err != nil {
return fmt.Errorf("failed to marshal bulk operation: %w", err)
}
body.Write(part)
body.WriteString("\n")
}
if _, err := b.client.Bulk(context.Background(), opensearchgoAPI.BulkReq{
Body: strings.NewReader(body.String()),
}); err != nil {
return fmt.Errorf("failed to execute bulk operations: %w", err)
}
bulkOperations = nil
return nil
}
// keep the order of operations in the batch intact,
// unfortunately, operations like DeleteByQuery cannot be part of the bulk API,
// so we need to push the previous bulk operations before executing such operations
// this might lead to smaller bulks than the configured size, but ensures correct order
for _, operation := range b.operations {
switch op := operation.(type) {
case func() []map[string]any:
bulkOperations = append(bulkOperations, op()...)
case func() error:
if err := pushBulkOperations(); err != nil {
return fmt.Errorf("failed to push operations: %w", err)
}
if err := op(); err != nil {
return fmt.Errorf("failed to execute operation: %w", err)
}
}
}
return pushBulkOperations()
}
func (b *Batch) withSizeLimit(f func() error) error {
if err := f(); err != nil {
return err
}
if len(b.operations) >= b.size {
return b.Push()
}
return nil
}
@@ -0,0 +1,143 @@
package opensearch
import (
"bytes"
"context"
"embed"
"errors"
"fmt"
"path"
"reflect"
"github.com/go-jose/go-jose/v3/json"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/tidwall/gjson"
)
var (
ErrManualActionRequired = errors.New("manual action required")
IndexManagerLatest = IndexIndexManagerResourceV2
IndexIndexManagerResourceV1 IndexManager = "resource_v1.json"
IndexIndexManagerResourceV2 IndexManager = "resource_v2.json"
)
//go:embed internal/indexes/*.json
var indexes embed.FS
type IndexManager string
func (m IndexManager) String() string {
b, err := m.MarshalJSON()
if err != nil {
return ""
}
return string(b)
}
func (m IndexManager) MarshalJSON() ([]byte, error) {
filePath := string(m)
body, err := indexes.ReadFile(path.Join("./internal/indexes", filePath))
switch {
case err != nil:
return nil, fmt.Errorf("failed to read index file %s: %w", filePath, err)
case len(body) <= 0:
return nil, fmt.Errorf("index file %s is empty", filePath)
}
return body, nil
}
func (m IndexManager) Apply(ctx context.Context, name string, client *opensearchgoAPI.Client) error {
localIndexB, err := m.MarshalJSON()
if err != nil {
return fmt.Errorf("failed to marshal index %s: %w", name, err)
}
indicesExistsResp, err := client.Indices.Exists(ctx, opensearchgoAPI.IndicesExistsReq{
Indices: []string{name},
})
switch {
case indicesExistsResp != nil && indicesExistsResp.StatusCode == 404:
break
case err != nil:
return fmt.Errorf("failed to check if index %s exists: %w", name, err)
case indicesExistsResp == nil:
return fmt.Errorf("indicesExistsResp is nil for index %s", name)
}
if indicesExistsResp.StatusCode == 200 {
resp, err := client.Indices.Get(ctx, opensearchgoAPI.IndicesGetReq{
Indices: []string{name},
})
if err != nil {
return fmt.Errorf("failed to get index %s: %w", name, err)
}
remoteIndex, ok := resp.Indices[name]
if !ok {
return fmt.Errorf("index %s not found in response", name)
}
remoteIndexB, err := json.Marshal(remoteIndex)
if err != nil {
return fmt.Errorf("failed to marshal index %s: %w", name, err)
}
localIndexJson := gjson.ParseBytes(localIndexB)
remoteIndexJson := gjson.ParseBytes(remoteIndexB)
compare := func(lvPath, rvPath string) (any, any, bool) {
lv := localIndexJson.Get(lvPath).Raw
rv := remoteIndexJson.Get(rvPath).Raw
var lvv, rvv any
if err := json.Unmarshal([]byte(lv), &lvv); err != nil {
return nil, nil, false
}
if err := json.Unmarshal([]byte(rv), &rvv); err != nil {
return nil, nil, false
}
return lv, rv, reflect.DeepEqual(lvv, rvv)
}
var errs []error
for k := range localIndexJson.Get("settings").Map() {
if lv, rv, ok := compare("settings."+k, "settings.index."+k); !ok {
errs = append(errs, fmt.Errorf("settings.%s local %s, remote %s", k, lv, rv))
}
}
for k := range localIndexJson.Get("mappings.properties").Map() {
if _, _, ok := compare("mappings.properties."+k, "mappings.properties."+k); !ok {
errs = append(errs, fmt.Errorf("mappings.properties.%s", k))
}
}
if errs != nil {
return fmt.Errorf(
"index %s already exists and is different from the requested version, %w: %w",
name,
ErrManualActionRequired,
errors.Join(errs...),
)
}
return nil // Index is already up to date, no action needed
}
createResp, err := client.Indices.Create(ctx, opensearchgoAPI.IndicesCreateReq{
Index: name,
Body: bytes.NewReader(localIndexB),
})
switch {
case err != nil:
return fmt.Errorf("failed to create index %s: %w", name, err)
case !createResp.Acknowledged:
return fmt.Errorf("failed to create index %s: not acknowledged", name)
}
return nil
}
@@ -0,0 +1,63 @@
package opensearch_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/sjson"
"github.com/qsfera/server/services/search/pkg/opensearch"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestIndexManager(t *testing.T) {
t.Run("index plausibility", func(t *testing.T) {
tests := []opensearchtest.TableTest[opensearch.IndexManager, struct{}]{
{
Name: "empty",
Got: opensearch.IndexManagerLatest,
},
}
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
indexName := "qsfera-test-resource"
tc.Require.IndicesReset([]string{indexName})
body, err := test.Got.MarshalJSON()
require.NoError(t, err)
require.NotEmpty(t, body)
require.NotEmpty(t, test.Got.String())
require.JSONEq(t, test.Got.String(), string(body))
require.NoError(t, test.Got.Apply(t.Context(), indexName, tc.Client()))
})
}
})
t.Run("does not create index if it already exists and is up to date", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "qsfera-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCreate(indexName, strings.NewReader(indexManager.String()))
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client()))
})
t.Run("fails to create index if it already exists but is not up to date", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "qsfera-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Set(indexManager.String(), "settings.number_of_shards", "2")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client()), opensearch.ErrManualActionRequired)
})
}
@@ -0,0 +1,197 @@
package convert
import (
"fmt"
"reflect"
"slices"
"strings"
"github.com/qsfera/server/pkg/ast"
)
func ExpandKQL(nodes []ast.Node) ([]ast.Node, error) {
return kqlExpander{}.expand(nodes, "")
}
type kqlExpander struct{}
func (e kqlExpander) expand(nodes []ast.Node, defaultKey string) ([]ast.Node, error) {
for i, node := range nodes {
rnode := reflect.ValueOf(node)
// we need to ensure that the node is a pointer to an ast.Node in every case
if rnode.Kind() != reflect.Ptr {
ptr := reflect.New(rnode.Type())
ptr.Elem().Set(rnode)
rnode = ptr
cnode, ok := rnode.Interface().(ast.Node)
if !ok {
return nil, fmt.Errorf("expected node to be of type ast.Node, got %T", rnode.Interface())
}
node = cnode // Update the original node to the pointer
nodes[i] = node // Update the original slice with the pointer
}
var unfoldedNodes []ast.Node
switch cnode := node.(type) {
case *ast.GroupNode:
if cnode.Key != "" { // group nodes should not get a default key
cnode.Key = e.remapKey(cnode.Key, defaultKey)
}
groupNodes, err := e.expand(cnode.Nodes, cnode.Key)
if err != nil {
return nil, err
}
cnode.Nodes = groupNodes
case *ast.StringNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
cnode.Value = e.lowerValue(cnode.Key, cnode.Value)
unfoldedNodes = e.unfoldValue(cnode.Key, cnode.Value)
case *ast.DateTimeNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
case *ast.BooleanNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
}
if unfoldedNodes != nil {
// Insert unfolded nodes at the current index
nodes = append(nodes[:i], append(unfoldedNodes, nodes[i+1:]...)...)
// Adjust index to account for new nodes
i += len(unfoldedNodes) - 1
}
}
return nodes, nil
}
func (_ kqlExpander) remapKey(current string, defaultKey string) string {
if defaultKey == "" {
defaultKey = "Name" // Set a default key if none is provided
}
key, ok := map[string]string{
"": defaultKey, // Default case if current is empty
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
}[current]
if !ok {
return current // Return the original key if not found
}
return key
}
func (_ kqlExpander) lowerValue(key, value string) string {
if slices.Contains([]string{"Hidden"}, key) {
return value // ignore certain keys and return the original value
}
return strings.ToLower(value)
}
func (_ kqlExpander) unfoldValue(key, value string) []ast.Node {
result, ok := map[string][]ast.Node{
"MimeType:file": {
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: key, Value: "httpd/unix-directory"},
},
"MimeType:folder": {
&ast.StringNode{Key: key, Value: "httpd/unix-directory"},
},
"MimeType:document": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/msword"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.form"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.text"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/plain"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/markdown"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/rtf"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.pages"},
}},
},
"MimeType:spreadsheet": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/vnd.ms-excel"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.spreadsheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/csv"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.spreadshee"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.numbers"},
}},
},
"MimeType:presentation": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.ms-powerpoint"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.keynote"},
}},
},
"MimeType:pdf": {
&ast.StringNode{Key: key, Value: "application/pdf"},
},
"MimeType:image": {
&ast.StringNode{Key: key, Value: "image/*"},
},
"MimeType:video": {
&ast.StringNode{Key: key, Value: "video/*"},
},
"MimeType:audio": {
&ast.StringNode{Key: key, Value: "audio/*"},
},
"MimeType:archive": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/zip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-7z-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-rar-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-tar"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-bzip2"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-bzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-tgz"},
}},
},
}[fmt.Sprintf("%s:%s", key, value)]
if !ok {
return nil
}
return result
}
@@ -0,0 +1,607 @@
package convert_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/convert"
"github.com/qsfera/server/pkg/ast"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestExpandKQLAST(t *testing.T) {
t.Run("always converts a value node to a pointer node", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "ast.node.V -> ast.node.PTR",
Got: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: "c"},
&ast.OperatorNode{Value: "OR"},
ast.DateTimeNode{Key: "d"},
ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: "f"},
&ast.OperatorNode{Value: "NOT"},
ast.BooleanNode{Key: "g"},
ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
}},
}},
}},
ast.GroupNode{Key: "i", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "OR"},
ast.GroupNode{Key: "h", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "OR"},
ast.GroupNode{Key: "h", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
}},
}},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: "c"},
&ast.OperatorNode{Value: "OR"},
&ast.DateTimeNode{Key: "d"},
&ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: "f"},
&ast.OperatorNode{Value: "NOT"},
&ast.BooleanNode{Key: "g"},
&ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
}},
}},
}},
&ast.GroupNode{Key: "i", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
}},
}},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("remaps some keys", func(t *testing.T) {
var tests []opensearchtest.TableTest[[]ast.Node, []ast.Node]
for k, v := range map[string]string{
"": "Name", // Default to "Name" if no key is provided
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
"any": "any", // Example of an unknown key that should remain unchanged
} {
tests = append(tests, opensearchtest.TableTest[[]ast.Node, []ast.Node]{
Name: fmt.Sprintf("%s -> %s", k, v),
Got: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: k},
&ast.OperatorNode{Value: "OR"},
ast.DateTimeNode{Key: k},
ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: k},
&ast.OperatorNode{Value: "NOT"},
ast.BooleanNode{Key: k},
ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
}},
}},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.DateTimeNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: v},
&ast.OperatorNode{Value: "NOT"},
&ast.BooleanNode{Key: v},
&ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
}},
}},
}},
},
})
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("lowercases some values", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "!Hidden: StringNode -> stringnode",
Got: []ast.Node{
ast.StringNode{Key: "aBc", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "aBc", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "stringnode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "stringnode"},
}},
},
},
{
Name: "Hidden: StringNode -> StringNode",
Got: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("unfolds some values", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "MimeType:unknown",
Got: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "unknown"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: "some-name"},
},
Want: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "unknown"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:file",
Got: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "file"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: "some-name"},
},
Want: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:folder",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "folder"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:document",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "document"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/msword"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.form"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.text"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/plain"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/markdown"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/rtf"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.pages"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:spreadsheet",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "spreadsheet"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/vnd.ms-excel"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.spreadsheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/csv"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.spreadshee"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.numbers"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:presentation",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "presentation"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.ms-powerpoint"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.keynote"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:pdf",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "pdf"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "application/pdf"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:image",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "image"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "image/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:video",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "video"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "video/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:audio",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "audio"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "audio/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:archive",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "archive"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/zip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-7z-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-rar-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-tar"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-bzip2"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-bzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-tgz"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
if test.Skip {
t.Skip("Skipping test due to known issue")
}
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.EqualValues(t, test.Want, result)
})
}
})
t.Run("different cases", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "use the group node key as default key",
Got: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "a", Nodes: []ast.Node{
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "mediatype", Nodes: []ast.Node{
&ast.StringNode{Value: "file"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "mediatype", Value: "file"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
},
Want: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "a", Nodes: []ast.Node{
&ast.StringNode{Key: "a", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "MimeType", Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
if test.Skip {
t.Skip("Skipping test due to known issue")
}
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.EqualValues(t, test.Want, result)
})
}
})
}
@@ -0,0 +1,35 @@
package convert
import (
"fmt"
"github.com/qsfera/server/pkg/kql"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
)
var (
ErrUnsupportedNodeType = fmt.Errorf("unsupported node type")
)
func KQLToOpenSearchBoolQuery(kqlQuery string) (*osu.BoolQuery, error) {
kqlAst, err := kql.Builder{}.Build(kqlQuery)
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
kqlNodes, err := ExpandKQL(kqlAst.Nodes)
if err != nil {
return nil, fmt.Errorf("failed to expand KQL AST nodes: %w", err)
}
builder, err := TranspileKQLToOpenSearch(kqlNodes)
if err != nil {
return nil, fmt.Errorf("failed to compile query: %w", err)
}
if q, ok := builder.(*osu.BoolQuery); !ok {
return osu.NewBoolQuery().Must(builder), nil
} else {
return q, nil
}
}
@@ -0,0 +1 @@
package convert_test
@@ -0,0 +1,147 @@
package convert
import (
"errors"
"fmt"
"strings"
"time"
"github.com/qsfera/server/pkg/ast"
"github.com/qsfera/server/pkg/kql"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
)
func TranspileKQLToOpenSearch(nodes []ast.Node) (osu.Builder, error) {
return kqlOpensearchTranspiler{}.Transpile(nodes)
}
type kqlOpensearchTranspiler struct{}
func (t kqlOpensearchTranspiler) Transpile(nodes []ast.Node) (osu.Builder, error) {
q, err := t.transpile(nodes)
if err != nil {
return nil, err
}
return q, nil
}
func (t kqlOpensearchTranspiler) transpile(nodes []ast.Node) (osu.Builder, error) {
if len(nodes) == 0 {
return nil, fmt.Errorf("no nodes to compile")
}
if len(nodes) == 1 {
builder, err := t.toBuilder(nodes[0])
if err != nil {
return nil, fmt.Errorf("failed to get builder for single node: %w", err)
}
return builder, nil
}
boolQueryParams := &osu.BoolQueryParams{}
boolQuery := osu.NewBoolQuery().Params(boolQueryParams)
boolQueryAdd := boolQuery.Must
for i, node := range nodes {
nextOp := t.getOperatorValueAt(nodes, i+1)
prevOp := t.getOperatorValueAt(nodes, i-1)
switch {
case nextOp == kql.BoolOR:
boolQueryAdd = boolQuery.Should
case nextOp == kql.BoolAND:
boolQueryAdd = boolQuery.Must
case prevOp == kql.BoolNOT:
boolQueryAdd = boolQuery.MustNot
}
builder, err := t.toBuilder(node)
switch {
// if the node is not known, we skip it, such as an operator node
case errors.Is(err, ErrUnsupportedNodeType):
continue
case err != nil:
return nil, fmt.Errorf("failed to get builder for node %T: %w", node, err)
}
if _, ok := node.(*ast.OperatorNode); ok {
// operatorNodes are not builders, so we skip them
continue
}
if nextOp == kql.BoolOR {
// if there are should clauses, we set the minimum should match to 1
boolQueryParams.MinimumShouldMatch = 1
}
boolQueryAdd(builder)
}
return boolQuery, nil
}
func (t kqlOpensearchTranspiler) getOperatorValueAt(nodes []ast.Node, i int) string {
if i < 0 || i >= len(nodes) {
return ""
}
if opn, ok := nodes[i].(*ast.OperatorNode); ok {
return opn.Value
}
return ""
}
func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
var builder osu.Builder
switch node := node.(type) {
case *ast.BooleanNode:
return osu.NewTermQuery[bool](node.Key).Value(node.Value), nil
case *ast.StringNode:
isWildcard := strings.Contains(node.Value, "*")
if isWildcard {
return osu.NewWildcardQuery(node.Key).Value(node.Value), nil
}
totalTerms := strings.Split(node.Value, " ")
isSingleTerm := len(totalTerms) == 1
isMultiTerm := len(totalTerms) >= 1
switch {
case isSingleTerm:
return osu.NewTermQuery[string](node.Key).Value(node.Value), nil
case isMultiTerm:
return osu.NewMatchPhraseQuery(node.Key).Query(node.Value), nil
}
return nil, fmt.Errorf("unsupported string node value: %s", node.Value)
case *ast.DateTimeNode:
if node.Operator == nil {
return builder, fmt.Errorf("date time node without operator: %w", ErrUnsupportedNodeType)
}
query := osu.NewRangeQuery[time.Time](node.Key)
switch node.Operator.Value {
case ">":
return query.Gt(node.Value), nil
case ">=":
return query.Gte(node.Value), nil
case "<":
return query.Lt(node.Value), nil
case "<=":
return query.Lte(node.Value), nil
}
return nil, fmt.Errorf("unsupported operator %s for date time node: %w", node.Operator.Value, ErrUnsupportedNodeType)
case *ast.GroupNode:
group, err := t.transpile(node.Nodes)
if err != nil {
return nil, fmt.Errorf("failed to build group: %w", err)
}
return group, nil
}
return nil, fmt.Errorf("%w: %T", ErrUnsupportedNodeType, node)
}
@@ -0,0 +1,378 @@
package convert_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/qsfera/server/pkg/ast"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/convert"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestTranspileKQLToOpenSearch(t *testing.T) {
tests := []opensearchtest.TableTest[*ast.Ast, osu.Builder]{
// kql to os dsl - type tests
{
Name: "term query - string node",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "qsfera"},
},
},
Want: osu.NewTermQuery[string]("Name").Value("qsfera"),
},
{
Name: "term query - boolean node - true",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: true},
},
},
Want: osu.NewTermQuery[bool]("Deleted").Value(true),
},
{
Name: "term query - boolean node - false",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
},
},
Want: osu.NewTermQuery[bool]("Deleted").Value(false),
},
{
Name: "match-phrase query - string node",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "open cloud"},
},
},
Want: osu.NewMatchPhraseQuery("Name").Query(`open cloud`),
},
{
Name: "wildcard query - string node",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "open*"},
},
},
Want: osu.NewWildcardQuery("Name").Value("open*"),
},
{
Name: "bool query",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "a"},
&ast.StringNode{Key: "Name", Value: "b"},
}},
},
},
Want: osu.NewBoolQuery().Must(
osu.NewTermQuery[string]("Name").Value("a"),
osu.NewTermQuery[string]("Name").Value("b"),
),
},
{
Name: "no bool query for single term",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "any"},
}},
},
},
Want: osu.NewTermQuery[string]("Name").Value("any"),
},
{
Name: "range query >",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.DateTimeNode{
Key: "Mtime",
Operator: &ast.OperatorNode{Value: ">"},
Value: opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
},
},
Want: osu.NewRangeQuery[time.Time]("Mtime").Gt(opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00")),
},
{
Name: "range query >=",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.DateTimeNode{
Key: "Mtime",
Operator: &ast.OperatorNode{Value: ">="},
Value: opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
},
},
Want: osu.NewRangeQuery[time.Time]("Mtime").Gte(opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00")),
},
{
Name: "range query <",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.DateTimeNode{
Key: "Mtime",
Operator: &ast.OperatorNode{Value: "<"},
Value: opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
},
},
Want: osu.NewRangeQuery[time.Time]("Mtime").Lt(opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00")),
},
{
Name: "range query <=",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.DateTimeNode{
Key: "Mtime",
Operator: &ast.OperatorNode{Value: "<="},
Value: opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
},
},
Want: osu.NewRangeQuery[time.Time]("Mtime").Lte(opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00")),
},
// kql to os dsl - structure tests
{
Name: "[*]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "qsfera"},
},
},
Want: osu.NewTermQuery[string]("Name").Value("qsfera"),
},
{
Name: "[* *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "qsfera"},
&ast.StringNode{Key: "age", Value: "32"},
},
},
Want: osu.NewBoolQuery().
Must(
osu.NewTermQuery[string]("Name").Value("qsfera"),
osu.NewTermQuery[string]("age").Value("32"),
),
},
{
Name: "[* AND *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "qsfera"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "age", Value: "32"},
},
},
Want: osu.NewBoolQuery().
Must(
osu.NewTermQuery[string]("Name").Value("qsfera"),
osu.NewTermQuery[string]("age").Value("32"),
),
},
{
Name: "[* OR *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "qsfera"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "age", Value: "32"},
},
},
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewTermQuery[string]("Name").Value("qsfera"),
osu.NewTermQuery[string]("age").Value("32"),
),
},
{
Name: "[NOT *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "age", Value: "32"},
},
},
Want: osu.NewBoolQuery().
MustNot(
osu.NewTermQuery[string]("age").Value("32"),
),
},
{
Name: "[* NOT *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "qsfera"},
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "age", Value: "32"},
},
},
Want: osu.NewBoolQuery().
Must(
osu.NewTermQuery[string]("Name").Value("qsfera"),
).
MustNot(
osu.NewTermQuery[string]("age").Value("32"),
),
},
{
Name: "[* OR * OR *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "qsfera"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "age", Value: "32"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "age", Value: "44"},
},
},
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewTermQuery[string]("Name").Value("qsfera"),
osu.NewTermQuery[string]("age").Value("32"),
osu.NewTermQuery[string]("age").Value("44"),
),
},
{
Name: "[* AND * OR *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "a", Value: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "c"},
},
},
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Must(
osu.NewTermQuery[string]("a").Value("a"),
).
Should(
osu.NewTermQuery[string]("b").Value("b"),
osu.NewTermQuery[string]("c").Value("c"),
),
},
{
Name: "[* OR * AND *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "a", Value: "a"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "b", Value: "b"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "c", Value: "c"},
},
},
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Must(
osu.NewTermQuery[string]("b").Value("b"),
osu.NewTermQuery[string]("c").Value("c"),
).
Should(
osu.NewTermQuery[string]("a").Value("a"),
),
},
{
Name: "[* OR * AND *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "a", Value: "a"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "b", Value: "b"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "c", Value: "c"},
},
},
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewTermQuery[string]("a").Value("a"),
).
Must(
osu.NewTermQuery[string]("b").Value("b"),
osu.NewTermQuery[string]("c").Value("c"),
),
},
{
Name: "[[* OR * OR *] AND *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "a", Value: "a"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "b", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "c"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "d", Value: "d"},
},
},
Want: osu.NewBoolQuery().
Must(
osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewTermQuery[string]("a").Value("a"),
osu.NewTermQuery[string]("b").Value("b"),
osu.NewTermQuery[string]("c").Value("c"),
),
osu.NewTermQuery[string]("d").Value("d"),
),
},
{
Name: "[[* OR * OR *] AND NOT *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "a", Value: "a"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "b", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "c"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "d", Value: "d"},
},
},
Want: osu.NewBoolQuery().
Must(
osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewTermQuery[string]("a").Value("a"),
osu.NewTermQuery[string]("b").Value("b"),
osu.NewTermQuery[string]("c").Value("c"),
),
).MustNot(
osu.NewTermQuery[string]("d").Value("d"),
),
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
if test.Skip {
t.Skip("skipping test: " + test.Name)
}
dsl, err := convert.TranspileKQLToOpenSearch(test.Got.Nodes)
assert.NoError(t, err)
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, dsl))
})
}
}
@@ -0,0 +1,99 @@
package convert
import (
"fmt"
"strings"
"time"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/qsfera/server/pkg/conversions"
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
"github.com/qsfera/server/services/search/pkg/search"
)
func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, error) {
resource, err := conversions.To[search.Resource](hit.Source)
if err != nil {
return nil, fmt.Errorf("failed to convert hit source: %w", err)
}
resourceRootID, err := storagespace.ParseID(resource.RootID)
if err != nil {
return nil, err
}
resourceID, err := storagespace.ParseID(resource.ID)
if err != nil {
return nil, err
}
resourceParentID, _ := storagespace.ParseID(resource.ParentID)
match := &searchMessage.Match{
Score: hit.Score,
Entity: &searchMessage.Entity{
Ref: &searchMessage.Reference{
ResourceId: &searchMessage.ResourceID{
StorageId: resourceRootID.GetStorageId(),
SpaceId: resourceRootID.GetSpaceId(),
OpaqueId: resourceRootID.GetOpaqueId(),
},
Path: resource.Path,
},
Id: &searchMessage.ResourceID{
StorageId: resourceID.GetStorageId(),
SpaceId: resourceID.GetSpaceId(),
OpaqueId: resourceID.GetOpaqueId(),
},
Name: resource.Name,
ParentId: &searchMessage.ResourceID{
StorageId: resourceParentID.GetStorageId(),
SpaceId: resourceParentID.GetSpaceId(),
OpaqueId: resourceParentID.GetOpaqueId(),
},
Size: resource.Size,
Type: resource.Type,
MimeType: resource.MimeType,
Deleted: resource.Deleted,
Tags: resource.Tags,
Highlights: func() string {
contentHighlights, ok := hit.Highlight["Content"]
if !ok {
return ""
}
return strings.Join(contentHighlights[:], "; ")
}(),
Audio: func() *searchMessage.Audio {
if !strings.HasPrefix(resource.MimeType, "audio/") {
return nil
}
audio, _ := conversions.To[*searchMessage.Audio](resource.Audio)
return audio
}(),
Image: func() *searchMessage.Image {
image, _ := conversions.To[*searchMessage.Image](resource.Image)
return image
}(),
Location: func() *searchMessage.GeoCoordinates {
geoCoordinates, _ := conversions.To[*searchMessage.GeoCoordinates](resource.Location)
return geoCoordinates
}(),
Photo: func() *searchMessage.Photo {
photo, _ := conversions.To[*searchMessage.Photo](resource.Photo)
return photo
}(),
},
}
if mtime, err := time.Parse(time.RFC3339, resource.Mtime); err == nil {
match.Entity.LastModifiedTime = &timestamppb.Timestamp{Seconds: mtime.Unix(), Nanos: int32(mtime.Nanosecond())}
}
return match, nil
}
@@ -0,0 +1,38 @@
package convert_test
import (
"encoding/json"
"testing"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/assert"
"github.com/qsfera/server/pkg/conversions"
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/convert"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestOpenSearchHitToMatch(t *testing.T) {
resource := opensearchtest.Testdata.Resources.File
resource.MimeType = "audio/anything"
hit := opensearchgoAPI.SearchHit{
Score: 1.1,
Source: json.RawMessage(opensearchtest.JSONMustMarshal(t, resource)),
}
match, err := convert.OpenSearchHitToMatch(hit)
assert.NoError(t, err)
assert.Equal(t, hit.Score, match.Score)
assert.Equal(t, resource.Name, match.Entity.Name)
t.Parallel()
t.Run("converts the audio field to the expected type", func(t *testing.T) {
// searchMessage.Audio contains int64, int32 ... values that are converted to strings by the JSON marshaler,
// so we need to convert the resource.Audio to align the expectations for the JSON comparison.
audio, err := conversions.To[*searchMessage.Audio](resource.Audio)
assert.NoError(t, err)
assert.Equal(t, resource.Audio.Bitrate, match.Entity.Audio.Bitrate)
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, audio), opensearchtest.JSONMustMarshal(t, match.Entity.Audio))
})
}
@@ -0,0 +1,49 @@
{
"settings": {
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": {
"analyzer": {
"path_hierarchy": {
"filter": [
"lowercase"
],
"tokenizer": "path_hierarchy",
"type": "custom"
}
},
"tokenizer": {
"path_hierarchy": {
"type": "path_hierarchy"
}
}
}
},
"mappings": {
"properties": {
"ID": {
"type": "keyword"
},
"ParentID": {
"type": "keyword"
},
"RootID": {
"type": "keyword"
},
"MimeType": {
"type": "wildcard",
"doc_values": false
},
"Path": {
"type": "text",
"analyzer": "path_hierarchy"
},
"Deleted": {
"type": "boolean"
},
"Hidden": {
"type": "boolean"
}
}
}
}
@@ -0,0 +1,56 @@
{
"settings": {
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": {
"analyzer": {
"path_hierarchy": {
"filter": [
"lowercase"
],
"tokenizer": "path_hierarchy",
"type": "custom"
}
},
"tokenizer": {
"path_hierarchy": {
"type": "path_hierarchy"
}
}
}
},
"mappings": {
"properties": {
"Content": {
"type": "text",
"term_vector": "with_positions_offsets"
},
"ID": {
"type": "keyword"
},
"ParentID": {
"type": "keyword"
},
"RootID": {
"type": "keyword"
},
"MimeType": {
"type": "wildcard",
"doc_values": false
},
"Path": {
"type": "text",
"analyzer": "path_hierarchy"
},
"Deleted": {
"type": "boolean"
},
"Hidden": {
"type": "boolean"
},
"Favorites": {
"type": "keyword"
}
}
}
}
@@ -0,0 +1,128 @@
package osu
import (
"encoding/json"
"fmt"
"reflect"
"dario.cat/mergo"
"github.com/qsfera/server/pkg/conversions"
)
type Builder interface {
json.Marshaler
Map() (map[string]any, error)
}
func newBase(v ...any) (map[string]any, error) {
base := make(map[string]any)
for _, value := range v {
data, err := conversions.To[map[string]any](value)
if err != nil {
return nil, fmt.Errorf("failed to convert value to map: %w", err)
}
if isEmpty(data) {
continue
}
if err := mergo.Merge(&base, data); err != nil {
return nil, fmt.Errorf("failed to merge value into base: %w", err)
}
}
return base, nil
}
func applyValue[T any](target map[string]any, key string, v T) {
if target == nil || isEmpty(key) || isEmpty(v) {
return
}
target[key] = v
}
func applyValues[T any](target map[string]any, values map[string]T) {
if target == nil || isEmpty(values) {
return
}
for k, v := range values {
applyValue[T](target, k, v)
}
}
func applyBuilder(target map[string]any, key string, builder Builder) error {
if target == nil || isEmpty(key) || isEmpty(builder) {
return nil
}
data, err := builder.Map()
if err != nil {
return fmt.Errorf("failed to map builder %s: %w", key, err)
}
if !isEmpty(data) {
target[key] = data
}
return nil
}
func applyBuilders(target map[string]any, key string, bs ...Builder) error {
if target == nil || isEmpty(key) || isEmpty(bs) {
return nil
}
builders := make([]map[string]any, 0, len(bs))
for _, builder := range bs {
data, err := builder.Map()
switch {
case err != nil:
return fmt.Errorf("failed to map builder %s: %w", key, err)
case isEmpty(data):
continue
default:
builders = append(builders, data)
}
}
if len(builders) > 0 {
target[key] = builders
}
return nil
}
func isEmpty(x any) bool {
switch {
case x == nil:
return true
case reflect.ValueOf(x).Kind() == reflect.Bool:
return false
case reflect.DeepEqual(x, reflect.Zero(reflect.TypeOf(x)).Interface()):
return true
case reflect.ValueOf(x).Kind() == reflect.Map && reflect.ValueOf(x).Len() == 0:
return true
default:
return false
}
}
func merge[T any](vals ...T) T {
base := make(map[string]any)
for _, val := range vals {
data, err := conversions.To[map[string]any](val)
if err != nil {
continue
}
_ = mergo.Merge(&base, data)
}
data, _ := conversions.To[T](base)
return data
}
@@ -0,0 +1,87 @@
package osu
import (
"encoding/json"
)
type BoolQuery struct {
must []Builder
mustNot []Builder
should []Builder
filter []Builder
params *BoolQueryParams
}
type BoolQueryParams struct {
MinimumShouldMatch int16 `json:"minimum_should_match,omitempty"`
Boost float32 `json:"boost,omitempty"`
Name string `json:"_name,omitempty"`
}
func NewBoolQuery() *BoolQuery {
return &BoolQuery{}
}
func (q *BoolQuery) Params(v *BoolQueryParams) *BoolQuery {
q.params = v
return q
}
func (q *BoolQuery) Must(v ...Builder) *BoolQuery {
q.must = append(q.must, v...)
return q
}
func (q *BoolQuery) MustNot(v ...Builder) *BoolQuery {
q.mustNot = append(q.mustNot, v...)
return q
}
func (q *BoolQuery) Should(v ...Builder) *BoolQuery {
q.should = append(q.should, v...)
return q
}
func (q *BoolQuery) Filter(v ...Builder) *BoolQuery {
q.filter = append(q.filter, v...)
return q
}
func (q *BoolQuery) Map() (map[string]any, error) {
base, err := newBase(q.params)
if err != nil {
return nil, err
}
if err := applyBuilders(base, "must", q.must...); err != nil {
return nil, err
}
if err := applyBuilders(base, "must_not", q.mustNot...); err != nil {
return nil, err
}
if err := applyBuilders(base, "should", q.should...); err != nil {
return nil, err
}
if err := applyBuilders(base, "filter", q.filter...); err != nil {
return nil, err
}
if isEmpty(base) {
return nil, nil
}
return map[string]any{
"bool": base,
}, nil
}
func (q *BoolQuery) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
@@ -0,0 +1,158 @@
package osu_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestBoolQuery(t *testing.T) {
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
{
Name: "empty",
Got: osu.NewBoolQuery(),
Want: nil,
},
{
Name: "with params",
Got: osu.NewBoolQuery().Params(&osu.BoolQueryParams{
MinimumShouldMatch: 10,
Boost: 10,
Name: "some-name",
}),
Want: map[string]any{
"bool": map[string]any{
"minimum_should_match": 10,
"boost": 10,
"_name": "some-name",
},
},
},
{
Name: "must",
Got: osu.NewBoolQuery().Must(osu.NewTermQuery[string]("name").Value("tom")),
Want: map[string]any{
"bool": map[string]any{
"must": []map[string]any{
{
"term": map[string]any{
"name": map[string]any{
"value": "tom",
},
},
},
},
},
},
},
{
Name: "must_not",
Got: osu.NewBoolQuery().MustNot(osu.NewTermQuery[string]("name").Value("tom")),
Want: map[string]any{
"bool": map[string]any{
"must_not": []map[string]any{
{
"term": map[string]any{
"name": map[string]any{
"value": "tom",
},
},
},
},
},
},
},
{
Name: "should",
Got: osu.NewBoolQuery().Should(osu.NewTermQuery[string]("name").Value("tom")),
Want: map[string]any{
"bool": map[string]any{
"should": []map[string]any{
{
"term": map[string]any{
"name": map[string]any{
"value": "tom",
},
},
},
},
},
},
},
{
Name: "filter",
Got: osu.NewBoolQuery().Filter(osu.NewTermQuery[string]("name").Value("tom")),
Want: map[string]any{
"bool": map[string]any{
"filter": []map[string]any{
{
"term": map[string]any{
"name": map[string]any{
"value": "tom",
},
},
},
},
},
},
},
{
Name: "full",
Got: osu.NewBoolQuery().
Must(osu.NewTermQuery[string]("name").Value("tom")).
MustNot(osu.NewTermQuery[bool]("deleted").Value(true)).
Should(osu.NewTermQuery[string]("gender").Value("male")).
Filter(osu.NewTermQuery[int]("age").Value(42)),
Want: map[string]any{
"bool": map[string]any{
"must": []map[string]any{
{
"term": map[string]any{
"name": map[string]any{
"value": "tom",
},
},
},
},
"must_not": []map[string]any{
{
"term": map[string]any{
"deleted": map[string]any{
"value": true,
},
},
},
},
"should": []map[string]any{
{
"term": map[string]any{
"gender": map[string]any{
"value": "male",
},
},
},
},
"filter": []map[string]any{
{
"term": map[string]any{
"age": map[string]any{
"value": 42,
},
},
},
},
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
})
}
}
@@ -0,0 +1,58 @@
package osu
import (
"encoding/json"
)
type MatchPhraseQuery struct {
field string
query string
params *MatchPhraseQueryParams
}
type MatchPhraseQueryParams struct {
Analyzer string `json:"analyzer,omitempty"`
Slop int `json:"slop,omitempty"`
ZeroTermsQuery string `json:"zero_terms_query,omitempty"`
}
func NewMatchPhraseQuery(field string) *MatchPhraseQuery {
return &MatchPhraseQuery{field: field}
}
func (q *MatchPhraseQuery) Params(v *MatchPhraseQueryParams) *MatchPhraseQuery {
q.params = v
return q
}
func (q *MatchPhraseQuery) Query(v string) *MatchPhraseQuery {
q.query = v
return q
}
func (q *MatchPhraseQuery) Map() (map[string]any, error) {
base, err := newBase(q.params)
if err != nil {
return nil, err
}
applyValue(base, "query", q.query)
if isEmpty(base) {
return nil, nil
}
return map[string]any{
"match_phrase": map[string]any{
q.field: base,
},
}, nil
}
func (q *MatchPhraseQuery) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
@@ -0,0 +1,72 @@
package osu_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestNewMatchPhraseQuery(t *testing.T) {
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
{
Name: "empty",
Got: osu.NewMatchPhraseQuery("empty"),
Want: nil,
},
{
Name: "with params",
Got: osu.NewMatchPhraseQuery("name").Params(&osu.MatchPhraseQueryParams{
Analyzer: "analyzer",
Slop: 2,
ZeroTermsQuery: "all",
}),
Want: map[string]any{
"match_phrase": map[string]any{
"name": map[string]any{
"analyzer": "analyzer",
"slop": 2,
"zero_terms_query": "all",
},
},
},
},
{
Name: "query",
Got: osu.NewMatchPhraseQuery("name").Query("some match query"),
Want: map[string]any{
"match_phrase": map[string]any{
"name": map[string]any{
"query": "some match query",
},
},
},
},
{
Name: "full",
Got: osu.NewMatchPhraseQuery("name").Params(&osu.MatchPhraseQueryParams{
Analyzer: "analyzer",
Slop: 2,
ZeroTermsQuery: "all",
}).Query("some match query"),
Want: map[string]any{
"match_phrase": map[string]any{
"name": map[string]any{
"query": "some match query",
"analyzer": "analyzer",
"slop": 2,
"zero_terms_query": "all",
},
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
})
}
}
@@ -0,0 +1,49 @@
package osu
import (
"encoding/json"
"slices"
)
type IDsQuery struct {
values []string
params *IDsQueryParams
}
type IDsQueryParams struct {
Boost float32 `json:"boost,omitempty"`
}
func NewIDsQuery(v ...string) *IDsQuery {
return &IDsQuery{values: slices.Compact(v)}
}
func (q *IDsQuery) Params(v *IDsQueryParams) *IDsQuery {
q.params = v
return q
}
func (q *IDsQuery) Map() (map[string]any, error) {
base, err := newBase(q.params)
if err != nil {
return nil, err
}
applyValue(base, "values", q.values)
if isEmpty(base) {
return nil, nil
}
return map[string]any{
"ids": base,
}, nil
}
func (q *IDsQuery) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
@@ -0,0 +1,45 @@
package osu_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestIDsQuery(t *testing.T) {
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
{
Name: "empty",
Got: osu.NewIDsQuery(),
Want: nil,
},
{
Name: "no params",
Got: osu.NewIDsQuery("1", "2", "3", "3"),
Want: map[string]any{
"ids": map[string]any{
"values": []string{"1", "2", "3"},
},
},
},
{
Name: "ids",
Got: osu.NewIDsQuery("1", "2", "3", "3").Params(&osu.IDsQueryParams{Boost: 1.0}),
Want: map[string]any{
"ids": map[string]any{
"values": []string{"1", "2", "3"},
"boost": 1.0,
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
})
}
}
@@ -0,0 +1,92 @@
package osu
import (
"encoding/json"
"errors"
"time"
)
type RangeQuery[T time.Time | string] struct {
field string
gt T
gte T
lt T
lte T
params *RangeQueryParams
}
type RangeQueryParams struct {
Format string `json:"format,omitempty"`
Relation string `json:"relation,omitempty"`
Boost float32 `json:"boost,omitempty"`
TimeZone string `json:"time_zone,omitempty"`
}
func NewRangeQuery[T time.Time | string](field string) *RangeQuery[T] {
return &RangeQuery[T]{field: field}
}
func (q *RangeQuery[T]) Params(v *RangeQueryParams) *RangeQuery[T] {
q.params = v
return q
}
func (q *RangeQuery[T]) Gt(v T) *RangeQuery[T] {
q.gt = v
return q
}
func (q *RangeQuery[T]) Gte(v T) *RangeQuery[T] {
q.gte = v
return q
}
func (q *RangeQuery[T]) Lt(v T) *RangeQuery[T] {
q.lt = v
return q
}
func (q *RangeQuery[T]) Lte(v T) *RangeQuery[T] {
q.lte = v
return q
}
func (q *RangeQuery[T]) Map() (map[string]any, error) {
if !isEmpty(q.gt) && !isEmpty(q.gte) {
return nil, errors.New("cannot set both gt and gte in RangeQuery")
}
if !isEmpty(q.lt) && !isEmpty(q.lte) {
return nil, errors.New("cannot set both lt and lte in RangeQuery")
}
base, err := newBase(q.params)
if err != nil {
return nil, err
}
applyValues(base, map[string]T{
"gt": q.gt,
"gte": q.gte,
"lt": q.lt,
"lte": q.lte,
})
if isEmpty(base) {
return nil, nil
}
return map[string]any{
"range": map[string]any{
q.field: base,
},
}, nil
}
func (q *RangeQuery[T]) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
@@ -0,0 +1,164 @@
package osu_test
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestRangeQuery(t *testing.T) {
now := time.Now()
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
{
Name: "empty",
Got: osu.NewRangeQuery[string]("empty"),
Want: nil,
},
{
Name: "gt string",
Got: osu.NewRangeQuery[string]("created").Gt("2023-01-01T00:00:00Z"),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"gt": "2023-01-01T00:00:00Z",
},
},
},
},
{
Name: "gt time",
Got: osu.NewRangeQuery[time.Time]("created").Gt(now),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"gt": now,
},
},
},
},
{
Name: "gte string",
Got: osu.NewRangeQuery[string]("created").Gte("2023-01-01T00:00:00Z"),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"gte": "2023-01-01T00:00:00Z",
},
},
},
},
{
Name: "gte time",
Got: osu.NewRangeQuery[time.Time]("created").Gte(now),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"gte": now,
},
},
},
},
{
Name: "gt & gte",
Got: osu.NewRangeQuery[time.Time]("created").Gt(now).Gte(now),
Want: nil,
Err: errors.New(""),
},
{
Name: "gt string",
Got: osu.NewRangeQuery[string]("created").Lt("2023-01-01T00:00:00Z"),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"lt": "2023-01-01T00:00:00Z",
},
},
},
},
{
Name: "lt time",
Got: osu.NewRangeQuery[time.Time]("created").Lt(now),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"lt": now,
},
},
},
},
{
Name: "lte string",
Got: osu.NewRangeQuery[string]("created").Lte("2023-01-01T00:00:00Z"),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"lte": "2023-01-01T00:00:00Z",
},
},
},
},
{
Name: "lte time",
Got: osu.NewRangeQuery[time.Time]("created").Lte(now),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"lte": now,
},
},
},
},
{
Name: "lt & lte",
Got: osu.NewRangeQuery[time.Time]("created").Lt(now).Lte(now),
Want: nil,
Err: errors.New(""),
},
{
Name: "with params",
Got: osu.NewRangeQuery[time.Time]("created").Params(&osu.RangeQueryParams{
Format: "strict_date_optional_time",
Relation: "within",
Boost: 1.0,
TimeZone: "UTC",
}).Lte(now).Gte(now),
Want: map[string]any{
"range": map[string]any{
"created": map[string]any{
"lte": now,
"gte": now,
"format": "strict_date_optional_time",
"relation": "within",
"boost": 1.0,
"time_zone": "UTC",
},
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
got, err := test.Got.MarshalJSON()
switch {
case test.Err != nil && test.Err.Error() == "": // Expecting any error
assert.Error(t, err)
assert.Nil(t, got)
return
case test.Err != nil && test.Err.Error() != "": // Expecting a specific error
assert.ErrorIs(t, test.Err, err)
assert.Nil(t, got)
return
}
require.NoError(t, err)
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), string(got))
})
}
}
@@ -0,0 +1,58 @@
package osu
import (
"encoding/json"
)
type TermQuery[T comparable] struct {
field string
value T
params *TermQueryParams
}
type TermQueryParams struct {
Boost float32 `json:"boost,omitempty"`
CaseInsensitive bool `json:"case_insensitive,omitempty"`
Name string `json:"_name,omitempty"`
}
func NewTermQuery[T comparable](field string) *TermQuery[T] {
return &TermQuery[T]{field: field}
}
func (q *TermQuery[T]) Params(v *TermQueryParams) *TermQuery[T] {
q.params = v
return q
}
func (q *TermQuery[T]) Value(v T) *TermQuery[T] {
q.value = v
return q
}
func (q *TermQuery[T]) Map() (map[string]any, error) {
base, err := newBase(q.params)
if err != nil {
return nil, err
}
applyValue(base, "value", q.value)
if isEmpty(base) {
return nil, nil
}
return map[string]any{
"term": map[string]any{
q.field: base,
},
}, nil
}
func (q *TermQuery[T]) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
@@ -0,0 +1,55 @@
package osu_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestTermQuery(t *testing.T) {
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
{
Name: "empty",
Got: osu.NewTermQuery[string]("empty"),
Want: nil,
},
{
Name: "no params",
Got: osu.NewTermQuery[bool]("deleted").Value(false),
Want: map[string]any{
"term": map[string]any{
"deleted": map[string]any{
"value": false,
},
},
},
},
{
Name: "with params",
Got: osu.NewTermQuery[bool]("deleted").Params(&osu.TermQueryParams{
Boost: 1.0,
CaseInsensitive: true,
Name: "is-deleted",
}).Value(true),
Want: map[string]any{
"term": map[string]any{
"deleted": map[string]any{
"value": true,
"boost": 1.0,
"case_insensitive": true,
"_name": "is-deleted",
},
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
})
}
}
@@ -0,0 +1,58 @@
package osu
import (
"encoding/json"
)
type WildcardQuery struct {
field string
value string
params *WildcardQueryParams
}
type WildcardQueryParams struct {
Boost float32 `json:"boost,omitempty"`
CaseInsensitive bool `json:"case_insensitive,omitempty"`
Rewrite string `json:"rewrite,omitempty"`
}
func NewWildcardQuery(field string) *WildcardQuery {
return &WildcardQuery{field: field}
}
func (q *WildcardQuery) Params(v *WildcardQueryParams) *WildcardQuery {
q.params = v
return q
}
func (q *WildcardQuery) Value(v string) *WildcardQuery {
q.value = v
return q
}
func (q *WildcardQuery) Map() (map[string]any, error) {
base, err := newBase(q.params)
if err != nil {
return nil, err
}
applyValue(base, "value", q.value)
if isEmpty(base) {
return nil, nil
}
return map[string]any{
"wildcard": map[string]any{
q.field: base,
},
}, nil
}
func (q *WildcardQuery) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
@@ -0,0 +1,44 @@
package osu_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestWildcardQuery(t *testing.T) {
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
{
Name: "empty",
Got: osu.NewWildcardQuery("empty"),
Want: nil,
},
{
Name: "wildcard",
Got: osu.NewWildcardQuery("name").Params(&osu.WildcardQueryParams{
Boost: 1.0,
CaseInsensitive: true,
Rewrite: "top_terms_blended_freqs_N",
}).Value("opencl*"),
Want: map[string]any{
"wildcard": map[string]any{
"name": map[string]any{
"value": "opencl*",
"boost": 1.0,
"case_insensitive": true,
"rewrite": "top_terms_blended_freqs_N",
},
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
})
}
}
@@ -0,0 +1,135 @@
package osu
import (
"bytes"
"encoding/json"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
)
type QueryReqBody[P any] struct {
query Builder
params P
}
func NewQueryReqBody[P any](q Builder, p ...P) *QueryReqBody[P] {
return &QueryReqBody[P]{query: q, params: merge(p...)}
}
func (q QueryReqBody[O]) Map() (map[string]any, error) {
base, err := newBase(q.params)
if err != nil {
return nil, err
}
if err := applyBuilder(base, "query", q.query); err != nil {
return nil, err
}
return base, nil
}
func (q QueryReqBody[O]) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
//----------------------------------------------------------------------------//
type BodyParamHighlight struct {
HighlightOptions
Fields map[string]HighlightOptions `json:"fields,omitempty"`
}
type HighlightType string
const (
HighlightTypeUnified HighlightType = "unified"
HighlightTypeFvh HighlightType = "fvh"
HighlightTypePlain HighlightType = "plain"
HighlightTypeSemantic HighlightType = "semantic"
)
type HighlightOptions struct {
Type HighlightType `json:"type,omitempty"`
FragmentSize int `json:"fragment_size,omitempty"`
NumberOfFragments int `json:"number_of_fragments,omitempty"`
FragmentOffset int `json:"fragment_offset,omitempty"`
BoundaryChars string `json:"boundary_chars,omitempty"`
BoundaryMaxScan int `json:"boundary_max_scan,omitempty"`
BoundaryScanner string `json:"boundary_scanner,omitempty"`
BoundaryScannerLocale string `json:"boundary_scanner_locale,omitempty"`
Encoder string `json:"encoder,omitempty"`
ForceSource bool `json:"force_source,omitempty"`
Fragmenter string `json:"fragmenter,omitempty"`
HighlightQuery Builder `json:"highlight_query,omitempty"`
Order string `json:"order,omitempty"`
NoMatchSize int `json:"no_match_size,omitempty"`
RequireFieldMatch bool `json:"require_field_match,omitempty"`
MatchedFields []string `json:"matched_fields,omitempty"`
PhraseLimit int `json:"phrase_limit,omitempty"`
PreTags []string `json:"pre_tags,omitempty"`
PostTags []string `json:"post_tags,omitempty"`
}
type BodyParamScript struct {
Source string `json:"source,omitempty"`
Lang string `json:"lang,omitempty"`
Params map[string]any `json:"params,omitempty"`
}
//----------------------------------------------------------------------------//
func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyParams) (*opensearchgoAPI.SearchReq, error) {
body, err := json.Marshal(NewQueryReqBody(q, p...))
if err != nil {
return nil, err
}
req.Body = bytes.NewReader(body)
return req, nil
}
type SearchBodyParams struct {
Highlight *BodyParamHighlight `json:"highlight,omitempty"`
}
//----------------------------------------------------------------------------//
func BuildDocumentDeleteByQueryReq(req opensearchgoAPI.DocumentDeleteByQueryReq, q Builder) (opensearchgoAPI.DocumentDeleteByQueryReq, error) {
body, err := json.Marshal(NewQueryReqBody[any](q))
if err != nil {
return req, err
}
req.Body = bytes.NewReader(body)
return req, nil
}
//----------------------------------------------------------------------------//
func BuildUpdateByQueryReq(req opensearchgoAPI.UpdateByQueryReq, q Builder, o ...UpdateByQueryBodyParams) (opensearchgoAPI.UpdateByQueryReq, error) {
body, err := json.Marshal(NewQueryReqBody(q, o...))
if err != nil {
return req, err
}
req.Body = bytes.NewReader(body)
return req, nil
}
type UpdateByQueryBodyParams struct {
Script *BodyParamScript `json:"script,omitempty"`
}
//----------------------------------------------------------------------------//
func BuildIndicesCountReq(req *opensearchgoAPI.IndicesCountReq, q Builder) (*opensearchgoAPI.IndicesCountReq, error) {
body, err := json.Marshal(NewQueryReqBody[any](q))
if err != nil {
return nil, err
}
req.Body = bytes.NewReader(body)
return req, nil
}
@@ -0,0 +1,94 @@
package osu_test
import (
"io"
"testing"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
opensearchtest "github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
func TestRequestBody(t *testing.T) {
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
{
Name: "simple",
Got: osu.NewQueryReqBody[any](osu.NewTermQuery[string]("name").Value("tom")),
Want: map[string]any{
"query": map[string]any{
"term": map[string]any{
"name": map[string]any{
"value": "tom",
},
},
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
})
}
}
func TestBuildSearchReq(t *testing.T) {
tests := []opensearchtest.TableTest[io.Reader, map[string]any]{
{
Name: "highlight",
Got: func() io.Reader {
req, _ := osu.BuildSearchReq(
&opensearchgoAPI.SearchReq{},
osu.NewTermQuery[string]("content").Value("content"),
osu.SearchBodyParams{
Highlight: &osu.BodyParamHighlight{
HighlightOptions: osu.HighlightOptions{
PreTags: []string{"<b>"},
PostTags: []string{"</b>"},
},
Fields: map[string]osu.HighlightOptions{
"content": {
PreTags: []string{"<strong>"},
PostTags: []string{"</strong>"},
},
},
},
},
)
return req.Body
}(),
Want: map[string]any{
"query": map[string]any{
"term": map[string]any{
"content": map[string]any{
"value": "content",
},
},
},
"highlight": map[string]any{
"pre_tags": []string{"<b>"},
"post_tags": []string{"</b>"},
"fields": map[string]any{
"content": map[string]any{
"pre_tags": []string{"<strong>"},
"post_tags": []string{"</strong>"},
},
},
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
body, err := io.ReadAll(test.Got)
require.NoError(t, err)
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), string(body))
})
}
}
@@ -0,0 +1,36 @@
package opensearchtest
import (
"encoding/json"
"testing"
"time"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/pkg/conversions"
)
var TimeMustParse = func(t *testing.T, ts string) time.Time {
tp, err := time.Parse(time.RFC3339Nano, ts)
require.NoError(t, err, "failed to parse time %s", ts)
return tp
}
func JSONMustMarshal(t *testing.T, data any) string {
jsonData, err := json.Marshal(data)
require.NoError(t, err, "failed to marshal data to JSON")
return string(jsonData)
}
func SearchHitsMustBeConverted[T any](t *testing.T, hits []opensearchgoAPI.SearchHit) []T {
ts := make([]T, len(hits))
for i, hit := range hits {
resource, err := conversions.To[T](hit.Source)
require.NoError(t, err)
ts[i] = resource
}
return ts
}
@@ -0,0 +1,256 @@
package opensearchtest
import (
"context"
"errors"
"fmt"
"io"
"slices"
"testing"
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/services/search/pkg/config"
)
type TestClient struct {
c *opensearchgoAPI.Client
Require *testRequireClient
}
func NewDefaultTestClient(t *testing.T, cfg config.EngineOpenSearchClient) *TestClient {
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{
Client: opensearchgo.Config{
Addresses: cfg.Addresses,
Username: cfg.Username,
Password: cfg.Password,
},
})
require.NoError(t, err, "failed to create OpenSearch client")
return NewTestClient(t, client)
}
func NewTestClient(t *testing.T, client *opensearchgoAPI.Client) *TestClient {
tc := &TestClient{c: client}
trc := &testRequireClient{tc: tc, t: t}
tc.Require = trc
return tc
}
func (tc *TestClient) Client() *opensearchgoAPI.Client {
return tc.c
}
func (tc *TestClient) IndicesReset(ctx context.Context, indices []string) error {
indicesToDelete := make([]string, 0, len(indices))
for _, index := range indices {
exist, err := tc.IndicesExists(ctx, []string{index})
if err != nil {
return fmt.Errorf("failed to check if index %s exists: %w", index, err)
}
if !exist {
continue
}
indicesToDelete = append(indicesToDelete, index)
}
if len(indicesToDelete) == 0 {
// If no indices to delete, return nil
return nil
}
return tc.IndicesDelete(ctx, indicesToDelete)
}
func (tc *TestClient) IndicesExists(ctx context.Context, indices []string) (bool, error) {
if err := tc.IndicesRefresh(ctx, indices, []int{404}); err != nil {
return false, err
}
resp, err := tc.c.Indices.Exists(ctx, opensearchgoAPI.IndicesExistsReq{
Indices: indices,
})
switch {
case resp != nil && resp.StatusCode == 404:
return false, nil
case err != nil:
return false, fmt.Errorf("failed to check if indices exist: %w", err)
case resp != nil && resp.IsError():
return false, fmt.Errorf("failed to check if indices exist: %s", resp.String())
default:
return true, nil
}
}
func (tc *TestClient) IndicesRefresh(ctx context.Context, indices []string, allow []int) error {
resp, err := tc.c.Indices.Refresh(ctx, &opensearchgoAPI.IndicesRefreshReq{
Indices: indices,
})
isAllowed := resp != nil
isAllowed = isAllowed && resp.Inspect().Response != nil
isAllowed = isAllowed && slices.Contains(allow, resp.Inspect().Response.StatusCode)
if err != nil && !isAllowed {
return fmt.Errorf("failed to refresh indices %v: %w", indices, err)
}
return nil
}
func (tc *TestClient) IndicesDelete(ctx context.Context, indices []string) error {
if err := tc.IndicesRefresh(ctx, indices, []int{}); err != nil {
return err
}
resp, err := tc.c.Indices.Delete(ctx, opensearchgoAPI.IndicesDeleteReq{
Indices: indices,
})
switch {
case err != nil:
return fmt.Errorf("failed to delete indices: %w", err)
case !resp.Acknowledged:
return errors.New("indices deletion not acknowledged")
default:
return nil
}
}
func (tc *TestClient) IndicesCreate(ctx context.Context, index string, body io.Reader) error {
resp, err := tc.c.Indices.Create(ctx, opensearchgoAPI.IndicesCreateReq{
Index: index,
Body: body,
})
switch {
case err != nil:
return fmt.Errorf("failed to create index %s: %w", index, err)
case !resp.Acknowledged:
return fmt.Errorf("index creation not acknowledged for %s", index)
default:
return nil
}
}
func (tc *TestClient) IndicesCount(ctx context.Context, indices []string, body io.Reader) (int, error) {
if err := tc.IndicesRefresh(ctx, indices, []int{404}); err != nil {
return 0, err
}
resp, err := tc.c.Indices.Count(ctx, &opensearchgoAPI.IndicesCountReq{
Indices: indices,
Body: body,
})
switch {
case err != nil:
return 0, fmt.Errorf("failed to count documents in indices: %w", err)
default:
return resp.Count, nil
}
}
func (tc *TestClient) DocumentCreate(ctx context.Context, index, id string, body io.Reader) error {
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
return err
}
_, err := tc.c.Document.Create(ctx, opensearchgoAPI.DocumentCreateReq{
Index: index,
DocumentID: id,
Body: body,
})
switch {
case err != nil:
return fmt.Errorf("failed to create document in index %s: %w", index, err)
default:
return nil
}
}
func (tc *TestClient) Update(ctx context.Context, index, id string, body io.Reader) error {
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
return err
}
_, err := tc.c.Update(ctx, opensearchgoAPI.UpdateReq{
Index: index,
DocumentID: id,
Body: body,
})
switch {
case err != nil:
return fmt.Errorf("failed to update document in index %s: %w", index, err)
default:
return nil
}
}
func (tc *TestClient) Search(ctx context.Context, index string, body io.Reader) (opensearchgoAPI.SearchHits, error) {
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
return opensearchgoAPI.SearchHits{}, err
}
resp, err := tc.c.Search(ctx, &opensearchgoAPI.SearchReq{
Indices: []string{index},
Body: body,
})
if err != nil {
return opensearchgoAPI.SearchHits{}, fmt.Errorf("failed to search in index %s: %w", index, err)
}
return resp.Hits, nil
}
type testRequireClient struct {
tc *TestClient
t *testing.T
}
func (trc *testRequireClient) IndicesReset(indices []string) {
require.NoError(trc.t, trc.tc.IndicesReset(trc.t.Context(), indices))
}
func (trc *testRequireClient) IndicesRefresh(indices []string, ignore []int) {
require.NoError(trc.t, trc.tc.IndicesRefresh(trc.t.Context(), indices, ignore))
}
func (trc *testRequireClient) IndicesCreate(index string, body io.Reader) {
require.NoError(trc.t, trc.tc.IndicesCreate(trc.t.Context(), index, body))
}
func (trc *testRequireClient) IndicesDelete(indices []string) {
require.NoError(trc.t, trc.tc.IndicesDelete(trc.t.Context(), indices))
}
func (trc *testRequireClient) IndicesCount(indices []string, body io.Reader, expected int) {
count, err := trc.tc.IndicesCount(trc.t.Context(), indices, body)
switch {
case expected <= 0:
require.True(trc.t, count <= 0, "expected indices to have no documents, but got a count of %d", count)
default:
require.Equal(trc.t, expected, count, "expected indices to have %d documents, but got %d", expected, count)
require.NoError(trc.t, err, "expected indices to have documents, but got an error")
}
}
func (trc *testRequireClient) DocumentCreate(index, id string, body io.Reader) {
require.NoError(trc.t, trc.tc.DocumentCreate(trc.t.Context(), index, id, body))
}
func (trc *testRequireClient) Update(index, id string, body io.Reader) {
require.NoError(trc.t, trc.tc.Update(trc.t.Context(), index, id, body))
}
func (trc *testRequireClient) Search(index string, body io.Reader) opensearchgoAPI.SearchHits {
hits, err := trc.tc.Search(trc.t.Context(), index, body)
require.NoError(trc.t, err)
return hits
}
@@ -0,0 +1,99 @@
package opensearchtest
import (
"context"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/opensearch"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/config/defaults"
"github.com/qsfera/server/services/search/pkg/config/parser"
)
const (
openSearchImage = "opensearchproject/opensearch:2"
)
func SetupTests(ctx context.Context) (*config.Config, func(), error) {
cfg, err := setupDefaultConfig()
if err != nil {
return nil, nil, fmt.Errorf("failed to setup default configuration: %w", err)
}
cleanupOpenSearch, err := setupOpenSearchTestContainer(ctx, cfg)
if err != nil {
return nil, nil, fmt.Errorf("failed to setup OpenSearch test container: %w", err)
}
return cfg, cleanupOpenSearch, nil
}
func setupDefaultConfig() (*config.Config, error) {
cfg := defaults.DefaultConfig()
defaults.EnsureDefaults(cfg)
{ // parser.Validate requires these fields to be set even if they are not used in these tests.
cfg.TokenManager.JWTSecret = "any-jwt"
cfg.ServiceAccount.ServiceAccountID = "any-service-account-id"
cfg.ServiceAccount.ServiceAccountSecret = "any-service-account-secret"
}
if err := parser.ParseConfig(cfg); err != nil {
return nil, fmt.Errorf("failed to parse default configuration: %w", err)
}
return cfg, nil
}
func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func(), error) {
usesExternalOpensearch := slices.Contains([]bool{
os.Getenv("CI") == "woodpecker",
os.Getenv("CI_SYSTEM_NAME") == "woodpecker",
os.Getenv("USE_TESTCONTAINERS") == "false",
}, true)
if usesExternalOpensearch {
return func() {}, nil
}
containerName := fmt.Sprintf("qsfera/test/%s", openSearchImage)
containerName = strings.Replace(containerName, "/", "__", -1)
containerName = strings.Replace(containerName, ":", "_", -1)
container, err := opensearch.Run(
ctx,
openSearchImage,
opensearch.WithUsername(cfg.Engine.OpenSearch.Client.Username),
opensearch.WithPassword(cfg.Engine.OpenSearch.Client.Password),
testcontainers.WithName(containerName),
testcontainers.WithReuseByName(containerName),
testcontainers.WithWaitStrategy(
wait.ForLog("ML configuration initialized successfully").
WithStartupTimeout(5*time.Second),
),
)
if err != nil {
return nil, fmt.Errorf("failed to start OpenSearch container: %w", err)
}
address, err := container.Address(ctx)
if err != nil {
_ = container.Terminate(ctx) // attempt to clean up the container
return nil, fmt.Errorf("failed to get OpenSearch container address: %w", err)
}
// Ensure the address is set in the default configuration.
cfg.Engine.OpenSearch.Client.Addresses = []string{address}
return func() {
err := container.Terminate(ctx)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to terminate OpenSearch container: %v\n", err)
}
}, nil
}
@@ -0,0 +1,9 @@
package opensearchtest
type TableTest[G any, W any] struct {
Name string
Got G
Want W
Err error
Skip bool
}
@@ -0,0 +1,44 @@
package opensearchtest
import (
"embed"
"encoding/json"
"fmt"
"path"
"github.com/qsfera/server/services/search/pkg/search"
)
//go:embed testdata/*.json
var testdata embed.FS
var Testdata = struct {
Resources resourceTestdata
}{
Resources: resourceTestdata{
Root: fromTestData[search.Resource]("resource_root.json"),
Folder: fromTestData[search.Resource]("resource_folder.json"),
File: fromTestData[search.Resource]("resource_file.json"),
},
}
type resourceTestdata struct {
Root search.Resource
File search.Resource
Folder search.Resource
}
func fromTestData[D any](name string) D {
name = path.Join("./testdata", name)
data, err := testdata.ReadFile(name)
if err != nil {
panic(fmt.Sprintf("failed to read testdata file %s: %v", name, err))
}
var d D
if json.Unmarshal(data, &d) != nil {
panic(fmt.Sprintf("failed to unmarshal testdata %s: %v", name, err))
}
return d
}
@@ -0,0 +1,54 @@
{
"ID" : "1$1!3",
"RootID" : "1$1!1",
"ParentID" : "1$1!2",
"Path" : "./parent d!r/child.jpg",
"Type" : 1,
"Title" : "dumme title",
"Name" : "dummy name",
"Content" : "dummy content",
"Size" : 42,
"Mtime" : "2025-07-24 15:15:01.324093 +0200 CEST m=+0.000056251",
"MimeType" : "image/jpeg",
"Tags" : [ "dummy" ],
"Deleted" : false,
"Hidden" : false,
"audio" : {
"album" : "Some Album",
"albumArtist" : "Some AlbumArtist",
"artist" : "Some Artist",
"bitrate" : 192,
"composers" : "Some Composers",
"copyright" : "",
"disc" : 2,
"discCount" : 5,
"duration" : 225000,
"genre" : "Some Genre",
"hasDrm" : false,
"isVariableBitrate" : true,
"title" : "Some Title",
"track" : 34,
"trackCount" : 99,
"year" : 2004
},
"image" : {
"height" : 100,
"width" : 100
},
"location" : {
"altitude" : 1047.7,
"latitude" : 49.48675890884328,
"longitude" : 11.103870357204285
},
"photo" : {
"cameraMake" : "CameraMake",
"cameraModel" : "CameraMake",
"exposureDenominator" : 1,
"exposureNumerator" : 1,
"fNumber" : 1,
"focalLength" : 1,
"iso" : 1,
"orientation" : 1,
"takenDateTime" : "2018-01-01T12:34:56Z"
}
}
@@ -0,0 +1,7 @@
{
"ID" : "1$1!2",
"RootID" : "1$1!1",
"ParentID" : "1$1!1",
"Path" : "./parent d!r",
"Type" : 2
}
@@ -0,0 +1,5 @@
{
"ID" : "1$1!1",
"RootID" : "1$1!1",
"Path" : "."
}
@@ -0,0 +1,77 @@
package opensearch
import (
"context"
"fmt"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/qsfera/server/services/search/pkg/search"
"github.com/qsfera/server/pkg/conversions"
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
)
func searchResourceByID(ctx context.Context, client *opensearchgoAPI.Client, index string, id string) (search.Resource, error) {
req, err := osu.BuildSearchReq(
&opensearchgoAPI.SearchReq{
Indices: []string{index},
},
osu.NewIDsQuery(id),
)
if err != nil {
return search.Resource{}, fmt.Errorf("failed to build search request: %w", err)
}
resp, err := client.Search(ctx, req)
switch {
case err != nil:
return search.Resource{}, fmt.Errorf("failed to search for resource: %w", err)
case resp.Hits.Total.Value == 0 || len(resp.Hits.Hits) == 0:
return search.Resource{}, fmt.Errorf("document with id %s not found", id)
}
resource, err := conversions.To[search.Resource](resp.Hits.Hits[0].Source)
if err != nil {
return search.Resource{}, fmt.Errorf("failed to convert hit source: %w", err)
}
return resource, nil
}
func updateSelfAndDescendants(ctx context.Context, client *opensearchgoAPI.Client, index string, id string, scriptProvider func(search.Resource) *osu.BodyParamScript) error {
if scriptProvider == nil {
return fmt.Errorf("script cannot be nil")
}
resource, err := searchResourceByID(context.Background(), client, index, id)
if err != nil {
return fmt.Errorf("failed to get resource: %w", err)
}
req, err := osu.BuildUpdateByQueryReq(
opensearchgoAPI.UpdateByQueryReq{
Indices: []string{index},
Params: opensearchgoAPI.UpdateByQueryParams{
WaitForCompletion: conversions.ToPointer(true),
},
},
osu.NewTermQuery[string]("Path").Value(resource.Path),
osu.UpdateByQueryBodyParams{
Script: scriptProvider(resource),
},
)
if err != nil {
return fmt.Errorf("failed to build update by query request: %w", err)
}
resp, err := client.UpdateByQuery(ctx, req)
switch {
case err != nil:
return fmt.Errorf("failed to update by query: %w", err)
case len(resp.Failures) != 0:
return fmt.Errorf("failed to update by query, failures: %v", resp.Failures)
}
return nil
}
@@ -0,0 +1,26 @@
package opensearch_test
import (
"context"
"fmt"
"os"
"testing"
"github.com/qsfera/server/services/search/pkg/config"
opensearchtest "github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
)
var defaultConfig *config.Config
func TestMain(m *testing.M) {
cfg, done, err := opensearchtest.SetupTests(context.Background())
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to setup tests:", err)
os.Exit(1)
return
}
defaultConfig = cfg
code := m.Run()
done()
os.Exit(code)
}
@@ -0,0 +1,34 @@
// Package bleve provides the ability to work with bleve queries.
package bleve
import (
bQuery "github.com/blevesearch/bleve/v2/search/query"
"github.com/qsfera/server/pkg/kql"
"github.com/qsfera/server/services/search/pkg/query"
)
// Creator is combines a Builder and a Compiler which is used to Create the query.
type Creator[T any] struct {
builder query.Builder
compiler query.Compiler[T]
}
// Create implements the Creator interface
func (c Creator[T]) Create(qs string) (T, error) {
var t T
builderAst, err := c.builder.Build(qs)
if err != nil {
return t, err
}
t, err = c.compiler.Compile(builderAst)
if err != nil {
return t, err
}
return t, nil
}
// DefaultCreator exposes a kql to bleve query creator.
var DefaultCreator = Creator[bQuery.Query]{kql.Builder{}, Compiler{}}
@@ -0,0 +1,365 @@
package bleve
import (
"fmt"
"strings"
"github.com/blevesearch/bleve/v2"
bleveQuery "github.com/blevesearch/bleve/v2/search/query"
"github.com/qsfera/server/pkg/ast"
"github.com/qsfera/server/pkg/kql"
)
// lowercaseFields lists the bleve fields whose index mapping uses a
// lowercasing analyzer. Values bound to these fields are pre-lowercased
// so query-side matching stays consistent with the index.
// Keep in sync with services/search/pkg/bleve/index.go NewMapping.
var lowercaseFields = map[string]struct{}{
"Name": {},
"Tags": {},
"Favorites": {},
"Content": {},
}
var _fields = map[string]string{
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
"favorite": "Favorites",
}
// The following quoted string enumerates the characters which may be escaped: "+-=&|><!(){}[]^\"~*?:\\/ "
// based on bleve docs https://blevesearch.com/docs/Query-String-Query/
// Wildcards * and ? are excluded
var bleveEscaper = strings.NewReplacer(
`+`, `\+`,
`-`, `\-`,
`=`, `\=`,
`&`, `\&`,
`|`, `\|`,
`>`, `\>`,
`<`, `\<`,
`!`, `\!`,
`(`, `\(`,
`)`, `\)`,
`{`, `\{`,
`}`, `\}`,
`{`, `\}`,
`[`, `\[`,
`]`, `\]`,
`^`, `\^`,
`"`, `\"`,
`~`, `\~`,
`:`, `\:`,
`\`, `\\`,
`/`, `\/`,
` `, `\ `,
)
// Compiler represents a KQL query search string to the bleve query formatter.
type Compiler struct{}
// Compile implements the query formatter which converts the KQL query search string to the bleve query.
func (c Compiler) Compile(givenAst *ast.Ast) (bleveQuery.Query, error) {
q, err := compile(givenAst)
if err != nil {
return nil, err
}
return q, nil
}
func compile(a *ast.Ast) (bleveQuery.Query, error) {
q, _, err := walk(0, a.Nodes)
if err != nil {
return nil, err
}
switch q.(type) {
case *bleveQuery.ConjunctionQuery, *bleveQuery.DisjunctionQuery:
return q, nil
}
return bleve.NewConjunctionQuery(q), nil
}
func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
var prev, next bleveQuery.Query
var operator *ast.OperatorNode
var isGroup bool
for i := offset; i < len(nodes); i++ {
switch n := nodes[i].(type) {
case *ast.StringNode:
k := getField(n.Key)
v := n.Value
if k != "ID" && k != "Size" {
v = bleveEscaper.Replace(n.Value)
}
if _, ok := lowercaseFields[k]; ok {
v = strings.ToLower(v)
}
var q bleveQuery.Query
var group bool
switch k {
case "MimeType":
q, group = mimeType(k, v)
if prev == nil {
isGroup = group
}
default:
q = bleveQuery.NewQueryStringQuery(k + ":" + v)
}
if prev == nil {
prev = q
} else {
next = q
}
case *ast.DateTimeNode:
q := &bleveQuery.DateRangeQuery{
Start: bleveQuery.BleveQueryTime{},
End: bleveQuery.BleveQueryTime{},
InclusiveStart: nil,
InclusiveEnd: nil,
FieldVal: getField(n.Key),
}
if n.Operator == nil {
continue
}
switch n.Operator.Value {
case ">":
q.Start.Time = n.Value
q.InclusiveStart = &[]bool{false}[0]
case ">=":
q.Start.Time = n.Value
q.InclusiveStart = &[]bool{true}[0]
case "<":
q.End.Time = n.Value
q.InclusiveEnd = &[]bool{false}[0]
case "<=":
q.End.Time = n.Value
q.InclusiveEnd = &[]bool{true}[0]
default:
continue
}
if prev == nil {
prev = q
} else {
next = q
}
case *ast.BooleanNode:
q := bleveQuery.NewQueryStringQuery(getField(n.Key) + fmt.Sprintf(":%v", n.Value))
if prev == nil {
prev = q
} else {
next = q
}
case *ast.GroupNode:
if n.Key != "" {
n = normalizeGroupingProperty(n)
}
q, _, err := walk(0, n.Nodes)
if err != nil {
return nil, 0, err
}
if prev == nil {
prev = q
isGroup = true
} else {
next = q
}
case *ast.OperatorNode:
if n.Value == kql.BoolAND || n.Value == kql.BoolOR {
operator = n
} else if n.Value == kql.BoolNOT {
var err error
next, offset, err = nextNode(i+1, nodes)
if err != nil {
return nil, 0, err
}
q := bleve.NewBooleanQuery()
q.AddMustNot(next)
if prev == nil {
// unary in the beginning
prev = q
} else {
next = q
}
}
}
if prev != nil && next != nil && operator != nil {
prev = mapBinary(operator, prev, next, isGroup)
isGroup = false
operator = nil
next = nil
}
if i < offset {
i = offset
}
}
if prev == nil {
return nil, 0, fmt.Errorf("can not compile the query")
}
return prev, offset, nil
}
func nextNode(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
if n, ok := nodes[offset].(*ast.GroupNode); ok {
gq, _, err := walk(0, n.Nodes)
if err != nil {
return nil, 0, err
}
return gq, offset + 1, nil
}
if n, ok := nodes[offset].(*ast.OperatorNode); ok {
if n.Value == kql.BoolNOT {
return walk(offset, nodes)
}
}
one := nodes[:offset+1]
return walk(offset, one)
}
func mapBinary(operator *ast.OperatorNode, ln, rn bleveQuery.Query, leftIsGroup bool) bleveQuery.Query {
if operator.Value == kql.BoolOR {
right, ok := rn.(*bleveQuery.DisjunctionQuery)
switch left := ln.(type) {
case *bleveQuery.DisjunctionQuery:
if ok {
left.AddQuery(right.Disjuncts...)
} else {
left.AddQuery(rn)
}
return left
case *bleveQuery.ConjunctionQuery:
return bleveQuery.NewDisjunctionQuery([]bleveQuery.Query{ln, rn})
default:
if ok {
left := bleveQuery.NewDisjunctionQuery([]bleveQuery.Query{ln})
left.AddQuery(right.Disjuncts...)
return left
}
return bleveQuery.NewDisjunctionQuery([]bleveQuery.Query{ln, rn})
}
}
if operator.Value == kql.BoolAND {
switch left := ln.(type) {
case *bleveQuery.ConjunctionQuery:
left.AddQuery(rn)
return left
case *bleveQuery.DisjunctionQuery:
if !leftIsGroup {
last := left.Disjuncts[len(left.Disjuncts)-1]
rn = bleveQuery.NewConjunctionQuery([]bleveQuery.Query{
last,
rn,
})
dj := bleveQuery.NewDisjunctionQuery(left.Disjuncts[:len(left.Disjuncts)-1])
dj.AddQuery(rn)
return dj
}
}
}
return bleveQuery.NewConjunctionQuery([]bleveQuery.Query{
ln,
rn,
})
}
func getField(name string) string {
if name == "" {
return "Name"
}
if _, ok := _fields[strings.ToLower(name)]; ok {
return _fields[strings.ToLower(name)]
}
return name
}
func normalizeGroupingProperty(group *ast.GroupNode) *ast.GroupNode {
for _, n := range group.Nodes {
if onode, ok := n.(*ast.StringNode); ok {
onode.Key = group.Key
}
}
return group
}
func mimeType(k, v string) (bleveQuery.Query, bool) {
switch v {
case "file":
q := bleve.NewBooleanQuery()
q.AddMustNot(bleveQuery.NewQueryStringQuery(k + ":httpd/unix-directory"))
return q, false
case "folder":
return bleveQuery.NewQueryStringQuery(k + ":httpd/unix-directory"), false
case "document":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
"application/vnd.oasis.opendocument.text",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.apple.pages",
)), true
case "spreadsheet":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/vnd.ms-excel",
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.apple.numbers",
)), true
case "presentation":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.ms-powerpoint",
"application/vnd.apple.keynote",
)), true
case "pdf":
return bleveQuery.NewQueryStringQuery(k + ":application/pdf"), false
case "image":
return bleveQuery.NewQueryStringQuery(k + ":image/*"), false
case "video":
return bleveQuery.NewQueryStringQuery(k + ":video/*"), false
case "audio":
return bleveQuery.NewQueryStringQuery(k + ":audio/*"), false
case "archive":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/zip",
"application/gzip",
"application/x-gzip",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-tar",
"application/x-bzip2",
"application/x-bzip",
"application/x-tgz",
)), true
default:
return bleveQuery.NewQueryStringQuery(k + ":" + v), false
}
}
func newQueryStringQueryList(k string, v ...string) []bleveQuery.Query {
list := make([]bleveQuery.Query, len(v))
for i := 0; i < len(v); i++ {
list[i] = bleveQuery.NewQueryStringQuery(k + ":" + v[i])
}
return list
}
@@ -0,0 +1,574 @@
package bleve
import (
"testing"
"time"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/qsfera/server/pkg/ast"
tAssert "github.com/stretchr/testify/assert"
)
var timeMustParse = func(t *testing.T, ts string) time.Time {
tp, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
t.Fatalf("time.Parse(...) error = %v", err)
}
return tp
}
func Test_compile(t *testing.T) {
tests := []struct {
name string
args *ast.Ast
want query.Query
wantErr bool
}{
{
name: `federated`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Value: "federated"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:federated`),
}),
wantErr: false,
},
{
name: `"John Smith"`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Value: "John Smith"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
}),
wantErr: false,
},
{
name: `"John Smith" Jane`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "name", Value: "John Smith"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "name", Value: "Jane"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
query.NewQueryStringQuery(`Name:jane`),
}),
wantErr: false,
},
{
name: `tag:bestseller tag:book`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "tag", Value: "bestseller"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "tag", Value: "book"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags:book`),
}),
wantErr: false,
},
{
name: `name:"moby di*" OR tag:bestseller AND tag:book`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "name", Value: "moby di*"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "tag", Value: "bestseller"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "tag", Value: "book"},
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags:book`),
}),
}),
wantErr: false,
},
{
name: `a AND b OR c`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Value: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Value: "c"},
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
query.NewQueryStringQuery(`Name:b`),
}),
query.NewQueryStringQuery(`Name:c`),
}),
wantErr: false,
},
{
name: `a OR b AND c`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Value: "a"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Value: "c"},
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:b`),
query.NewQueryStringQuery(`Name:c`),
}),
}),
wantErr: false,
},
{
name: `(a OR b OR c) AND d`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Value: "a"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Value: "c"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Value: "d"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
query.NewQueryStringQuery(`Name:b`),
query.NewQueryStringQuery(`Name:c`),
}),
query.NewQueryStringQuery(`Name:d`),
}),
wantErr: false,
},
{
name: `(name:"moby di*" OR tag:bestseller) AND tag:book`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "name", Value: "moby di*"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "tag", Value: "bestseller"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "tag", Value: "book"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
query.NewQueryStringQuery(`Tags:bestseller`),
}),
query.NewQueryStringQuery(`Tags:book`),
}),
wantErr: false,
},
{
name: `(name:"moby di*" OR tag:bestseller) AND tag:book AND NOT tag:read`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "name", Value: "moby di*"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "tag", Value: "bestseller"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "tag", Value: "book"},
&ast.OperatorNode{Value: "AND"},
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "tag", Value: "read"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
query.NewQueryStringQuery(`Tags:bestseller`),
}),
query.NewQueryStringQuery(`Tags:book`),
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:read`)}),
}),
wantErr: false,
},
{
name: `author:("John Smith" Jane)`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{
Key: "author",
Nodes: []ast.Node{
&ast.StringNode{Value: "John Smith"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Value: "Jane"},
},
},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`author:John\ Smith`),
query.NewQueryStringQuery(`author:Jane`),
}),
wantErr: false,
},
{
name: `author:("John Smith" Jane) AND tag:bestseller`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{
Key: "author",
Nodes: []ast.Node{
&ast.StringNode{Value: "John Smith"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Value: "Jane"},
},
},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "tag", Value: "bestseller"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`author:John\ Smith`),
query.NewQueryStringQuery(`author:Jane`),
query.NewQueryStringQuery(`Tags:bestseller`),
}),
wantErr: false,
},
{
name: `id:b27d3bf1-b254-459f-92e8-bdba668d6d3f$d0648459-25fb-4ed8-8684-bc62c7dca29c!d0648459-25fb-4ed8-8684-bc62c7dca29c mtime>=2023-09-05T12:40:59.14741+02:00`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{
Key: "id",
Value: "b27d3bf1-b254-459f-92e8-bdba668d6d3f$d0648459-25fb-4ed8-8684-bc62c7dca29c!d0648459-25fb-4ed8-8684-bc62c7dca29c",
},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{
Key: "Mtime",
Operator: &ast.OperatorNode{Value: ">="},
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`ID:b27d3bf1-b254-459f-92e8-bdba668d6d3f$d0648459-25fb-4ed8-8684-bc62c7dca29c!d0648459-25fb-4ed8-8684-bc62c7dca29c`),
func() query.Query {
q := query.NewDateRangeInclusiveQuery(timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), time.Time{}, &[]bool{true}[0], nil)
q.FieldVal = "Mtime"
return q
}(),
}),
wantErr: false,
},
{
name: `StringNode value lowercase`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Value: "John Smith"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Hidden", Value: "T"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "hidden", Value: "T"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
query.NewQueryStringQuery(`Hidden:T`),
query.NewQueryStringQuery(`Hidden:T`),
}),
wantErr: false,
},
{
name: `NOT tag:physik`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "tag", Value: "physik"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:physik`)}),
}),
wantErr: false,
},
{
name: `ast.DateTimeNode`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.DateTimeNode{
Key: "mtime",
// "=" is not supported by bleve, ignore
Operator: &ast.OperatorNode{Value: "="},
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{
Key: "mtime",
// ":" is not supported by bleve, ignore
Operator: &ast.OperatorNode{Value: ":"},
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{
Key: "mtime",
// no operator, skip
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{
Key: "mtime",
Operator: &ast.OperatorNode{Value: ">"},
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{
Key: "mtime",
Operator: &ast.OperatorNode{Value: ">="},
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{
Key: "mtime",
Operator: &ast.OperatorNode{Value: "<"},
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{
Key: "mtime",
Operator: &ast.OperatorNode{Value: "<="},
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
},
},
},
want: query.NewConjunctionQuery([]query.Query{
func() query.Query {
q := query.NewDateRangeInclusiveQuery(timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), time.Time{}, &[]bool{false}[0], nil)
q.FieldVal = "Mtime"
return q
}(),
func() query.Query {
q := query.NewDateRangeInclusiveQuery(timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), time.Time{}, &[]bool{true}[0], nil)
q.FieldVal = "Mtime"
return q
}(),
func() query.Query {
q := query.NewDateRangeInclusiveQuery(time.Time{}, timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), nil, &[]bool{false}[0])
q.FieldVal = "Mtime"
return q
}(),
func() query.Query {
q := query.NewDateRangeInclusiveQuery(time.Time{}, timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), nil, &[]bool{true}[0])
q.FieldVal = "Mtime"
return q
}(),
}),
wantErr: false,
},
{
name: `MimeType:document`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "mediatype", Value: "document"},
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/msword`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
query.NewQueryStringQuery(`MimeType:text/plain`),
query.NewQueryStringQuery(`MimeType:text/markdown`),
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
wantErr: false,
},
{
name: `MimeType:document AND *tdd*`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "mediatype", Value: "document"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "name", Value: "*tdd*"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/msword`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
query.NewQueryStringQuery(`MimeType:text/plain`),
query.NewQueryStringQuery(`MimeType:text/markdown`),
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
}),
wantErr: false,
},
{
name: `MimeType:document OR MimeType:pdf AND *tdd*`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "mediatype", Value: "document"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "mediatype", Value: "pdf"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "name", Value: "*tdd*"},
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/msword`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
query.NewQueryStringQuery(`MimeType:text/plain`),
query.NewQueryStringQuery(`MimeType:text/markdown`),
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/pdf`),
query.NewQueryStringQuery(`Name:*tdd*`),
}),
}),
wantErr: false,
},
{
name: `(MimeType:document OR MimeType:pdf) AND *tdd*`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "mediatype", Value: "document"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "mediatype", Value: "pdf"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "name", Value: "*tdd*"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/msword`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
query.NewQueryStringQuery(`MimeType:text/plain`),
query.NewQueryStringQuery(`MimeType:text/markdown`),
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
query.NewQueryStringQuery(`MimeType:application/pdf`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
}),
wantErr: false,
},
{
name: `(MimeType:pdf OR MimeType:document) AND *tdd*`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "mediatype", Value: "pdf"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "mediatype", Value: "document"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "name", Value: "*tdd*"},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/pdf`),
query.NewQueryStringQuery(`MimeType:application/msword`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
query.NewQueryStringQuery(`MimeType:text/plain`),
query.NewQueryStringQuery(`MimeType:text/markdown`),
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
}),
wantErr: false,
},
{
name: `John Smith`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Value: "John Smith +-=&|><!(){}[]^\"~: "},
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith\ \+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\ `),
}),
wantErr: false,
},
}
assert := tAssert.New(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := compile(tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("compile() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Equal(tt.want, got)
})
}
}
func Test_escape(t *testing.T) {
type args struct {
str string
}
tests := []struct {
name string
args args
want string
}{
{
name: "all escaped",
args: args{
`+-=&|><!(){}[]^"~:\/ `,
},
want: `\+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\\\/\ `,
},
{
name: "no one escaped",
args: args{
`@#$%`,
},
want: `@#$%`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tAssert.Equalf(t, tt.want, bleveEscaper.Replace(tt.args.str), "bleveEscaper(%v)", tt.args.str)
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package query
import (
"fmt"
"github.com/qsfera/server/pkg/ast"
)
// StartsWithBinaryOperatorError records an error and the operation that caused it.
type StartsWithBinaryOperatorError struct {
Node *ast.OperatorNode
}
func (e StartsWithBinaryOperatorError) Error() string {
return "the expression can't begin from a binary operator: '" + e.Node.Value + "'"
}
// NamedGroupInvalidNodesError records an error and the operation that caused it.
type NamedGroupInvalidNodesError struct {
Node ast.Node
}
func (e NamedGroupInvalidNodesError) Error() string {
return fmt.Errorf(
"'%T' - '%v' - '%v' is not valid",
e.Node,
ast.NodeKey(e.Node),
ast.NodeValue(e.Node),
).Error()
}
// UnsupportedTimeRangeError records an error and the value that caused it.
type UnsupportedTimeRangeError struct {
Value any
}
func (e UnsupportedTimeRangeError) Error() string {
return fmt.Sprintf("unable to convert '%v' to a time range", e.Value)
}
func IsValidationError(err error) bool {
switch err.(type) {
case *StartsWithBinaryOperatorError, *NamedGroupInvalidNodesError, *UnsupportedTimeRangeError:
return true
}
return false
}
+19
View File
@@ -0,0 +1,19 @@
// Package query provides functions to work with the different search query flavours.
package query
import "github.com/qsfera/server/pkg/ast"
// Builder is the interface that wraps the basic Build method.
type Builder interface {
Build(qs string) (*ast.Ast, error)
}
// Compiler is the interface that wraps the basic Compile method.
type Compiler[T any] interface {
Compile(ast *ast.Ast) (T, error)
}
// Creator is the interface that wraps the basic Create method.
type Creator[T any] interface {
Create(qs string) (T, error)
}
@@ -0,0 +1,360 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"github.com/qsfera/server/services/search/pkg/search"
mock "github.com/stretchr/testify/mock"
)
// NewBatchOperator creates a new instance of BatchOperator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewBatchOperator(t interface {
mock.TestingT
Cleanup(func())
}) *BatchOperator {
mock := &BatchOperator{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// BatchOperator is an autogenerated mock type for the BatchOperator type
type BatchOperator struct {
mock.Mock
}
type BatchOperator_Expecter struct {
mock *mock.Mock
}
func (_m *BatchOperator) EXPECT() *BatchOperator_Expecter {
return &BatchOperator_Expecter{mock: &_m.Mock}
}
// Delete provides a mock function for the type BatchOperator
func (_mock *BatchOperator) Delete(id string) error {
ret := _mock.Called(id)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
r0 = returnFunc(id)
} else {
r0 = ret.Error(0)
}
return r0
}
// BatchOperator_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type BatchOperator_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - id string
func (_e *BatchOperator_Expecter) Delete(id interface{}) *BatchOperator_Delete_Call {
return &BatchOperator_Delete_Call{Call: _e.mock.On("Delete", id)}
}
func (_c *BatchOperator_Delete_Call) Run(run func(id string)) *BatchOperator_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
run(
arg0,
)
})
return _c
}
func (_c *BatchOperator_Delete_Call) Return(err error) *BatchOperator_Delete_Call {
_c.Call.Return(err)
return _c
}
func (_c *BatchOperator_Delete_Call) RunAndReturn(run func(id string) error) *BatchOperator_Delete_Call {
_c.Call.Return(run)
return _c
}
// Move provides a mock function for the type BatchOperator
func (_mock *BatchOperator) Move(rootID string, parentID string, location string) error {
ret := _mock.Called(rootID, parentID, location)
if len(ret) == 0 {
panic("no return value specified for Move")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = returnFunc(rootID, parentID, location)
} else {
r0 = ret.Error(0)
}
return r0
}
// BatchOperator_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move'
type BatchOperator_Move_Call struct {
*mock.Call
}
// Move is a helper method to define mock.On call
// - rootID string
// - parentID string
// - location string
func (_e *BatchOperator_Expecter) Move(rootID interface{}, parentID interface{}, location interface{}) *BatchOperator_Move_Call {
return &BatchOperator_Move_Call{Call: _e.mock.On("Move", rootID, parentID, location)}
}
func (_c *BatchOperator_Move_Call) Run(run func(rootID string, parentID string, location string)) *BatchOperator_Move_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *BatchOperator_Move_Call) Return(err error) *BatchOperator_Move_Call {
_c.Call.Return(err)
return _c
}
func (_c *BatchOperator_Move_Call) RunAndReturn(run func(rootID string, parentID string, location string) error) *BatchOperator_Move_Call {
_c.Call.Return(run)
return _c
}
// Purge provides a mock function for the type BatchOperator
func (_mock *BatchOperator) Purge(id string, onlyDeleted bool) error {
ret := _mock.Called(id, onlyDeleted)
if len(ret) == 0 {
panic("no return value specified for Purge")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok {
r0 = returnFunc(id, onlyDeleted)
} else {
r0 = ret.Error(0)
}
return r0
}
// BatchOperator_Purge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Purge'
type BatchOperator_Purge_Call struct {
*mock.Call
}
// Purge is a helper method to define mock.On call
// - id string
// - onlyDeleted bool
func (_e *BatchOperator_Expecter) Purge(id interface{}, onlyDeleted interface{}) *BatchOperator_Purge_Call {
return &BatchOperator_Purge_Call{Call: _e.mock.On("Purge", id, onlyDeleted)}
}
func (_c *BatchOperator_Purge_Call) Run(run func(id string, onlyDeleted bool)) *BatchOperator_Purge_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 bool
if args[1] != nil {
arg1 = args[1].(bool)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *BatchOperator_Purge_Call) Return(err error) *BatchOperator_Purge_Call {
_c.Call.Return(err)
return _c
}
func (_c *BatchOperator_Purge_Call) RunAndReturn(run func(id string, onlyDeleted bool) error) *BatchOperator_Purge_Call {
_c.Call.Return(run)
return _c
}
// Push provides a mock function for the type BatchOperator
func (_mock *BatchOperator) Push() error {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Push")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func() error); ok {
r0 = returnFunc()
} else {
r0 = ret.Error(0)
}
return r0
}
// BatchOperator_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
type BatchOperator_Push_Call struct {
*mock.Call
}
// Push is a helper method to define mock.On call
func (_e *BatchOperator_Expecter) Push() *BatchOperator_Push_Call {
return &BatchOperator_Push_Call{Call: _e.mock.On("Push")}
}
func (_c *BatchOperator_Push_Call) Run(run func()) *BatchOperator_Push_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *BatchOperator_Push_Call) Return(err error) *BatchOperator_Push_Call {
_c.Call.Return(err)
return _c
}
func (_c *BatchOperator_Push_Call) RunAndReturn(run func() error) *BatchOperator_Push_Call {
_c.Call.Return(run)
return _c
}
// Restore provides a mock function for the type BatchOperator
func (_mock *BatchOperator) Restore(id string) error {
ret := _mock.Called(id)
if len(ret) == 0 {
panic("no return value specified for Restore")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
r0 = returnFunc(id)
} else {
r0 = ret.Error(0)
}
return r0
}
// BatchOperator_Restore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restore'
type BatchOperator_Restore_Call struct {
*mock.Call
}
// Restore is a helper method to define mock.On call
// - id string
func (_e *BatchOperator_Expecter) Restore(id interface{}) *BatchOperator_Restore_Call {
return &BatchOperator_Restore_Call{Call: _e.mock.On("Restore", id)}
}
func (_c *BatchOperator_Restore_Call) Run(run func(id string)) *BatchOperator_Restore_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
run(
arg0,
)
})
return _c
}
func (_c *BatchOperator_Restore_Call) Return(err error) *BatchOperator_Restore_Call {
_c.Call.Return(err)
return _c
}
func (_c *BatchOperator_Restore_Call) RunAndReturn(run func(id string) error) *BatchOperator_Restore_Call {
_c.Call.Return(run)
return _c
}
// Upsert provides a mock function for the type BatchOperator
func (_mock *BatchOperator) Upsert(id string, r search.Resource) error {
ret := _mock.Called(id, r)
if len(ret) == 0 {
panic("no return value specified for Upsert")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string, search.Resource) error); ok {
r0 = returnFunc(id, r)
} else {
r0 = ret.Error(0)
}
return r0
}
// BatchOperator_Upsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upsert'
type BatchOperator_Upsert_Call struct {
*mock.Call
}
// Upsert is a helper method to define mock.On call
// - id string
// - r search.Resource
func (_e *BatchOperator_Expecter) Upsert(id interface{}, r interface{}) *BatchOperator_Upsert_Call {
return &BatchOperator_Upsert_Call{Call: _e.mock.On("Upsert", id, r)}
}
func (_c *BatchOperator_Upsert_Call) Run(run func(id string, r search.Resource)) *BatchOperator_Upsert_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 search.Resource
if args[1] != nil {
arg1 = args[1].(search.Resource)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *BatchOperator_Upsert_Call) Return(err error) *BatchOperator_Upsert_Call {
_c.Call.Return(err)
return _c
}
func (_c *BatchOperator_Upsert_Call) RunAndReturn(run func(id string, r search.Resource) error) *BatchOperator_Upsert_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,502 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
"github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/search"
mock "github.com/stretchr/testify/mock"
)
// NewEngine creates a new instance of Engine. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewEngine(t interface {
mock.TestingT
Cleanup(func())
}) *Engine {
mock := &Engine{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Engine is an autogenerated mock type for the Engine type
type Engine struct {
mock.Mock
}
type Engine_Expecter struct {
mock *mock.Mock
}
func (_m *Engine) EXPECT() *Engine_Expecter {
return &Engine_Expecter{mock: &_m.Mock}
}
// Delete provides a mock function for the type Engine
func (_mock *Engine) Delete(id string) error {
ret := _mock.Called(id)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
r0 = returnFunc(id)
} else {
r0 = ret.Error(0)
}
return r0
}
// Engine_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type Engine_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - id string
func (_e *Engine_Expecter) Delete(id interface{}) *Engine_Delete_Call {
return &Engine_Delete_Call{Call: _e.mock.On("Delete", id)}
}
func (_c *Engine_Delete_Call) Run(run func(id string)) *Engine_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
run(
arg0,
)
})
return _c
}
func (_c *Engine_Delete_Call) Return(err error) *Engine_Delete_Call {
_c.Call.Return(err)
return _c
}
func (_c *Engine_Delete_Call) RunAndReturn(run func(id string) error) *Engine_Delete_Call {
_c.Call.Return(run)
return _c
}
// DocCount provides a mock function for the type Engine
func (_mock *Engine) DocCount() (uint64, error) {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for DocCount")
}
var r0 uint64
var r1 error
if returnFunc, ok := ret.Get(0).(func() (uint64, error)); ok {
return returnFunc()
}
if returnFunc, ok := ret.Get(0).(func() uint64); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(uint64)
}
if returnFunc, ok := ret.Get(1).(func() error); ok {
r1 = returnFunc()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Engine_DocCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DocCount'
type Engine_DocCount_Call struct {
*mock.Call
}
// DocCount is a helper method to define mock.On call
func (_e *Engine_Expecter) DocCount() *Engine_DocCount_Call {
return &Engine_DocCount_Call{Call: _e.mock.On("DocCount")}
}
func (_c *Engine_DocCount_Call) Run(run func()) *Engine_DocCount_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *Engine_DocCount_Call) Return(v uint64, err error) *Engine_DocCount_Call {
_c.Call.Return(v, err)
return _c
}
func (_c *Engine_DocCount_Call) RunAndReturn(run func() (uint64, error)) *Engine_DocCount_Call {
_c.Call.Return(run)
return _c
}
// Move provides a mock function for the type Engine
func (_mock *Engine) Move(id string, parentid string, target string) error {
ret := _mock.Called(id, parentid, target)
if len(ret) == 0 {
panic("no return value specified for Move")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = returnFunc(id, parentid, target)
} else {
r0 = ret.Error(0)
}
return r0
}
// Engine_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move'
type Engine_Move_Call struct {
*mock.Call
}
// Move is a helper method to define mock.On call
// - id string
// - parentid string
// - target string
func (_e *Engine_Expecter) Move(id interface{}, parentid interface{}, target interface{}) *Engine_Move_Call {
return &Engine_Move_Call{Call: _e.mock.On("Move", id, parentid, target)}
}
func (_c *Engine_Move_Call) Run(run func(id string, parentid string, target string)) *Engine_Move_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *Engine_Move_Call) Return(err error) *Engine_Move_Call {
_c.Call.Return(err)
return _c
}
func (_c *Engine_Move_Call) RunAndReturn(run func(id string, parentid string, target string) error) *Engine_Move_Call {
_c.Call.Return(run)
return _c
}
// NewBatch provides a mock function for the type Engine
func (_mock *Engine) NewBatch(batchSize int) (search.BatchOperator, error) {
ret := _mock.Called(batchSize)
if len(ret) == 0 {
panic("no return value specified for NewBatch")
}
var r0 search.BatchOperator
var r1 error
if returnFunc, ok := ret.Get(0).(func(int) (search.BatchOperator, error)); ok {
return returnFunc(batchSize)
}
if returnFunc, ok := ret.Get(0).(func(int) search.BatchOperator); ok {
r0 = returnFunc(batchSize)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(search.BatchOperator)
}
}
if returnFunc, ok := ret.Get(1).(func(int) error); ok {
r1 = returnFunc(batchSize)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Engine_NewBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewBatch'
type Engine_NewBatch_Call struct {
*mock.Call
}
// NewBatch is a helper method to define mock.On call
// - batchSize int
func (_e *Engine_Expecter) NewBatch(batchSize interface{}) *Engine_NewBatch_Call {
return &Engine_NewBatch_Call{Call: _e.mock.On("NewBatch", batchSize)}
}
func (_c *Engine_NewBatch_Call) Run(run func(batchSize int)) *Engine_NewBatch_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 int
if args[0] != nil {
arg0 = args[0].(int)
}
run(
arg0,
)
})
return _c
}
func (_c *Engine_NewBatch_Call) Return(batchOperator search.BatchOperator, err error) *Engine_NewBatch_Call {
_c.Call.Return(batchOperator, err)
return _c
}
func (_c *Engine_NewBatch_Call) RunAndReturn(run func(batchSize int) (search.BatchOperator, error)) *Engine_NewBatch_Call {
_c.Call.Return(run)
return _c
}
// Purge provides a mock function for the type Engine
func (_mock *Engine) Purge(id string, onlyDeleted bool) error {
ret := _mock.Called(id, onlyDeleted)
if len(ret) == 0 {
panic("no return value specified for Purge")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok {
r0 = returnFunc(id, onlyDeleted)
} else {
r0 = ret.Error(0)
}
return r0
}
// Engine_Purge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Purge'
type Engine_Purge_Call struct {
*mock.Call
}
// Purge is a helper method to define mock.On call
// - id string
// - onlyDeleted bool
func (_e *Engine_Expecter) Purge(id interface{}, onlyDeleted interface{}) *Engine_Purge_Call {
return &Engine_Purge_Call{Call: _e.mock.On("Purge", id, onlyDeleted)}
}
func (_c *Engine_Purge_Call) Run(run func(id string, onlyDeleted bool)) *Engine_Purge_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 bool
if args[1] != nil {
arg1 = args[1].(bool)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Engine_Purge_Call) Return(err error) *Engine_Purge_Call {
_c.Call.Return(err)
return _c
}
func (_c *Engine_Purge_Call) RunAndReturn(run func(id string, onlyDeleted bool) error) *Engine_Purge_Call {
_c.Call.Return(run)
return _c
}
// Restore provides a mock function for the type Engine
func (_mock *Engine) Restore(id string) error {
ret := _mock.Called(id)
if len(ret) == 0 {
panic("no return value specified for Restore")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
r0 = returnFunc(id)
} else {
r0 = ret.Error(0)
}
return r0
}
// Engine_Restore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restore'
type Engine_Restore_Call struct {
*mock.Call
}
// Restore is a helper method to define mock.On call
// - id string
func (_e *Engine_Expecter) Restore(id interface{}) *Engine_Restore_Call {
return &Engine_Restore_Call{Call: _e.mock.On("Restore", id)}
}
func (_c *Engine_Restore_Call) Run(run func(id string)) *Engine_Restore_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
run(
arg0,
)
})
return _c
}
func (_c *Engine_Restore_Call) Return(err error) *Engine_Restore_Call {
_c.Call.Return(err)
return _c
}
func (_c *Engine_Restore_Call) RunAndReturn(run func(id string) error) *Engine_Restore_Call {
_c.Call.Return(run)
return _c
}
// Search provides a mock function for the type Engine
func (_mock *Engine) Search(ctx context.Context, req *v0.SearchIndexRequest) (*v0.SearchIndexResponse, error) {
ret := _mock.Called(ctx, req)
if len(ret) == 0 {
panic("no return value specified for Search")
}
var r0 *v0.SearchIndexResponse
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.SearchIndexRequest) (*v0.SearchIndexResponse, error)); ok {
return returnFunc(ctx, req)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.SearchIndexRequest) *v0.SearchIndexResponse); ok {
r0 = returnFunc(ctx, req)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0.SearchIndexResponse)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *v0.SearchIndexRequest) error); ok {
r1 = returnFunc(ctx, req)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Engine_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search'
type Engine_Search_Call struct {
*mock.Call
}
// Search is a helper method to define mock.On call
// - ctx context.Context
// - req *v0.SearchIndexRequest
func (_e *Engine_Expecter) Search(ctx interface{}, req interface{}) *Engine_Search_Call {
return &Engine_Search_Call{Call: _e.mock.On("Search", ctx, req)}
}
func (_c *Engine_Search_Call) Run(run func(ctx context.Context, req *v0.SearchIndexRequest)) *Engine_Search_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *v0.SearchIndexRequest
if args[1] != nil {
arg1 = args[1].(*v0.SearchIndexRequest)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Engine_Search_Call) Return(searchIndexResponse *v0.SearchIndexResponse, err error) *Engine_Search_Call {
_c.Call.Return(searchIndexResponse, err)
return _c
}
func (_c *Engine_Search_Call) RunAndReturn(run func(ctx context.Context, req *v0.SearchIndexRequest) (*v0.SearchIndexResponse, error)) *Engine_Search_Call {
_c.Call.Return(run)
return _c
}
// Upsert provides a mock function for the type Engine
func (_mock *Engine) Upsert(id string, r search.Resource) error {
ret := _mock.Called(id, r)
if len(ret) == 0 {
panic("no return value specified for Upsert")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string, search.Resource) error); ok {
r0 = returnFunc(id, r)
} else {
r0 = ret.Error(0)
}
return r0
}
// Engine_Upsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upsert'
type Engine_Upsert_Call struct {
*mock.Call
}
// Upsert is a helper method to define mock.On call
// - id string
// - r search.Resource
func (_e *Engine_Expecter) Upsert(id interface{}, r interface{}) *Engine_Upsert_Call {
return &Engine_Upsert_Call{Call: _e.mock.On("Upsert", id, r)}
}
func (_c *Engine_Upsert_Call) Run(run func(id string, r search.Resource)) *Engine_Upsert_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 search.Resource
if args[1] != nil {
arg1 = args[1].(search.Resource)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Engine_Upsert_Call) Return(err error) *Engine_Upsert_Call {
_c.Call.Return(err)
return _c
}
func (_c *Engine_Upsert_Call) RunAndReturn(run func(id string, r search.Resource) error) *Engine_Upsert_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,416 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
"github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
mock "github.com/stretchr/testify/mock"
)
// NewSearcher creates a new instance of Searcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewSearcher(t interface {
mock.TestingT
Cleanup(func())
}) *Searcher {
mock := &Searcher{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Searcher is an autogenerated mock type for the Searcher type
type Searcher struct {
mock.Mock
}
type Searcher_Expecter struct {
mock *mock.Mock
}
func (_m *Searcher) EXPECT() *Searcher_Expecter {
return &Searcher_Expecter{mock: &_m.Mock}
}
// IndexSpace provides a mock function for the type Searcher
func (_mock *Searcher) IndexSpace(rID *providerv1beta1.StorageSpaceId, forceRescan bool) error {
ret := _mock.Called(rID, forceRescan)
if len(ret) == 0 {
panic("no return value specified for IndexSpace")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(*providerv1beta1.StorageSpaceId, bool) error); ok {
r0 = returnFunc(rID, forceRescan)
} else {
r0 = ret.Error(0)
}
return r0
}
// Searcher_IndexSpace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IndexSpace'
type Searcher_IndexSpace_Call struct {
*mock.Call
}
// IndexSpace is a helper method to define mock.On call
// - rID *providerv1beta1.StorageSpaceId
// - forceRescan bool
func (_e *Searcher_Expecter) IndexSpace(rID interface{}, forceRescan interface{}) *Searcher_IndexSpace_Call {
return &Searcher_IndexSpace_Call{Call: _e.mock.On("IndexSpace", rID, forceRescan)}
}
func (_c *Searcher_IndexSpace_Call) Run(run func(rID *providerv1beta1.StorageSpaceId, forceRescan bool)) *Searcher_IndexSpace_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *providerv1beta1.StorageSpaceId
if args[0] != nil {
arg0 = args[0].(*providerv1beta1.StorageSpaceId)
}
var arg1 bool
if args[1] != nil {
arg1 = args[1].(bool)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Searcher_IndexSpace_Call) Return(err error) *Searcher_IndexSpace_Call {
_c.Call.Return(err)
return _c
}
func (_c *Searcher_IndexSpace_Call) RunAndReturn(run func(rID *providerv1beta1.StorageSpaceId, forceRescan bool) error) *Searcher_IndexSpace_Call {
_c.Call.Return(run)
return _c
}
// MoveItem provides a mock function for the type Searcher
func (_mock *Searcher) MoveItem(ref *providerv1beta1.Reference) {
_mock.Called(ref)
return
}
// Searcher_MoveItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MoveItem'
type Searcher_MoveItem_Call struct {
*mock.Call
}
// MoveItem is a helper method to define mock.On call
// - ref *providerv1beta1.Reference
func (_e *Searcher_Expecter) MoveItem(ref interface{}) *Searcher_MoveItem_Call {
return &Searcher_MoveItem_Call{Call: _e.mock.On("MoveItem", ref)}
}
func (_c *Searcher_MoveItem_Call) Run(run func(ref *providerv1beta1.Reference)) *Searcher_MoveItem_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *providerv1beta1.Reference
if args[0] != nil {
arg0 = args[0].(*providerv1beta1.Reference)
}
run(
arg0,
)
})
return _c
}
func (_c *Searcher_MoveItem_Call) Return() *Searcher_MoveItem_Call {
_c.Call.Return()
return _c
}
func (_c *Searcher_MoveItem_Call) RunAndReturn(run func(ref *providerv1beta1.Reference)) *Searcher_MoveItem_Call {
_c.Run(run)
return _c
}
// PurgeDeleted provides a mock function for the type Searcher
func (_mock *Searcher) PurgeDeleted(spaceID *providerv1beta1.StorageSpaceId) error {
ret := _mock.Called(spaceID)
if len(ret) == 0 {
panic("no return value specified for PurgeDeleted")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(*providerv1beta1.StorageSpaceId) error); ok {
r0 = returnFunc(spaceID)
} else {
r0 = ret.Error(0)
}
return r0
}
// Searcher_PurgeDeleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PurgeDeleted'
type Searcher_PurgeDeleted_Call struct {
*mock.Call
}
// PurgeDeleted is a helper method to define mock.On call
// - spaceID *providerv1beta1.StorageSpaceId
func (_e *Searcher_Expecter) PurgeDeleted(spaceID interface{}) *Searcher_PurgeDeleted_Call {
return &Searcher_PurgeDeleted_Call{Call: _e.mock.On("PurgeDeleted", spaceID)}
}
func (_c *Searcher_PurgeDeleted_Call) Run(run func(spaceID *providerv1beta1.StorageSpaceId)) *Searcher_PurgeDeleted_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *providerv1beta1.StorageSpaceId
if args[0] != nil {
arg0 = args[0].(*providerv1beta1.StorageSpaceId)
}
run(
arg0,
)
})
return _c
}
func (_c *Searcher_PurgeDeleted_Call) Return(err error) *Searcher_PurgeDeleted_Call {
_c.Call.Return(err)
return _c
}
func (_c *Searcher_PurgeDeleted_Call) RunAndReturn(run func(spaceID *providerv1beta1.StorageSpaceId) error) *Searcher_PurgeDeleted_Call {
_c.Call.Return(run)
return _c
}
// PurgeItem provides a mock function for the type Searcher
func (_mock *Searcher) PurgeItem(rID *providerv1beta1.Reference) {
_mock.Called(rID)
return
}
// Searcher_PurgeItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PurgeItem'
type Searcher_PurgeItem_Call struct {
*mock.Call
}
// PurgeItem is a helper method to define mock.On call
// - rID *providerv1beta1.Reference
func (_e *Searcher_Expecter) PurgeItem(rID interface{}) *Searcher_PurgeItem_Call {
return &Searcher_PurgeItem_Call{Call: _e.mock.On("PurgeItem", rID)}
}
func (_c *Searcher_PurgeItem_Call) Run(run func(rID *providerv1beta1.Reference)) *Searcher_PurgeItem_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *providerv1beta1.Reference
if args[0] != nil {
arg0 = args[0].(*providerv1beta1.Reference)
}
run(
arg0,
)
})
return _c
}
func (_c *Searcher_PurgeItem_Call) Return() *Searcher_PurgeItem_Call {
_c.Call.Return()
return _c
}
func (_c *Searcher_PurgeItem_Call) RunAndReturn(run func(rID *providerv1beta1.Reference)) *Searcher_PurgeItem_Call {
_c.Run(run)
return _c
}
// RestoreItem provides a mock function for the type Searcher
func (_mock *Searcher) RestoreItem(ref *providerv1beta1.Reference) {
_mock.Called(ref)
return
}
// Searcher_RestoreItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreItem'
type Searcher_RestoreItem_Call struct {
*mock.Call
}
// RestoreItem is a helper method to define mock.On call
// - ref *providerv1beta1.Reference
func (_e *Searcher_Expecter) RestoreItem(ref interface{}) *Searcher_RestoreItem_Call {
return &Searcher_RestoreItem_Call{Call: _e.mock.On("RestoreItem", ref)}
}
func (_c *Searcher_RestoreItem_Call) Run(run func(ref *providerv1beta1.Reference)) *Searcher_RestoreItem_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *providerv1beta1.Reference
if args[0] != nil {
arg0 = args[0].(*providerv1beta1.Reference)
}
run(
arg0,
)
})
return _c
}
func (_c *Searcher_RestoreItem_Call) Return() *Searcher_RestoreItem_Call {
_c.Call.Return()
return _c
}
func (_c *Searcher_RestoreItem_Call) RunAndReturn(run func(ref *providerv1beta1.Reference)) *Searcher_RestoreItem_Call {
_c.Run(run)
return _c
}
// Search provides a mock function for the type Searcher
func (_mock *Searcher) Search(ctx context.Context, req *v0.SearchRequest) (*v0.SearchResponse, error) {
ret := _mock.Called(ctx, req)
if len(ret) == 0 {
panic("no return value specified for Search")
}
var r0 *v0.SearchResponse
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.SearchRequest) (*v0.SearchResponse, error)); ok {
return returnFunc(ctx, req)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.SearchRequest) *v0.SearchResponse); ok {
r0 = returnFunc(ctx, req)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0.SearchResponse)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *v0.SearchRequest) error); ok {
r1 = returnFunc(ctx, req)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Searcher_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search'
type Searcher_Search_Call struct {
*mock.Call
}
// Search is a helper method to define mock.On call
// - ctx context.Context
// - req *v0.SearchRequest
func (_e *Searcher_Expecter) Search(ctx interface{}, req interface{}) *Searcher_Search_Call {
return &Searcher_Search_Call{Call: _e.mock.On("Search", ctx, req)}
}
func (_c *Searcher_Search_Call) Run(run func(ctx context.Context, req *v0.SearchRequest)) *Searcher_Search_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *v0.SearchRequest
if args[1] != nil {
arg1 = args[1].(*v0.SearchRequest)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Searcher_Search_Call) Return(searchResponse *v0.SearchResponse, err error) *Searcher_Search_Call {
_c.Call.Return(searchResponse, err)
return _c
}
func (_c *Searcher_Search_Call) RunAndReturn(run func(ctx context.Context, req *v0.SearchRequest) (*v0.SearchResponse, error)) *Searcher_Search_Call {
_c.Call.Return(run)
return _c
}
// TrashItem provides a mock function for the type Searcher
func (_mock *Searcher) TrashItem(rID *providerv1beta1.ResourceId) {
_mock.Called(rID)
return
}
// Searcher_TrashItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TrashItem'
type Searcher_TrashItem_Call struct {
*mock.Call
}
// TrashItem is a helper method to define mock.On call
// - rID *providerv1beta1.ResourceId
func (_e *Searcher_Expecter) TrashItem(rID interface{}) *Searcher_TrashItem_Call {
return &Searcher_TrashItem_Call{Call: _e.mock.On("TrashItem", rID)}
}
func (_c *Searcher_TrashItem_Call) Run(run func(rID *providerv1beta1.ResourceId)) *Searcher_TrashItem_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *providerv1beta1.ResourceId
if args[0] != nil {
arg0 = args[0].(*providerv1beta1.ResourceId)
}
run(
arg0,
)
})
return _c
}
func (_c *Searcher_TrashItem_Call) Return() *Searcher_TrashItem_Call {
_c.Call.Return()
return _c
}
func (_c *Searcher_TrashItem_Call) RunAndReturn(run func(rID *providerv1beta1.ResourceId)) *Searcher_TrashItem_Call {
_c.Run(run)
return _c
}
// UpsertItem provides a mock function for the type Searcher
func (_mock *Searcher) UpsertItem(ref *providerv1beta1.Reference) {
_mock.Called(ref)
return
}
// Searcher_UpsertItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertItem'
type Searcher_UpsertItem_Call struct {
*mock.Call
}
// UpsertItem is a helper method to define mock.On call
// - ref *providerv1beta1.Reference
func (_e *Searcher_Expecter) UpsertItem(ref interface{}) *Searcher_UpsertItem_Call {
return &Searcher_UpsertItem_Call{Call: _e.mock.On("UpsertItem", ref)}
}
func (_c *Searcher_UpsertItem_Call) Run(run func(ref *providerv1beta1.Reference)) *Searcher_UpsertItem_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *providerv1beta1.Reference
if args[0] != nil {
arg0 = args[0].(*providerv1beta1.Reference)
}
run(
arg0,
)
})
return _c
}
func (_c *Searcher_UpsertItem_Call) Return() *Searcher_UpsertItem_Call {
_c.Call.Return()
return _c
}
func (_c *Searcher_UpsertItem_Call) RunAndReturn(run func(ref *providerv1beta1.Reference)) *Searcher_UpsertItem_Call {
_c.Run(run)
return _c
}
+212
View File
@@ -0,0 +1,212 @@
package search
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/conversions"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/grants"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/log"
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchService "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/content"
)
var scopeRegex = regexp.MustCompile(`scope:\s*([^" "\n\r]*)`)
// Engine is the interface to the search engine
type Engine interface {
Search(ctx context.Context, req *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error)
DocCount() (uint64, error)
Upsert(id string, r Resource) error
Move(id string, parentid string, target string) error
Delete(id string) error
Restore(id string) error
Purge(id string, onlyDeleted bool) error
NewBatch(batchSize int) (BatchOperator, error)
}
type BatchOperator interface {
Upsert(id string, r Resource) error
Move(rootID, parentID, location string) error
Delete(id string) error
Restore(id string) error
Purge(id string, onlyDeleted bool) error
Push() error
}
// Resource is the entity that is stored in the index.
type Resource struct {
content.Document
ID string
RootID string
Path string
ParentID string
Type uint64
Deleted bool
Hidden bool
}
// ResolveReference makes sure the path is relative to the space root
func ResolveReference(ctx context.Context, ref *provider.Reference, ri *provider.ResourceInfo, gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) (*provider.Reference, error) {
if ref.GetResourceId().GetOpaqueId() == ref.GetResourceId().GetSpaceId() {
return ref, nil
}
gatewayClient, err := gatewaySelector.Next()
if err != nil {
return nil, err
}
gpRes, err := gatewayClient.GetPath(ctx, &provider.GetPathRequest{
ResourceId: ri.GetId(),
})
if err != nil || gpRes.GetStatus().GetCode() != rpc.Code_CODE_OK {
return nil, err
}
return &provider.Reference{
ResourceId: &provider.ResourceId{
StorageId: ref.GetResourceId().GetStorageId(),
SpaceId: ref.GetResourceId().GetSpaceId(),
OpaqueId: ref.GetResourceId().GetSpaceId(),
},
Path: utils.MakeRelativePath(gpRes.GetPath()),
}, nil
}
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 {
logger.Error().Err(err).Msg("error getting document count from the index")
}
logger.Debug().Interface("count", c).Msg("new document count")
}
func getAuthContext(serviceAccountID string, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], secret string, logger log.Logger) (context.Context, error) {
gatewayClient, err := gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not get reva gatewayClient")
return nil, err
}
return utils.GetServiceUserContext(serviceAccountID, gatewayClient, secret)
}
func statResource(ctx context.Context, ref *provider.Reference, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], logger log.Logger) (*provider.StatResponse, error) {
gatewayClient, err := gatewaySelector.Next()
if err != nil {
return nil, err
}
res, err := gatewayClient.Stat(ctx, &provider.StatRequest{Ref: ref})
if err != nil {
logger.Error().Err(err).Msg("failed to stat the moved resource")
return nil, err
}
switch res.GetStatus().GetCode() {
case rpc.Code_CODE_OK:
return res, nil
case rpc.Code_CODE_NOT_FOUND:
// Resource was moved or deleted in the meantime. ignore.
return nil, err
default:
err := errors.New("failed to stat the moved resource")
logger.Error().Interface("res", res).Msg(err.Error())
return nil, err
}
}
// NOTE: this converts CS3 to WebDAV permissions
// since conversions pkg is reva internal we have no other choice than to duplicate the logic
func convertToWebDAVPermissions(isShared, isMountpoint, isDir bool, p *provider.ResourcePermissions) string {
if p == nil {
return ""
}
var b strings.Builder
if isShared {
fmt.Fprintf(&b, "S")
}
if p.GetListContainer() &&
p.GetListFileVersions() &&
p.GetListRecycle() &&
p.GetStat() &&
p.GetGetPath() &&
p.GetGetQuota() &&
p.GetInitiateFileDownload() {
fmt.Fprintf(&b, "R")
}
if isMountpoint {
fmt.Fprintf(&b, "M")
}
if p.GetDelete() {
fmt.Fprintf(&b, "D")
}
if p.GetInitiateFileUpload() &&
p.GetRestoreFileVersion() &&
p.GetRestoreRecycleItem() {
fmt.Fprintf(&b, "NV")
if !isDir {
fmt.Fprintf(&b, "W")
}
}
if isDir &&
p.GetListContainer() &&
p.GetStat() &&
p.GetCreateContainer() &&
p.GetInitiateFileUpload() {
fmt.Fprintf(&b, "CK")
}
if grants.PermissionsEqual(p, conversions.NewSecureViewerRole().CS3ResourcePermissions()) {
fmt.Fprintf(&b, "X")
}
return b.String()
}
// ParseScope extract a scope value from the query string and returns search, scope strings
func ParseScope(query string) (string, string) {
match := scopeRegex.FindStringSubmatch(query)
if len(match) >= 2 {
cut := match[0]
return strings.TrimSpace(strings.ReplaceAll(query, cut, "")), strings.TrimSpace(match[1])
}
return query, ""
}
// ParseFlags extracts supported flags from the query string and returns the cleaned query and a map of flags
func ParseFlags(query string) (string, []string) {
supportedFlags := []string{"is:favorite"}
flags := []string{}
for _, flag := range supportedFlags {
if strings.Contains(query, flag) {
flags = append(flags, flag)
query = strings.ReplaceAll(query, flag, "")
}
}
return strings.TrimSpace(query), flags
}
@@ -0,0 +1,25 @@
package search_test
import (
"testing"
"github.com/qsfera/server/pkg/registry"
mRegistry "go-micro.dev/v4/registry"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func init() {
r := registry.GetRegistry(registry.Inmemory())
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
service.Nodes = []*mRegistry.Node{{
Address: "any",
}}
_ = r.Register(service)
}
func TestSearch(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Search Suite")
}
@@ -0,0 +1,765 @@
package search
import (
"context"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
sdk "github.com/opencloud-eu/reva/v2/pkg/sdk/common"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/walker"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"github.com/qsfera/server/pkg/log"
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/content"
"github.com/qsfera/server/services/search/pkg/metrics"
)
const (
_spaceStateTrashed = "trashed"
_spaceTypeMountpoint = "mountpoint"
_spaceTypePersonal = "personal"
_spaceTypeProject = "project"
_spaceTypeGrant = "grant"
_slowQueryDuration = 500 * time.Millisecond
)
// Searcher is the interface to the SearchService
type Searcher interface {
Search(ctx context.Context, req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error)
IndexSpace(rID *provider.StorageSpaceId, forceRescan bool) error
PurgeDeleted(spaceID *provider.StorageSpaceId) error
TrashItem(rID *provider.ResourceId)
PurgeItem(rID *provider.Reference)
UpsertItem(ref *provider.Reference)
RestoreItem(ref *provider.Reference)
MoveItem(ref *provider.Reference)
}
// Service is responsible for indexing spaces and pass on a search
// to it's underlying engine.
type Service struct {
logger log.Logger
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
engine Engine
extractor content.Extractor
metrics *metrics.Metrics
serviceAccountID string
serviceAccountSecret string
batchSize int
}
var errSkipSpace error
// NewService creates a new Provider instance.
func NewService(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], eng Engine, extractor content.Extractor, metrics *metrics.Metrics, logger log.Logger, cfg *config.Config) *Service {
var s = &Service{
gatewaySelector: gatewaySelector,
engine: eng,
logger: logger,
extractor: extractor,
metrics: metrics,
serviceAccountID: cfg.ServiceAccount.ServiceAccountID,
serviceAccountSecret: cfg.ServiceAccount.ServiceAccountSecret,
batchSize: cfg.BatchSize,
}
return s
}
// Search processes a search request and passes it down to the engine.
func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
s.logger.Debug().Str("query", req.Query).Msg("performing a search")
// collect metrics
startTime := time.Now()
success := false
defer func() {
if s.metrics == nil {
return
}
status := "success"
if !success {
status = "error"
}
s.metrics.SearchDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())
}()
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
currentUser := revactx.ContextMustGetUser(ctx)
// Handle flags
query, flags := ParseFlags(req.Query)
for _, flag := range flags {
switch flag {
case "is:favorite":
query += " Favorites:\"" + currentUser.GetId().GetOpaqueId() + "\""
}
}
// Extract scope from query if set
query, scope := ParseScope(query)
if query == "" {
return nil, errtypes.BadRequest("empty query provided")
}
req.Query = query
if len(scope) > 0 {
scopedID, err := storagespace.ParseID(scope)
if err != nil {
s.logger.Error().Err(err).Msg("failed to parse scope")
}
// Stat the scope to get the resource id
statRes, err := gatewayClient.Stat(ctx, &provider.StatRequest{
Ref: &provider.Reference{
ResourceId: &scopedID,
},
FieldMask: &fieldmaskpb.FieldMask{Paths: []string{"space"}},
})
if err != nil {
return nil, err
}
// GetPath the scope to get the full path in the space
gpRes, err := gatewayClient.GetPath(ctx, &provider.GetPathRequest{
ResourceId: statRes.GetInfo().GetId(),
})
if err != nil {
return nil, err
}
req.Ref = &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: statRes.GetInfo().GetSpace().GetRoot().GetStorageId(),
SpaceId: statRes.GetInfo().GetSpace().GetRoot().GetSpaceId(),
OpaqueId: statRes.GetInfo().GetSpace().GetRoot().GetOpaqueId(),
},
Path: gpRes.Path,
}
}
filters := []*provider.ListStorageSpacesRequest_Filter{
{
Type: provider.ListStorageSpacesRequest_Filter_TYPE_USER,
Term: &provider.ListStorageSpacesRequest_Filter_User{User: currentUser.GetId()},
},
{
Type: provider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE,
Term: &provider.ListStorageSpacesRequest_Filter_SpaceType{SpaceType: "+grant"},
},
}
// Get the spaces to search
spaces := []*provider.StorageSpace{}
listSpacesRes, err := gatewayClient.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{Filters: filters})
if err != nil {
s.logger.Error().Err(err).Msg("failed to list the user's storage spaces")
return nil, err
}
for _, space := range listSpacesRes.StorageSpaces {
if utils.ReadPlainFromOpaque(space.Opaque, "trashed") == _spaceStateTrashed {
// Do not consider disabled spaces
continue
}
if space.SpaceType != "mountpoint" && req.Ref != nil && (req.Ref.GetResourceId().GetSpaceId() != space.Root.GetSpaceId()) {
// Do not search (non-mountpoint) spaces that do not match the given scope (if a scope is set)
// We still need the mountpoint in order to map the result paths to the according share
continue
}
spaces = append(spaces, space)
}
mountpointMap := map[string]string{}
for _, space := range spaces {
if space.SpaceType != _spaceTypeMountpoint {
continue
}
opaqueMap := sdk.DecodeOpaqueMap(space.Opaque)
grantSpaceID := storagespace.FormatResourceID(&provider.ResourceId{
StorageId: opaqueMap["grantStorageID"],
SpaceId: opaqueMap["grantSpaceID"],
OpaqueId: opaqueMap["grantOpaqueID"],
})
mountpointMap[grantSpaceID] = space.Id.OpaqueId
}
matches := matchArray{}
total := int32(0)
errg, ctx := errgroup.WithContext(ctx)
work := make(chan *provider.StorageSpace, len(spaces))
results := make(chan *searchsvc.SearchIndexResponse, len(spaces))
// Distribute work
errg.Go(func() error {
defer close(work)
for _, space := range spaces {
select {
case work <- space:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
})
// Spawn workers that'll concurrently work the queue
numWorkers := 20
if len(spaces) < numWorkers {
numWorkers = len(spaces)
}
for i := 0; i < numWorkers; i++ {
errg.Go(func() error {
for space := range work {
res, err := s.searchIndex(ctx, req, space, mountpointMap[space.Id.OpaqueId])
if err != nil && err != errSkipSpace {
return err
}
select {
case results <- res:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
})
}
// Wait for things to settle down, then close results chan
go func() {
_ = errg.Wait() // error is checked later
close(results)
}()
responses := make([]*searchsvc.SearchIndexResponse, len(spaces))
i := 0
for r := range results {
responses[i] = r
i++
}
if err := errg.Wait(); err != nil {
return nil, err
}
for _, res := range responses {
if res == nil {
continue
}
total += res.TotalMatches
for _, match := range res.Matches {
matches = append(matches, match)
}
}
// compile one sorted list of matches from all spaces and apply the limit if needed
sort.Sort(matches)
limit := req.PageSize
if limit == 0 {
limit = 200
}
if int32(len(matches)) > limit && limit != -1 {
matches = matches[0:limit]
}
success = true
return &searchsvc.SearchResponse{
Matches: matches,
TotalMatches: total,
}, nil
}
func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest, space *provider.StorageSpace, mountpointID string) (*searchsvc.SearchIndexResponse, error) {
if req.Ref != nil &&
(req.Ref.ResourceId.StorageId != space.Root.StorageId ||
req.Ref.ResourceId.SpaceId != space.Root.SpaceId) {
return nil, errSkipSpace
}
searchRootID := &searchmsg.ResourceID{
StorageId: space.Root.StorageId,
SpaceId: space.Root.SpaceId,
OpaqueId: space.Root.OpaqueId,
}
var (
mountpointRootID *searchmsg.ResourceID
rootName string
permissions *provider.ResourcePermissions
remoteItemId *searchmsg.ResourceID
)
mountpointPrefix := ""
searchPathPrefix := req.Ref.GetPath()
switch space.SpaceType {
case _spaceTypeMountpoint:
return nil, errSkipSpace // mountpoint spaces are only "links" to the shared spaces. we have to search the shared "grant" space instead
case _spaceTypeGrant:
// In case of grant spaces we search the root of the outer space and translate the paths to the according mountpoint
searchRootID.OpaqueId = space.Root.SpaceId
if mountpointID == "" {
s.logger.Warn().Interface("space", space).Msg("could not find mountpoint space for grant space")
return nil, errSkipSpace
}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
serviceCtx, err := getAuthContext(s.serviceAccountID, s.gatewaySelector, s.serviceAccountSecret, s.logger)
if err != nil {
return nil, err
}
gpRes, err := gatewayClient.GetPath(serviceCtx, &provider.GetPathRequest{
ResourceId: space.Root,
})
if err != nil {
s.logger.Error().Err(err).Str("space", space.Id.OpaqueId).Msg("failed to get path for grant space root")
return nil, errSkipSpace
}
if gpRes.Status.Code != rpcv1beta1.Code_CODE_OK {
s.logger.Error().Interface("status", gpRes.Status).Str("space", space.Id.OpaqueId).Msg("failed to get path for grant space root")
return nil, errSkipSpace
}
mountpointPrefix = utils.MakeRelativePath(gpRes.Path)
if searchPathPrefix == "" {
searchPathPrefix = mountpointPrefix
}
sid, spid, oid, err := storagespace.SplitID(mountpointID)
if err != nil {
s.logger.Error().Err(err).Str("space", space.Id.OpaqueId).Str("mountpointId", mountpointID).Msg("invalid mountpoint space id")
return nil, errSkipSpace
}
// exclude the hidden shares
rs, err := gatewayClient.GetReceivedShare(ctx, &collaborationv1beta1.GetReceivedShareRequest{
Ref: &collaborationv1beta1.ShareReference{
Spec: &collaborationv1beta1.ShareReference_Id{
Id: &collaborationv1beta1.ShareId{
OpaqueId: oid,
},
},
},
})
if err != nil {
s.logger.Error().Err(err).Str("space", space.Id.OpaqueId).Str("shareId", oid).Msg("invalid receive share")
}
if rs.GetStatus().GetCode() == rpcv1beta1.Code_CODE_OK && rs.GetShare().GetHidden() {
return nil, errSkipSpace
}
mountpointRootID = &searchmsg.ResourceID{
StorageId: sid,
SpaceId: spid,
OpaqueId: oid,
}
rootName = space.GetRootInfo().GetPath()
permissions = space.GetRootInfo().GetPermissionSet()
remoteItemId = &searchmsg.ResourceID{
StorageId: space.GetRootInfo().GetId().GetStorageId(),
SpaceId: space.GetRootInfo().GetId().GetSpaceId(),
OpaqueId: space.GetRootInfo().GetId().GetOpaqueId(),
}
s.logger.Debug().Interface("grantSpace", space).Interface("mountpointRootId", mountpointRootID).Msg("searching a grant")
case _spaceTypePersonal, _spaceTypeProject:
permissions = space.GetRootInfo().GetPermissionSet()
}
searchRequest := &searchsvc.SearchIndexRequest{
Query: req.Query,
Ref: &searchmsg.Reference{
ResourceId: searchRootID,
Path: searchPathPrefix,
},
PageSize: req.PageSize,
}
start := time.Now()
res, err := s.engine.Search(ctx, searchRequest)
duration := time.Since(start)
if err != nil {
s.logger.Error().Err(err).Str("duration", fmt.Sprint(duration)).Str("space", space.Id.OpaqueId).Msg("failed to search the index")
return nil, err
}
if duration > _slowQueryDuration {
s.logger.Info().Interface("searchRequest", searchRequest).Str("duration", fmt.Sprint(duration)).Str("space", space.Id.OpaqueId).Int("hits", len(res.Matches)).Msg("slow space search")
} else {
s.logger.Debug().Interface("searchRequest", searchRequest).Str("duration", fmt.Sprint(duration)).Str("space", space.Id.OpaqueId).Int("hits", len(res.Matches)).Msg("space search done")
}
matches := make([]*searchmsg.Match, 0, len(res.Matches))
for _, match := range res.Matches {
if mountpointPrefix != "" {
match.Entity.Ref.Path = utils.MakeRelativePath(strings.TrimPrefix(match.Entity.Ref.Path, mountpointPrefix))
}
if mountpointRootID != nil {
match.Entity.Ref.ResourceId = mountpointRootID
}
match.Entity.ShareRootName = rootName
match.Entity.RemoteItemId = remoteItemId
isShared := match.GetEntity().GetRef().GetResourceId().GetSpaceId() == utils.ShareStorageSpaceID
isMountpoint := isShared && match.GetEntity().GetRef().GetPath() == "."
isDir := match.GetEntity().GetMimeType() == "httpd/unix-directory"
match.Entity.Permissions = convertToWebDAVPermissions(isShared, isMountpoint, isDir, permissions)
if req.Ref != nil && searchPathPrefix == "/"+match.Entity.Name {
continue
}
matches = append(matches, match)
}
res.Matches = matches
return res, nil
}
// IndexSpace (re)indexes all resources of a given space.
func (s *Service) IndexSpace(spaceID *provider.StorageSpaceId, forceRescan bool) error {
ownerCtx, err := getAuthContext(s.serviceAccountID, s.gatewaySelector, s.serviceAccountSecret, s.logger)
if err != nil {
return err
}
rootID, err := storagespace.ParseID(spaceID.OpaqueId)
if err != nil {
s.logger.Error().Err(err).Msg("invalid space id")
return err
}
if rootID.StorageId == "" || rootID.SpaceId == "" {
s.logger.Error().Err(err).Msg("invalid space id")
return fmt.Errorf("invalid space id")
}
rootID.OpaqueId = rootID.SpaceId
// Collect metrics
startTime := time.Now()
success := false
defer func() {
if s.metrics == nil {
return
}
status := "success"
if !success {
status = "error"
}
s.metrics.IndexDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())
}()
w := walker.NewWalker(s.gatewaySelector)
batch, err := s.engine.NewBatch(s.batchSize)
if err != nil {
return err
}
defer func() {
if err := batch.Push(); err != nil {
s.logger.Error().Err(err).Msg("failed to end batch")
}
logDocCount(s.engine, s.logger)
}()
err = w.Walk(ownerCtx, &rootID, func(wd string, info *provider.ResourceInfo, err error) error {
if err != nil {
s.logger.Error().Err(err).Msg("error walking the tree")
return err
}
if info == nil {
return nil
}
ref := &provider.Reference{
Path: utils.MakeRelativePath(filepath.Join(wd, info.Path)),
ResourceId: &rootID,
}
s.logger.Debug().Str("path", ref.Path).Msg("Walking tree")
if forceRescan {
s.doUpsertItem(ref, batch)
return nil
}
searchRes, err := s.engine.Search(ownerCtx, &searchsvc.SearchIndexRequest{
Query: "id:" + storagespace.FormatResourceID(info.Id) + ` mtime>=` + utils.TSToTime(info.Mtime).Format(time.RFC3339Nano),
})
if err == nil && len(searchRes.Matches) >= 1 {
if info.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER {
s.logger.Debug().Str("path", ref.Path).Msg("subtree hasn't changed. Skipping.")
return filepath.SkipDir
}
s.logger.Debug().Str("path", ref.Path).Msg("element hasn't changed. Skipping.")
return nil
}
s.doUpsertItem(ref, batch)
return nil
})
if err != nil {
return err
}
success = true
return nil
}
// TrashItem marks the item as deleted.
func (s *Service) TrashItem(rID *provider.ResourceId) {
if err := s.engine.Delete(storagespace.FormatResourceID(rID)); err != nil {
s.logger.Error().Err(err).Interface("Id", rID).Msg("failed to remove item from index")
}
}
func (s *Service) PurgeItem(ref *provider.Reference) {
if ref.Path != "" && ref.Path != "." {
s.logger.Warn().Str("path", ref.Path).Msg("purging an item with a path is not supported")
return
}
err := s.engine.Purge(storagespace.FormatResourceID(ref.ResourceId), false)
if err != nil {
s.logger.Error().Err(err).Interface("Id", ref.ResourceId).Msg("failed to purge item from index")
return
}
s.logger.Info().Interface("Id", ref.ResourceId).Msg("purged item from index")
logDocCount(s.engine, s.logger)
}
func (s *Service) PurgeDeleted(spaceID *provider.StorageSpaceId) error {
if spaceID == nil {
return fmt.Errorf("spaceID must not be nil")
}
rootID, err := storagespace.ParseID(spaceID.OpaqueId)
if err != nil {
s.logger.Error().Err(err).Msg("invalid space id")
return err
}
if rootID.StorageId == "" || rootID.SpaceId == "" {
s.logger.Error().Err(err).Msg("invalid space id")
return fmt.Errorf("invalid space id")
}
rootID.OpaqueId = rootID.SpaceId
if err := s.engine.Purge(storagespace.FormatResourceID(&rootID), true); err != nil {
s.logger.Error().Err(err).Interface("Id", &rootID).Msg("failed to purge deleted items from index")
return err
}
logDocCount(s.engine, s.logger)
return nil
}
// UpsertItem indexes or stores Resource data fields.
func (s *Service) UpsertItem(ref *provider.Reference) {
s.doUpsertItem(ref, nil)
}
// doUpsertItem indexes or stores Resource data fields.
func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) {
ctx, stat, path := s.resInfo(ref)
if ctx == nil || stat == nil || path == "" {
return
}
doc, err := s.extractor.Extract(ctx, stat.Info)
if err != nil {
s.logger.Error().Err(err).Msg("failed to extract resource content")
return
}
r := Resource{
ID: storagespace.FormatResourceID(stat.Info.Id),
RootID: storagespace.FormatResourceID(&provider.ResourceId{
StorageId: stat.Info.Id.StorageId,
OpaqueId: stat.Info.Id.SpaceId,
SpaceId: stat.Info.Id.SpaceId,
}),
Path: utils.MakeRelativePath(path),
Type: uint64(stat.Info.Type),
Document: doc,
}
r.Hidden = strings.HasPrefix(r.Path, ".")
if parentID := stat.GetInfo().GetParentId(); parentID != nil {
r.ParentID = storagespace.FormatResourceID(parentID)
}
if batch != nil {
err = batch.Upsert(r.ID, r)
} else {
err = s.engine.Upsert(r.ID, r)
}
if err != nil {
s.logger.Error().Err(err).Msg("error adding updating the resource in the index")
} else {
logDocCount(s.engine, s.logger)
}
// determine if metadata needs to be stored in storage as well
metadata := map[string]string{}
addAudioMetadata(metadata, doc.Audio)
addImageMetadata(metadata, doc.Image)
addLocationMetadata(metadata, doc.Location)
addPhotoMetadata(metadata, doc.Photo)
if len(metadata) == 0 {
return
}
s.logger.Trace().Str("name", doc.Name).Interface("metadata", metadata).Msg("Storing metadata")
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
s.logger.Error().Err(err).Msg("could not retrieve client to store metadata")
return
}
resp, err := gatewayClient.SetArbitraryMetadata(ctx, &provider.SetArbitraryMetadataRequest{
Ref: ref,
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: metadata,
},
})
if err != nil || resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
s.logger.Error().Err(err).Int32("status", int32(resp.GetStatus().GetCode())).Msg("error storing metadata")
return
}
}
func addAudioMetadata(metadata map[string]string, audio *libregraph.Audio) {
if audio == nil {
return
}
marshalToStringMap(audio, metadata, "libre.graph.audio.")
}
func addImageMetadata(metadata map[string]string, image *libregraph.Image) {
if image == nil {
return
}
marshalToStringMap(image, metadata, "libre.graph.image.")
}
func addLocationMetadata(metadata map[string]string, location *libregraph.GeoCoordinates) {
if location == nil {
return
}
marshalToStringMap(location, metadata, "libre.graph.location.")
}
func addPhotoMetadata(metadata map[string]string, photo *libregraph.Photo) {
if photo == nil {
return
}
marshalToStringMap(photo, metadata, "libre.graph.photo.")
}
func marshalToStringMap[T libregraph.MappedNullable](source T, target map[string]string, prefix string) {
// ToMap never returns a non-nil error ...
m, _ := source.ToMap()
for k, v := range m {
if v == nil {
continue
}
target[prefix+k] = valueToString(v)
}
}
func valueToString(value any) string {
if value == nil {
return ""
}
switch v := value.(type) {
case *string:
return *v
case *int32:
return strconv.FormatInt(int64(*v), 10)
case *int64:
return strconv.FormatInt(*v, 10)
case *float32:
return strconv.FormatFloat(float64(*v), 'f', -1, 32)
case *float64:
return strconv.FormatFloat(*v, 'f', -1, 64)
case *bool:
return strconv.FormatBool(*v)
case *time.Time:
return v.Format(time.RFC3339)
default:
return fmt.Sprintf("%v", v)
}
}
// RestoreItem makes the item available again.
func (s *Service) RestoreItem(ref *provider.Reference) {
ctx, stat, path := s.resInfo(ref)
if ctx == nil || stat == nil || path == "" {
return
}
if err := s.engine.Restore(storagespace.FormatResourceID(stat.Info.Id)); err != nil {
s.logger.Error().Err(err).Msg("failed to restore the changed resource in the index")
}
}
// MoveItem updates the resource location and all of its necessary fields.
func (s *Service) MoveItem(ref *provider.Reference) {
ctx, stat, path := s.resInfo(ref)
if ctx == nil || stat == nil || path == "" {
return
}
if err := s.engine.Move(storagespace.FormatResourceID(stat.GetInfo().GetId()), storagespace.FormatResourceID(stat.GetInfo().GetParentId()), path); err != nil {
s.logger.Error().Err(err).Msg("failed to move the changed resource in the index")
}
}
func (s *Service) resInfo(ref *provider.Reference) (context.Context, *provider.StatResponse, string) {
ownerCtx, err := getAuthContext(s.serviceAccountID, s.gatewaySelector, s.serviceAccountSecret, s.logger)
if err != nil {
return nil, nil, ""
}
statRes, err := statResource(ownerCtx, ref, s.gatewaySelector, s.logger)
if err != nil {
return nil, nil, ""
}
r, err := ResolveReference(ownerCtx, ref, statRes.GetInfo(), s.gatewaySelector)
if err != nil {
return nil, nil, ""
}
return ownerCtx, statRes, r.GetPath()
}
@@ -0,0 +1,539 @@
package search_test
import (
"context"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/log"
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/content"
contentMocks "github.com/qsfera/server/services/search/pkg/content/mocks"
"github.com/qsfera/server/services/search/pkg/search"
engineMocks "github.com/qsfera/server/services/search/pkg/search/mocks"
)
var _ = Describe("Searchprovider", func() {
var (
s search.Searcher
extractor *contentMocks.Extractor
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
indexClient *engineMocks.Engine
ctx context.Context
logger = log.NewLogger()
user = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
otherUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "otheruser",
},
}
personalSpace = &sprovider.StorageSpace{
Opaque: &typesv1beta1.Opaque{
Map: map[string]*typesv1beta1.OpaqueEntry{
"path": {
Decoder: "plain",
Value: []byte("/foo"),
},
},
},
Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$personalspace!personalspace"},
Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "personalspace", OpaqueId: "personalspace"},
Name: "personalspace",
SpaceType: "personal",
}
ri = &sprovider.ResourceInfo{
Id: &sprovider.ResourceId{
StorageId: "storageid",
OpaqueId: "opaqueid",
},
ParentId: &sprovider.ResourceId{
StorageId: "storageid",
OpaqueId: "parentopaqueid",
},
Path: "foo.pdf",
Size: 12345,
Mtime: &typesv1beta1.Timestamp{Seconds: 4000},
}
)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
ctx = revactx.ContextSetUser(context.Background(), user)
indexClient = &engineMocks.Engine{}
extractor = &contentMocks.Extractor{}
s = search.NewService(gatewaySelector, indexClient, extractor, nil, logger, &config.Config{})
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
Status: status.NewOK(ctx),
Token: "authtoken",
}, nil)
gatewayClient.On("GetPath", mock.Anything, mock.MatchedBy(func(req *sprovider.GetPathRequest) bool {
return req.ResourceId.OpaqueId == ri.Id.OpaqueId
})).Return(&sprovider.GetPathResponse{
Status: status.NewOK(context.Background()),
Path: ri.Path,
}, nil)
gatewayClient.On("GetReceivedShare", mock.Anything, mock.Anything).Return(&collaborationv1beta1.GetReceivedShareResponse{
Status: status.NewOK(ctx),
}, nil)
indexClient.On("DocCount").Return(uint64(1), nil)
})
Describe("New", func() {
It("returns a new instance", func() {
s := search.NewService(gatewaySelector, indexClient, extractor, nil, logger, &config.Config{})
Expect(s).ToNot(BeNil())
})
})
Describe("IndexSpace", func() {
It("walks the space and indexes all files", func() {
batch := &engineMocks.BatchOperator{}
batch.EXPECT().Push().Return(nil)
gatewayClient.On("GetUserByClaim", mock.Anything, mock.Anything).Return(&userv1beta1.GetUserByClaimResponse{
Status: status.NewOK(context.Background()),
User: user,
}, nil)
extractor.On("Extract", mock.Anything, mock.Anything, mock.Anything).Return(content.Document{}, nil)
indexClient.On("NewBatch", mock.Anything).Return(batch, nil)
batch.On("Upsert", mock.Anything, mock.Anything).Return(nil)
indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{}, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: ri,
}, nil)
err := s.IndexSpace(&sprovider.StorageSpaceId{OpaqueId: "storageid$spaceid!spaceid"}, false)
Expect(err).ShouldNot(HaveOccurred())
})
})
Describe("Search", func() {
It("fails when an empty query is given", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "",
})
Expect(err).To(HaveOccurred())
Expect(res).To(BeNil())
})
Context("with a personal space", func() {
BeforeEach(func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*sprovider.StorageSpace{personalSpace},
}, nil)
indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 1,
Matches: []*searchmsg.Match{
{
Score: 1,
Entity: &searchmsg.Entity{
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: personalSpace.Root.StorageId,
SpaceId: personalSpace.Root.SpaceId,
OpaqueId: personalSpace.Root.OpaqueId,
},
Path: "./path/to/Foo.pdf",
},
Id: &searchmsg.ResourceID{
StorageId: personalSpace.Root.StorageId,
OpaqueId: "foo-id",
},
Name: "Foo.pdf",
},
},
},
}, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: ri,
}, nil)
})
It("does not mess with field-based searches", func() {
_, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "Size:<10",
})
Expect(err).ToNot(HaveOccurred())
indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return req.Query == "Size:<10"
}))
})
It("searches the personal user space", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "foo",
})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeNil())
Expect(res.TotalMatches).To(Equal(int32(1)))
Expect(len(res.Matches)).To(Equal(1))
match := res.Matches[0]
Expect(match.Entity.Id.OpaqueId).To(Equal("foo-id"))
Expect(match.Entity.Name).To(Equal("Foo.pdf"))
Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(personalSpace.Root.OpaqueId))
Expect(match.Entity.Ref.Path).To(Equal("./path/to/Foo.pdf"))
})
})
Context("with a personal space with a filter", func() {
BeforeEach(func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*sprovider.StorageSpace{personalSpace},
}, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: &sprovider.ResourceInfo{
Space: &sprovider.StorageSpace{Root: &sprovider.ResourceId{
StorageId: "storageid",
SpaceId: "personalspace",
OpaqueId: "personalspace",
}},
},
}, nil)
gatewayClient.On("GetPath", mock.Anything, mock.Anything).Return(&sprovider.GetPathResponse{
Status: status.NewOK(ctx),
Path: "/path",
}, nil)
indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 1,
Matches: []*searchmsg.Match{
{
Score: 1,
Entity: &searchmsg.Entity{
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: personalSpace.Root.StorageId,
SpaceId: personalSpace.Root.SpaceId,
OpaqueId: personalSpace.Root.OpaqueId,
},
Path: "./path/to/Foo.pdf",
},
Id: &searchmsg.ResourceID{
StorageId: personalSpace.Root.StorageId,
OpaqueId: "foo-id",
},
Name: "Foo.pdf",
},
},
},
}, nil)
})
It("searches the personal user space", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "foo scope:storageid$personalspace!personalspace/path",
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: "storageid",
SpaceId: "personalspace",
OpaqueId: "personalspace",
},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeNil())
Expect(res.TotalMatches).To(Equal(int32(1)))
Expect(len(res.Matches)).To(Equal(1))
match := res.Matches[0]
Expect(match.Entity.Id.OpaqueId).To(Equal("foo-id"))
Expect(match.Entity.Name).To(Equal("Foo.pdf"))
Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(personalSpace.Root.OpaqueId))
Expect(match.Entity.Ref.Path).To(Equal("./path/to/Foo.pdf"))
})
})
Context("with received shares", func() {
var (
grantSpace *sprovider.StorageSpace
mountpointSpace *sprovider.StorageSpace
)
BeforeEach(func() {
grantSpace = &sprovider.StorageSpace{
SpaceType: "grant",
Owner: otherUser,
Id: &sprovider.StorageSpaceId{OpaqueId: "storageproviderid$spaceid!otherspacegrant"},
Root: &sprovider.ResourceId{StorageId: "storageproviderid", SpaceId: "spaceid", OpaqueId: "otherspacegrant"},
Name: "grantspace",
RootInfo: &sprovider.ResourceInfo{
Id: &sprovider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "opaqueid",
},
},
}
mountpointSpace = &sprovider.StorageSpace{
SpaceType: "mountpoint",
Owner: otherUser,
Id: &sprovider.StorageSpaceId{OpaqueId: "storageproviderid$spaceid!otherspacemountpoint"},
Root: &sprovider.ResourceId{StorageId: "storageproviderid", SpaceId: "spaceid", OpaqueId: "otherspacemountpoint"},
Name: "mountpointspace",
Opaque: &typesv1beta1.Opaque{
Map: map[string]*typesv1beta1.OpaqueEntry{
"grantStorageID": {Decoder: "plain", Value: []byte("storageproviderid")},
"grantSpaceID": {Decoder: "plain", Value: []byte("spaceid")},
"grantOpaqueID": {Decoder: "plain", Value: []byte("otherspacegrant")},
},
},
}
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: ri,
}, nil)
gatewayClient.On("GetPath", mock.Anything, mock.Anything).Return(&sprovider.GetPathResponse{
Status: status.NewOK(ctx),
Path: "/grant/path",
}, nil)
})
It("searches the received spaces", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*sprovider.StorageSpace{grantSpace, mountpointSpace},
}, nil)
indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 1,
Matches: []*searchmsg.Match{
{
Entity: &searchmsg.Entity{
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: grantSpace.Root.StorageId,
SpaceId: grantSpace.Root.SpaceId,
OpaqueId: grantSpace.Root.OpaqueId,
},
Path: "./grant/path/to/Shared.pdf",
},
Id: &searchmsg.ResourceID{
StorageId: grantSpace.Root.StorageId,
OpaqueId: "grant-shared-id",
},
Name: "Shared.pdf",
},
},
},
}, nil)
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "Foo",
})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeNil())
Expect(len(res.Matches)).To(Equal(1))
match := res.Matches[0]
Expect(match.Entity.Id.OpaqueId).To(Equal("grant-shared-id"))
Expect(match.Entity.Name).To(Equal("Shared.pdf"))
Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(mountpointSpace.Root.OpaqueId))
Expect(match.Entity.Ref.Path).To(Equal("./to/Shared.pdf"))
Expect(match.Entity.RemoteItemId).To(Equal(&searchmsg.ResourceID{
StorageId: grantSpace.RootInfo.Id.StorageId,
SpaceId: grantSpace.RootInfo.Id.SpaceId,
OpaqueId: grantSpace.RootInfo.Id.OpaqueId,
}))
})
Context("when searching both spaces", func() {
BeforeEach(func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*sprovider.StorageSpace{personalSpace, grantSpace, mountpointSpace},
}, nil)
indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return req.Ref.ResourceId.OpaqueId == grantSpace.Root.SpaceId &&
req.Ref.ResourceId.SpaceId == grantSpace.Root.SpaceId
})).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 2,
Matches: []*searchmsg.Match{
{
Score: 2,
Entity: &searchmsg.Entity{
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: grantSpace.Root.StorageId,
SpaceId: grantSpace.Root.SpaceId,
OpaqueId: grantSpace.Root.OpaqueId,
},
Path: "./grant/path/to/Shared.pdf",
},
Id: &searchmsg.ResourceID{
StorageId: grantSpace.Root.StorageId,
OpaqueId: "grant-shared-id",
},
Name: "Shared.pdf",
},
},
{
Score: 0.01,
Entity: &searchmsg.Entity{
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: grantSpace.Root.StorageId,
SpaceId: grantSpace.Root.SpaceId,
OpaqueId: grantSpace.Root.OpaqueId,
},
Path: "./grant/path/to/Irrelevant.pdf",
},
Id: &searchmsg.ResourceID{
StorageId: grantSpace.Root.StorageId,
OpaqueId: "grant-irrelevant-id",
},
Name: "Irrelevant.pdf",
},
},
},
}, nil)
indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return req.Ref.ResourceId.OpaqueId == personalSpace.Root.OpaqueId &&
req.Ref.ResourceId.SpaceId == personalSpace.Root.SpaceId
})).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 1,
Matches: []*searchmsg.Match{
{
Score: 1,
Entity: &searchmsg.Entity{
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: personalSpace.Root.StorageId,
SpaceId: personalSpace.Root.SpaceId,
OpaqueId: personalSpace.Root.OpaqueId,
},
Path: "./path/to/Foo.pdf",
},
Id: &searchmsg.ResourceID{
StorageId: personalSpace.Root.StorageId,
OpaqueId: "foo-id",
},
Name: "Foo.pdf",
},
},
},
}, nil)
})
It("considers the search Ref parameter", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "foo",
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: "storageid",
SpaceId: "personalspace",
OpaqueId: "personalspace",
},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeNil())
Expect(len(res.Matches)).To(Equal(1))
Expect(res.Matches[0].Entity.Id.OpaqueId).To(Equal("foo-id"))
})
It("finds matches in both the personal space AND the grant", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "foo",
})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeNil())
Expect(len(res.Matches)).To(Equal(3))
ids := []string{res.Matches[0].Entity.Id.OpaqueId, res.Matches[1].Entity.Id.OpaqueId, res.Matches[2].Entity.Id.OpaqueId}
Expect(ids).To(ConsistOf("foo-id", "grant-shared-id", "grant-irrelevant-id"))
})
It("sorts and limits the combined results from all spaces", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "foo",
PageSize: 2,
})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeNil())
Expect(len(res.Matches)).To(Equal(2))
ids := []string{res.Matches[0].Entity.Id.OpaqueId, res.Matches[1].Entity.Id.OpaqueId}
Expect(ids).To(Equal([]string{"grant-shared-id", "foo-id"}))
})
})
})
})
})
var _ = DescribeTable("Parse Scope",
func(pattern, wantSearch, wantScope string) {
gotSearch, gotScope := search.ParseScope(pattern)
Expect(gotSearch).To(Equal(wantSearch))
Expect(gotScope).To(Equal(wantScope))
},
Entry("When scope is at the end of the line",
`+Name:*file* +Tags:&quot;foo&quot; scope:<uuid>/folder/subfolder`,
`+Name:*file* +Tags:&quot;foo&quot;`,
`<uuid>/folder/subfolder`,
),
Entry("When scope is at the end of the line 2",
`+Name:*file* +Tags:&quot;foo&quot; scope:<uuid>/folder`,
`+Name:*file* +Tags:&quot;foo&quot;`,
`<uuid>/folder`,
),
Entry("When scope is at the end of the line 3",
`file scope:<uuid>/folder/subfolder`,
`file`,
`<uuid>/folder/subfolder`,
),
Entry("When scope is at the end of the line with a space",
`+Name:*file* +Tags:&quot;foo&quot; scope: <uuid>/folder/subfolder`,
`+Name:*file* +Tags:&quot;foo&quot;`,
`<uuid>/folder/subfolder`,
),
Entry("When scope is in the middle of the line",
`+Name:*file* scope:<uuid>/folder/subfolder +Tags:&quot;foo&quot;`,
`+Name:*file* +Tags:&quot;foo&quot;`,
`<uuid>/folder/subfolder`,
),
Entry("When scope is at the end of the line",
`scope:<uuid>/folder/subfolder +Name:*file*`,
`+Name:*file*`,
`<uuid>/folder/subfolder`,
),
Entry("When scope is at the begging of the line",
`scope:<uuid>/folder/subfolder file`,
`file`,
`<uuid>/folder/subfolder`,
),
Entry("When no scope",
`+Name:*file* +Tags:&quot;foo&quot;`,
`+Name:*file* +Tags:&quot;foo&quot;`,
``,
),
)
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,46 @@
package debug
import (
"context"
"net/http"
"net/url"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("grpc reachability", checks.NewGRPCCheck(options.Config.GRPC.Addr))
readyHandlerConfiguration := healthHandlerConfiguration.
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint)).
WithCheck("tika-check", func(ctx context.Context) error {
if options.Config.Extractor.Type == "tika" {
u, err := url.Parse(options.Config.Extractor.Tika.TikaURL)
if err != nil {
return err
}
return checks.NewTCPCheck(u.Host)(ctx)
}
return nil
})
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Address(options.Config.Debug.Addr),
debug.Token(options.Config.Debug.Token),
debug.Pprof(options.Config.Debug.Pprof),
debug.Zpages(options.Config.Debug.Zpages),
debug.Health(handlers.NewCheckHandler(healthHandlerConfiguration)),
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
), nil
}
@@ -0,0 +1,113 @@
package grpc
import (
"context"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go.opentelemetry.io/otel/trace"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/metrics"
"github.com/qsfera/server/services/search/pkg/search"
svc "github.com/qsfera/server/services/search/pkg/service/grpc/v0"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Name string
Logger log.Logger
Context context.Context
Config *config.Config
Metrics *metrics.Metrics
Handler *svc.Service
JWTSecret string
TraceProvider trace.TracerProvider
GatewaySelector *pool.Selector[gateway.GatewayAPIClient]
Searcher search.Searcher
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Name provides a name for the service.
func Name(val string) Option {
return func(o *Options) {
o.Name = val
}
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Metrics provides a function to set the metrics option.
func Metrics(val *metrics.Metrics) Option {
return func(o *Options) {
o.Metrics = val
}
}
// Handler provides a function to set the handler option.
func Handler(val *svc.Service) Option {
return func(o *Options) {
o.Handler = val
}
}
// JWTSecret provides a function to set the Config option.
func JWTSecret(val string) Option {
return func(o *Options) {
o.JWTSecret = val
}
}
// TraceProvider provides a function to set the trace provider option.
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
// GatewaySelector provides a function to set the GatewaySelector option.
func GatewaySelector(val *pool.Selector[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.GatewaySelector = val
}
}
// Searcher provides a function to set the Searcher option.
func Searcher(val search.Searcher) Option {
return func(o *Options) {
o.Searcher = val
}
}
@@ -0,0 +1,61 @@
package grpc
import (
"github.com/qsfera/server/pkg/service/grpc"
"github.com/qsfera/server/pkg/version"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
svc "github.com/qsfera/server/services/search/pkg/service/grpc/v0"
)
// Server initializes a new go-micro service ready to run
func Server(opts ...Option) (grpc.Service, error) {
options := newOptions(opts...)
service, err := grpc.NewServiceWithClient(
options.Config.GrpcClient,
grpc.TLSEnabled(options.Config.GRPC.TLS.Enabled),
grpc.TLSCert(
options.Config.GRPC.TLS.Cert,
options.Config.GRPC.TLS.Key,
),
grpc.Name(options.Config.Service.Name),
grpc.Context(options.Context),
grpc.Address(options.Config.GRPC.Addr),
grpc.Namespace(options.Config.GRPC.Namespace),
grpc.Logger(options.Logger),
grpc.Version(version.GetString()),
grpc.TraceProvider(options.TraceProvider),
)
if err != nil {
options.Logger.Fatal().Err(err).Msg("Error creating search service")
return grpc.Service{}, err
}
handle, err := svc.NewHandler(
svc.Config(options.Config),
svc.Logger(options.Logger),
svc.JWTSecret(options.JWTSecret),
svc.TracerProvider(options.TraceProvider),
svc.Metrics(options.Metrics),
svc.GatewaySelector(options.GatewaySelector),
svc.Searcher(options.Searcher),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing search service")
return grpc.Service{}, err
}
if err := searchsvc.RegisterSearchProviderHandler(
service.Server(),
handle,
); err != nil {
options.Logger.Error().
Err(err).
Msg("Error registering search provider handler")
return grpc.Service{}, err
}
return service, nil
}
@@ -0,0 +1,97 @@
package event
import (
"sync"
"time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/pkg/log"
)
// SpaceDebouncer debounces operations on spaces for a configurable amount of time
type SpaceDebouncer struct {
after time.Duration
timeout time.Duration
f func(id *provider.StorageSpaceId)
pending map[string]*workItem
inProgress sync.Map
mutex sync.Mutex
log log.Logger
}
type workItem struct {
t *time.Timer
timeout *time.Timer
work func()
}
type AckFunc func() error
// NewSpaceDebouncer returns a new SpaceDebouncer instance
func NewSpaceDebouncer(d time.Duration, timeout time.Duration, f func(id *provider.StorageSpaceId), logger log.Logger) *SpaceDebouncer {
return &SpaceDebouncer{
after: d,
timeout: timeout,
f: f,
pending: map[string]*workItem{},
inProgress: sync.Map{},
log: logger,
}
}
// Debounce restars the debounce timer for the given space
func (d *SpaceDebouncer) Debounce(id *provider.StorageSpaceId, ack AckFunc) {
d.mutex.Lock()
defer d.mutex.Unlock()
if wi := d.pending[id.OpaqueId]; wi != nil {
if ack != nil {
go ack() // Acknowledge the event immediately, the according space is already scheduled for indexing
}
wi.t.Reset(d.after)
return
}
wi := &workItem{}
wi.work = func() {
if _, ok := d.inProgress.Load(id.OpaqueId); ok {
// Reschedule this run for when the previous run has finished
d.mutex.Lock()
if wi := d.pending[id.OpaqueId]; wi != nil {
wi.t.Reset(d.after)
}
d.mutex.Unlock()
return
}
d.mutex.Lock()
wi.timeout.Stop() // stop the timeout timer if it is running
delete(d.pending, id.OpaqueId)
d.inProgress.Store(id.OpaqueId, true)
defer func() {
d.inProgress.Delete(id.OpaqueId)
}()
d.mutex.Unlock() // release the lock early to allow other goroutines to debounce
d.f(id)
go func() {
if ack != nil {
if err := ack(); err != nil {
d.log.Error().Err(err).Msg("error while acknowledging event")
}
}
}()
}
wi.t = time.AfterFunc(d.after, wi.work)
wi.timeout = time.AfterFunc(d.timeout, func() {
d.log.Debug().Msg("timeout while waiting for space debouncer to finish")
wi.t.Stop()
wi.work()
})
d.pending[id.OpaqueId] = wi
}
@@ -0,0 +1,181 @@
package event_test
import (
"sync/atomic"
"time"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/service/event"
)
var _ = Describe("SpaceDebouncer", func() {
var (
debouncer *event.SpaceDebouncer
callCount atomic.Int32
spaceid = &sprovider.StorageSpaceId{
OpaqueId: "spaceid",
}
)
BeforeEach(func() {
callCount = atomic.Int32{}
debouncer = event.NewSpaceDebouncer(50*time.Millisecond, 10*time.Second, func(id *sprovider.StorageSpaceId) {
if id.OpaqueId == "spaceid" {
callCount.Add(1)
}
}, log.NewLogger())
})
It("debounces", func() {
debouncer.Debounce(spaceid, nil)
debouncer.Debounce(spaceid, nil)
debouncer.Debounce(spaceid, nil)
Eventually(func() int {
return int(callCount.Load())
}, "200ms").Should(Equal(1))
})
It("works multiple times", func() {
debouncer.Debounce(spaceid, nil)
debouncer.Debounce(spaceid, nil)
debouncer.Debounce(spaceid, nil)
time.Sleep(100 * time.Millisecond)
debouncer.Debounce(spaceid, nil)
debouncer.Debounce(spaceid, nil)
Eventually(func() int {
return int(callCount.Load())
}, "200ms").Should(Equal(2))
})
It("doesn't trigger twice simultaneously", func() {
debouncer = event.NewSpaceDebouncer(50*time.Millisecond, 5*time.Second, func(id *sprovider.StorageSpaceId) {
if id.OpaqueId == "spaceid" {
callCount.Add(1)
}
time.Sleep(300 * time.Millisecond)
}, log.NewLogger())
debouncer.Debounce(spaceid, nil)
time.Sleep(100 * time.Millisecond) // Let it trigger once
debouncer.Debounce(spaceid, nil)
time.Sleep(100 * time.Millisecond) // shouldn't trigger as the other run is still in progress
Expect(int(callCount.Load())).To(Equal(1))
Eventually(func() int {
return int(callCount.Load())
}, "2000ms").Should(Equal(2))
})
It("fires at the timeout even when continuously debounced", func() {
debouncer = event.NewSpaceDebouncer(100*time.Millisecond, 250*time.Millisecond, func(id *sprovider.StorageSpaceId) {
if id.OpaqueId == "spaceid" {
callCount.Add(1)
}
}, log.NewLogger())
// Reset the debounce timer every 50ms (shorter than the 100ms debounce
// duration) but stop before the 250ms timeout fires. Continuing past the
// timeout would race with the work function's cleanup of pending state:
// a Debounce call arriving right after the timeout fires would find
// pending empty and schedule a second workItem, breaking the assertion
// below that the work function is invoked exactly once.
debouncer.Debounce(spaceid, nil)
for i := 0; i < 4 && callCount.Load() == 0; i++ {
time.Sleep(50 * time.Millisecond)
if callCount.Load() == 0 {
debouncer.Debounce(spaceid, nil)
}
}
// The debounce timer (100ms) was reset roughly every 50ms and should
// not have fired. The timeout timer (250ms) should fire regardless.
Eventually(func() int {
return int(callCount.Load())
}, "300ms").Should(Equal(1))
// And it should not fire again
Consistently(func() int {
return int(callCount.Load())
}, "300ms").Should(Equal(1))
})
It("doesn't run the timeout function if the work function has been called", func() {
debouncer = event.NewSpaceDebouncer(100*time.Millisecond, 250*time.Millisecond, func(id *sprovider.StorageSpaceId) {
if id.OpaqueId == "spaceid" {
callCount.Add(1)
}
}, log.NewLogger())
// Initial call to start the timers
debouncer.Debounce(spaceid, nil)
// Wait for the debounce timer to fire
Eventually(func() int {
return int(callCount.Load())
}, "200ms").Should(Equal(1))
// The timeout function should not be called
time.Sleep(300 * time.Millisecond)
Expect(int(callCount.Load())).To(Equal(1))
})
It("calls the ack function when the debounce fires", func() {
var ackCalled atomic.Bool
ackFunc := func() error {
ackCalled.Store(true)
return nil
}
debouncer.Debounce(spaceid, ackFunc)
Eventually(func() int {
return int(callCount.Load())
}, "200ms").Should(Equal(1))
Eventually(func() bool {
return ackCalled.Load()
}, "200ms").Should(BeTrue())
})
It("calls the ack function immediately for subsequent calls", func() {
var firstAckCalled atomic.Bool
firstAckFunc := func() error {
firstAckCalled.Store(true)
return nil
}
var secondAckCalled atomic.Bool
secondAckFunc := func() error {
secondAckCalled.Store(true)
return nil
}
// First call, sets up the trigger
debouncer.Debounce(spaceid, firstAckFunc)
Expect(firstAckCalled.Load()).To(BeFalse())
Expect(secondAckCalled.Load()).To(BeFalse())
// Second call, should call its ack immediately
debouncer.Debounce(spaceid, secondAckFunc)
Eventually(func() bool {
return secondAckCalled.Load()
}, "50ms").Should(BeTrue())
// The first ack is not yet called.
Expect(firstAckCalled.Load()).To(BeFalse())
// After the debounce period, the trigger fires, calling the main function and the first ack.
Eventually(func() int {
return int(callCount.Load())
}, "200ms").Should(Equal(1))
Eventually(func() bool {
return firstAckCalled.Load()
}, "200ms").Should(BeTrue())
})
})
@@ -0,0 +1,25 @@
package event_test
import (
"testing"
"github.com/qsfera/server/pkg/registry"
mRegistry "go-micro.dev/v4/registry"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func init() {
r := registry.GetRegistry(registry.Inmemory())
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
service.Nodes = []*mRegistry.Node{{
Address: "any",
}}
_ = r.Register(service)
}
func TestEvent(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Event Suite")
}
@@ -0,0 +1,237 @@
package event
import (
"context"
"sync"
"sync/atomic"
"time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/metrics"
"github.com/qsfera/server/services/search/pkg/search"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
var tracer trace.Tracer
func init() {
tracer = otel.Tracer("github.com/qsfera/server/services/search/pkg/service/event")
}
// Service defines the service handlers.
type Service struct {
ctx context.Context
log log.Logger
tp trace.TracerProvider
m *metrics.Metrics
index search.Searcher
events []events.Unmarshaller
stream raw.Stream
indexSpaceDebouncer *SpaceDebouncer
numConsumers int
stopCh chan struct{}
stopped *atomic.Bool
}
// New returns a service implementation for Service.
func New(ctx context.Context, stream raw.Stream, logger log.Logger, tp trace.TracerProvider, m *metrics.Metrics, index search.Searcher, debounceDuration int, numConsumers int, asyncUploads bool) (Service, error) {
svc := Service{
ctx: ctx,
log: logger,
tp: tp,
m: m,
index: index,
stream: stream,
stopCh: make(chan struct{}, 1),
stopped: new(atomic.Bool),
events: []events.Unmarshaller{
events.ItemTrashed{},
events.ItemPurged{},
events.ItemRestored{},
events.ItemMoved{},
events.TrashbinPurged{},
events.ContainerCreated{},
events.FileTouched{},
events.FileVersionRestored{},
events.TagsAdded{},
events.TagsRemoved{},
events.SpaceRenamed{},
events.LabelAdded{},
events.LabelRemoved{},
},
numConsumers: numConsumers,
}
if asyncUploads {
svc.events = append(svc.events, events.UploadReady{})
} else {
svc.events = append(svc.events, events.FileUploaded{})
}
svc.indexSpaceDebouncer = NewSpaceDebouncer(time.Duration(debounceDuration)*time.Millisecond, 30*time.Second, func(id *provider.StorageSpaceId) {
if err := svc.index.IndexSpace(id, false); err != nil {
svc.log.Error().Err(err).Interface("spaceID", id).Msg("error while indexing a space")
}
}, svc.log)
return svc, nil
}
// Run to fulfil Runner interface
func (s Service) Run() error {
ch, err := s.stream.Consume("search-pull", s.events...)
if err != nil {
return err
}
if s.m != nil {
monitorMetrics(s.ctx, s.stream, "search-pull", s.m, s.log)
}
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(s.ctx)
defer cancel()
s.log.Debug().Int("worker.count", s.numConsumers).
Str("messaging.consumer.group.name", "search-pull").
Str("messaging.system", "nats").
Str("messaging.operation.name", "receive").
Msg("starting event processing workers")
// start workers
for i := 0; i < s.numConsumers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case e, ok := <-ch:
if !ok {
return
}
if err := s.processEvent(e); err != nil {
s.log.Error().Err(err).
Int("worker", workerID).
Interface("event", e).
Msg("failed to process event")
}
}
}
}(i)
}
// wait for stop signal
<-s.stopCh
cancel() // signal workers to stop
wg.Wait()
return nil
}
// Close will make the service to stop processing, so the `Run`
// method can finish.
// TODO: Underlying services can't be stopped. This means that some goroutines
// will get stuck trying to push events through a channel nobody is reading
// from, so resources won't be freed and there will be memory leaks. For now,
// if the service is stopped, you should close the app soon after.
func (s Service) Close() {
if s.stopped.CompareAndSwap(false, true) {
close(s.stopCh)
}
}
func getSpaceID(ref *provider.Reference) *provider.StorageSpaceId {
return &provider.StorageSpaceId{
OpaqueId: storagespace.FormatResourceID(
&provider.ResourceId{
StorageId: ref.GetResourceId().GetStorageId(),
SpaceId: ref.GetResourceId().GetSpaceId(),
},
),
}
}
func (s Service) processEvent(e raw.Event) error {
ctx := e.GetTraceContext(s.ctx)
_, span := tracer.Start(ctx, "processEvent")
defer span.End()
e.InProgress() // let nats know that we are processing this event
s.log.Debug().Interface("event", e).Msg("updating index")
switch ev := e.Event.Event.(type) {
case events.ItemTrashed:
s.index.TrashItem(ev.ID)
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.ItemPurged:
s.index.PurgeItem(ev.Ref)
e.Ack()
case events.TrashbinPurged:
s.index.PurgeDeleted(getSpaceID(ev.Ref))
e.Ack()
case events.ItemMoved:
s.index.MoveItem(ev.Ref)
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.ItemRestored:
s.index.RestoreItem(ev.Ref)
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.ContainerCreated:
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.FileTouched:
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.FileVersionRestored:
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.TagsAdded:
s.index.UpsertItem(ev.Ref)
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.TagsRemoved:
s.index.UpsertItem(ev.Ref)
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.FileUploaded:
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
case events.UploadReady:
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.FileRef), e.Ack)
case events.SpaceRenamed:
s.indexSpaceDebouncer.Debounce(ev.ID, e.Ack)
case events.LabelAdded:
s.index.UpsertItem(ev.Ref)
case events.LabelRemoved:
s.index.UpsertItem(ev.Ref)
}
return nil
}
func monitorMetrics(ctx context.Context, stream raw.Stream, name string, m *metrics.Metrics, logger log.Logger) {
consumer, err := stream.JetStream().Consumer(ctx, name)
if err != nil {
logger.Error().Err(err).Msg("failed to get consumer")
}
ticker := time.NewTicker(5 * time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
info, err := consumer.Info(ctx)
if err != nil {
logger.Error().Err(err).Msg("failed to get consumer")
continue
}
m.EventsOutstandingAcks.Set(float64(info.NumAckPending))
m.EventsUnprocessed.Set(float64(info.NumPending))
m.EventsRedelivered.Set(float64(info.NumRedelivered))
logger.Trace().Msg("updated search event metrics")
}
}
}()
}
@@ -0,0 +1,63 @@
package event_test
import (
"context"
"sync/atomic"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
searchMocks "github.com/qsfera/server/services/search/pkg/search/mocks"
"github.com/qsfera/server/services/search/pkg/service/event"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
rawMocks "github.com/opencloud-eu/reva/v2/pkg/events/raw/mocks"
"github.com/stretchr/testify/mock"
)
var _ = DescribeTable("event",
func(mcks []string, e any, asyncUploads bool) {
var (
s = &searchMocks.Searcher{}
calls atomic.Int32
)
stream := rawMocks.NewStream(GinkgoT())
ch := make(chan raw.Event, 1)
stream.EXPECT().Consume(mock.Anything, mock.Anything).Return((<-chan raw.Event)(ch), nil)
event, err := event.New(context.Background(), stream, log.NewLogger(), nil, nil, s, 50, 1, asyncUploads)
Expect(err).NotTo(HaveOccurred())
go func() {
err := event.Run()
Expect(err).NotTo(HaveOccurred())
}()
defer event.Close()
for _, mck := range mcks {
s.On(mck, mock.Anything, mock.Anything).Return(nil).Run(func(args mock.Arguments) {
calls.Add(1)
})
}
ch <- raw.Event{
Event: events.Event{Event: e},
}
Eventually(func() int {
return int(calls.Load())
}, "2s").Should(Equal(len(mcks)))
},
Entry("ItemTrashed", []string{"TrashItem", "IndexSpace"}, events.ItemTrashed{}, false),
Entry("ItemMoved", []string{"MoveItem", "IndexSpace"}, events.ItemMoved{}, false),
Entry("ItemRestored", []string{"RestoreItem", "IndexSpace"}, events.ItemRestored{}, false),
Entry("ContainerCreated", []string{"IndexSpace"}, events.ContainerCreated{}, false),
Entry("FileTouched", []string{"IndexSpace"}, events.FileTouched{}, false),
Entry("FileVersionRestored", []string{"IndexSpace"}, events.FileVersionRestored{}, false),
Entry("TagsAdded", []string{"UpsertItem", "IndexSpace"}, events.TagsAdded{}, false),
Entry("TagsRemoved", []string{"UpsertItem", "IndexSpace"}, events.TagsRemoved{}, false),
Entry("FileUploaded", []string{"IndexSpace"}, events.FileUploaded{}, false),
Entry("UploadReady", []string{"IndexSpace"}, events.UploadReady{ExecutingUser: &userv1beta1.User{}}, true),
)
@@ -0,0 +1,87 @@
package service
import (
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go.opentelemetry.io/otel/trace"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/metrics"
"github.com/qsfera/server/services/search/pkg/search"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
JWTSecret string
TracerProvider trace.TracerProvider
Metrics *metrics.Metrics
GatewaySelector *pool.Selector[gateway.GatewayAPIClient]
Searcher search.Searcher
}
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the Logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the Config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// JWTSecret provides a function to set the Config option.
func JWTSecret(val string) Option {
return func(o *Options) {
o.JWTSecret = val
}
}
// TracerProvider provides a function to set the TracerProvider option
func TracerProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TracerProvider = val
}
}
// Metrics provides a function to set the Metrics option.
func Metrics(val *metrics.Metrics) Option {
return func(o *Options) {
if val != nil {
o.Metrics = val
}
}
}
// GatewaySelector provides a function to set the GatewaySelector option.
func GatewaySelector(val *pool.Selector[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.GatewaySelector = val
}
}
// Searcher provides a function to set the Searcher option.
func Searcher(val search.Searcher) Option {
return func(o *Options) {
o.Searcher = val
}
}
@@ -0,0 +1,175 @@
package service
import (
"context"
"errors"
"fmt"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/jellydator/ttlcache/v2"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/token"
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
"github.com/opencloud-eu/reva/v2/pkg/utils"
merrors "go-micro.dev/v4/errors"
"go-micro.dev/v4/metadata"
grpcmetadata "google.golang.org/grpc/metadata"
"github.com/qsfera/server/pkg/log"
v0 "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/search/pkg/config"
"github.com/qsfera/server/services/search/pkg/search"
)
// NewHandler returns a service implementation for Service.
func NewHandler(opts ...Option) (searchsvc.SearchProviderHandler, error) {
options := newOptions(opts...)
cfg := options.Config
if options.GatewaySelector == nil {
return nil, errors.New("no GatewaySelector provided")
}
if options.Searcher == nil {
return nil, errors.New("no Searcher provided")
}
cache := ttlcache.NewCache()
if err := cache.SetTTL(time.Second); err != nil {
return nil, err
}
tokenManager, err := jwt.New(map[string]any{
"secret": options.JWTSecret,
"expires": int64(24 * 60 * 60),
})
if err != nil {
return nil, err
}
return &Service{
id: cfg.GRPC.Namespace + "." + cfg.Service.Name,
log: &options.Logger,
searcher: options.Searcher,
cache: cache,
tokenManager: tokenManager,
gws: options.GatewaySelector,
cfg: cfg,
}, nil
}
// Service implements the searchServiceHandler interface
type Service struct {
id string
log *log.Logger
searcher search.Searcher
cache *ttlcache.Cache
tokenManager token.Manager
gws *pool.Selector[gateway.GatewayAPIClient]
cfg *config.Config
}
// Search handles the search
func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *searchsvc.SearchResponse) error {
// Get token from the context (go-micro) and make it known to the reva client too (grpc)
t, ok := metadata.Get(ctx, revactx.TokenHeader)
if !ok {
s.log.Error().Msg("Could not get token from context")
return errors.New("could not get token from context")
}
ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, t)
// unpack user
u, _, err := s.tokenManager.DismantleToken(ctx, t)
if err != nil {
return err
}
ctx = revactx.ContextSetUser(ctx, u)
key := cacheKey(in.Query, in.PageSize, in.Ref, u)
res, ok := s.FromCache(key)
if !ok {
var err error
res, err = s.searcher.Search(ctx, &searchsvc.SearchRequest{
Query: in.Query,
PageSize: in.PageSize,
Ref: in.Ref,
})
if err != nil {
switch err.(type) {
case errtypes.BadRequest:
return merrors.BadRequest(s.id, "%s", err.Error())
default:
return merrors.InternalServerError(s.id, "%s", err.Error())
}
}
s.Cache(key, res)
}
out.Matches = res.Matches
out.TotalMatches = res.TotalMatches
out.NextPageToken = res.NextPageToken
return nil
}
// IndexSpace (re)indexes all resources of a given space.
func (s Service) IndexSpace(_ context.Context, in *searchsvc.IndexSpaceRequest, _ *searchsvc.IndexSpaceResponse) error {
if in.GetSpaceId() != "" {
return s.searcher.IndexSpace(&provider.StorageSpaceId{OpaqueId: in.GetSpaceId()}, in.GetForceReindex())
}
// index all spaces instead
gwc, err := s.gws.Next()
if err != nil {
return err
}
ctx, err := utils.GetServiceUserContext(s.cfg.ServiceAccount.ServiceAccountID, gwc, s.cfg.ServiceAccount.ServiceAccountSecret)
if err != nil {
return err
}
resp, err := gwc.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{})
if err != nil {
return err
}
if resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
return errors.New(resp.GetStatus().GetMessage())
}
for _, space := range resp.GetStorageSpaces() {
if err := s.searcher.IndexSpace(space.GetId(), in.GetForceReindex()); err != nil {
return err
}
}
return nil
}
// FromCache pulls a search result from cache
func (s Service) FromCache(key string) (*searchsvc.SearchResponse, bool) {
v, err := s.cache.Get(key)
if err != nil {
return nil, false
}
sr, ok := v.(*searchsvc.SearchResponse)
return sr, ok
}
// Cache caches the search result
func (s Service) Cache(key string, res *searchsvc.SearchResponse) {
// lets ignore the error
_ = s.cache.Set(key, res)
}
func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User) string {
return fmt.Sprintf("%s|%d|%s$%s!%s/%s|%s", query, pagesize, ref.GetResourceId().GetStorageId(), ref.GetResourceId().GetSpaceId(), ref.GetResourceId().GetOpaqueId(), ref.GetPath(), user.GetId().GetOpaqueId())
}