diff --git a/internal/graph/base.resolvers.go b/internal/graph/base.resolvers.go index 5eaf1f6..84dfe4a 100644 --- a/internal/graph/base.resolvers.go +++ b/internal/graph/base.resolvers.go @@ -48,7 +48,7 @@ func (r *queryResolver) LatestIndex(ctx context.Context, did string, filter *mod if err != nil { return nil, err } - idx, err := r.EventService.GetLatestIndex(ctx, opts) + idx, err := r.EventService.GetLatestIndexAdvanced(ctx, opts) if err != nil { return nil, err } @@ -61,7 +61,7 @@ func (r *queryResolver) Indexes(ctx context.Context, did string, limit *int, fil if err != nil { return nil, err } - list, err := r.EventService.ListIndexes(ctx, resolveLimit(limit), opts) + list, err := r.EventService.ListIndexesAdvanced(ctx, resolveLimit(limit), opts) if err != nil { if errors.Is(err, sql.ErrNoRows) { return emptyCloudEventIndexList, nil @@ -81,7 +81,7 @@ func (r *queryResolver) LatestCloudEvent(ctx context.Context, did string, filter if err != nil { return nil, err } - idx, err := r.EventService.GetLatestIndex(ctx, opts) + idx, err := r.EventService.GetLatestIndexAdvanced(ctx, opts) if err != nil { return nil, err } @@ -106,7 +106,7 @@ func (r *queryResolver) CloudEvents(ctx context.Context, did string, limit *int, if err != nil { return nil, err } - list, err := r.EventService.ListIndexes(ctx, resolveLimit(limit), opts) + list, err := r.EventService.ListIndexesAdvanced(ctx, resolveLimit(limit), opts) if err != nil { if errors.Is(err, sql.ErrNoRows) { return emptyCloudEventList, nil @@ -152,7 +152,7 @@ func (r *queryResolver) AvailableCloudEventTypes(ctx context.Context, did string if err != nil { return nil, err } - summaries, err := r.EventService.GetCloudEventTypeSummaries(ctx, opts) + summaries, err := r.EventService.GetCloudEventTypeSummariesAdvanced(ctx, opts) if err != nil { return nil, err } diff --git a/internal/graph/convert.go b/internal/graph/convert.go index 19ad7ce..c0e9593 100644 --- a/internal/graph/convert.go +++ b/internal/graph/convert.go @@ -6,31 +6,38 @@ import ( "github.com/DIMO-Network/fetch-api/pkg/eventrepo" "github.com/DIMO-Network/fetch-api/pkg/grpc" "google.golang.org/protobuf/types/known/timestamppb" - "google.golang.org/protobuf/types/known/wrapperspb" ) -// filterToSearchOptions converts GraphQL filter and subject DID to grpc.SearchOptions. -func filterToSearchOptions(filter *model.CloudEventFilter, subject string) *grpc.SearchOptions { - opts := &grpc.SearchOptions{ - Subject: &wrapperspb.StringValue{Value: subject}, +// filterToAdvancedSearchOptions converts a GraphQL filter and subject DID directly to +// grpc.AdvancedSearchOptions. The type and types fields are unioned: if both are set, +// results match events whose type is any of the combined values. +func filterToAdvancedSearchOptions(filter *model.CloudEventFilter, subject string) *grpc.AdvancedSearchOptions { + opts := &grpc.AdvancedSearchOptions{ + Subject: &grpc.StringFilterOption{In: []string{subject}}, } if filter == nil { return opts } - if filter.ID != nil { - opts.Id = &wrapperspb.StringValue{Value: *filter.ID} - } + // Merge type (single) and types (list) with OR semantics. + var allTypes []string if filter.Type != nil { - opts.Type = &wrapperspb.StringValue{Value: *filter.Type} + allTypes = append(allTypes, *filter.Type) + } + allTypes = append(allTypes, filter.Types...) + if len(allTypes) > 0 { + opts.Type = &grpc.StringFilterOption{In: allTypes} + } + if filter.ID != nil { + opts.Id = &grpc.StringFilterOption{In: []string{*filter.ID}} } if filter.Dataversion != nil { - opts.DataVersion = &wrapperspb.StringValue{Value: *filter.Dataversion} + opts.DataVersion = &grpc.StringFilterOption{In: []string{*filter.Dataversion}} } if filter.Source != nil { - opts.Source = &wrapperspb.StringValue{Value: *filter.Source} + opts.Source = &grpc.StringFilterOption{In: []string{*filter.Source}} } if filter.Producer != nil { - opts.Producer = &wrapperspb.StringValue{Value: *filter.Producer} + opts.Producer = &grpc.StringFilterOption{In: []string{*filter.Producer}} } if filter.Before != nil { opts.Before = timestamppb.New(*filter.Before) diff --git a/internal/graph/generated.go b/internal/graph/generated.go index 0702476..596c305 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -501,11 +501,13 @@ type CloudEventIndex { } """ -Filter for cloud event queries. +Filter for cloud event queries. """ input CloudEventFilter { id: String type: String + """List of event types to match (OR semantics). Combined with ` + "`" + `type` + "`" + ` if both are set.""" + types: [String!] dataversion: String source: String producer: String @@ -3171,7 +3173,7 @@ func (ec *executionContext) unmarshalInputCloudEventFilter(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"id", "type", "dataversion", "source", "producer", "before", "after"} + fieldsInOrder := [...]string{"id", "type", "types", "dataversion", "source", "producer", "before", "after"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -3192,6 +3194,13 @@ func (ec *executionContext) unmarshalInputCloudEventFilter(ctx context.Context, return it, err } it.Type = data + case "types": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("types")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.Types = data case "dataversion": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("dataversion")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) @@ -4669,6 +4678,42 @@ func (ec *executionContext) marshalOString2string(ctx context.Context, sel ast.S return res } +func (ec *executionContext) unmarshalOString2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { + if v == nil { + return nil, nil + } + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNString2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalOString2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNString2string(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) unmarshalOString2ᚖstring(ctx context.Context, v any) (*string, error) { if v == nil { return nil, nil diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 17c4017..cd1e114 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -10,8 +10,10 @@ import ( // Filter for cloud event queries. type CloudEventFilter struct { - ID *string `json:"id,omitempty"` - Type *string `json:"type,omitempty"` + ID *string `json:"id,omitempty"` + Type *string `json:"type,omitempty"` + // List of event types to match (OR semantics). Combined with `type` if both are set. + Types []string `json:"types,omitempty"` Dataversion *string `json:"dataversion,omitempty"` Source *string `json:"source,omitempty"` Producer *string `json:"producer,omitempty"` diff --git a/internal/graph/resolver.go b/internal/graph/resolver.go index 4e9eeb4..2121961 100644 --- a/internal/graph/resolver.go +++ b/internal/graph/resolver.go @@ -33,10 +33,10 @@ const ( errNoAccessToSubject = "unauthorized: token does not have access to this subject" ) -// requireSubjectOptsByDID validates raw-data access and returns search options for the DID. +// requireSubjectOptsByDID validates raw-data access and returns advanced search options for the DID. // requestedDID: the DID from the client (e.g. cloudEvents(did: "...")). // tokenSubjectDID: the DID the JWT grants access to (tok.Asset). -func (r *queryResolver) requireSubjectOptsByDID(ctx context.Context, requestedDID string, filter *model.CloudEventFilter) (*grpc.SearchOptions, error) { +func (r *queryResolver) requireSubjectOptsByDID(ctx context.Context, requestedDID string, filter *model.CloudEventFilter) (*grpc.AdvancedSearchOptions, error) { token, err := requireRawDataToken(ctx) if err != nil { return nil, err @@ -46,7 +46,7 @@ func (r *queryResolver) requireSubjectOptsByDID(ctx context.Context, requestedDI if err != nil { return nil, err } - return filterToSearchOptions(filter, searchSubject), nil + return filterToAdvancedSearchOptions(filter, searchSubject), nil } // requireRawDataToken returns the token if the context has claims and the token has raw-data permission. diff --git a/internal/graph/resolver_test.go b/internal/graph/resolver_test.go index 248734b..551d31c 100644 --- a/internal/graph/resolver_test.go +++ b/internal/graph/resolver_test.go @@ -31,10 +31,10 @@ func TestRequireVehicleOptsByDID(t *testing.T) { require.NoError(t, err) require.NotNil(t, opts) require.NotNil(t, opts.Subject) - assert.Equal(t, didStr, opts.Subject.Value) + assert.Equal(t, []string{didStr}, opts.Subject.In) }) - t.Run("applies filter to search options", func(t *testing.T) { + t.Run("applies single type filter to search options", func(t *testing.T) { ctx := contextWithToken(didStr, tokenclaims.PermissionGetRawData) filter := &model.CloudEventFilter{ Type: ptr("dimo.status"), @@ -45,8 +45,37 @@ func TestRequireVehicleOptsByDID(t *testing.T) { require.NoError(t, err) require.NotNil(t, opts) require.NotNil(t, opts.Type) - assert.Equal(t, "dimo.status", opts.Type.Value) - assert.Equal(t, didStr, opts.Subject.Value) + assert.Equal(t, []string{"dimo.status"}, opts.Type.In) + assert.Equal(t, []string{didStr}, opts.Subject.In) + }) + + t.Run("applies types array filter to search options", func(t *testing.T) { + ctx := contextWithToken(didStr, tokenclaims.PermissionGetRawData) + filter := &model.CloudEventFilter{ + Types: []string{"dimo.status", "dimo.fingerprint"}, + } + r := &Resolver{} + q := &queryResolver{r} + opts, err := q.requireSubjectOptsByDID(ctx, didStr, filter) + require.NoError(t, err) + require.NotNil(t, opts) + require.NotNil(t, opts.Type) + assert.Equal(t, []string{"dimo.status", "dimo.fingerprint"}, opts.Type.In) + }) + + t.Run("unions type and types when both set", func(t *testing.T) { + ctx := contextWithToken(didStr, tokenclaims.PermissionGetRawData) + filter := &model.CloudEventFilter{ + Type: ptr("dimo.status"), + Types: []string{"dimo.fingerprint", "dimo.attestation"}, + } + r := &Resolver{} + q := &queryResolver{r} + opts, err := q.requireSubjectOptsByDID(ctx, didStr, filter) + require.NoError(t, err) + require.NotNil(t, opts) + require.NotNil(t, opts.Type) + assert.Equal(t, []string{"dimo.status", "dimo.fingerprint", "dimo.attestation"}, opts.Type.In) }) t.Run("unauthorized when token does not match DID", func(t *testing.T) { @@ -90,7 +119,7 @@ func TestRequireVehicleOptsByDID(t *testing.T) { opts, err := q.requireSubjectOptsByDID(ctx, didStr, nil) require.NoError(t, err) require.NotNil(t, opts) - assert.Equal(t, didStr, opts.Subject.Value) + assert.Equal(t, []string{didStr}, opts.Subject.In) }) t.Run("only GetLocationHistory without GetNonLocationHistory denied", func(t *testing.T) { @@ -156,7 +185,7 @@ func TestRequireSubjectOptsByDID_EthrDID(t *testing.T) { require.NoError(t, err) require.NotNil(t, opts) require.NotNil(t, opts.Subject) - assert.Equal(t, ethrDID, opts.Subject.Value) + assert.Equal(t, []string{ethrDID}, opts.Subject.In) }) t.Run("ethr token + different ethr query DID denied", func(t *testing.T) { @@ -223,7 +252,7 @@ func TestRequireVehicleOptsByDID_DeviceDID(t *testing.T) { require.NoError(t, err) require.NotNil(t, opts) // Subject must be the device DID, not the vehicle DID. - assert.Equal(t, deviceDID, opts.Subject.Value) + assert.Equal(t, []string{deviceDID}, opts.Subject.In) }) t.Run("device DID with no identity client returns error", func(t *testing.T) { diff --git a/pkg/eventrepo/eventrepo.go b/pkg/eventrepo/eventrepo.go index 8fe2cc2..0305e5c 100644 --- a/pkg/eventrepo/eventrepo.go +++ b/pkg/eventrepo/eventrepo.go @@ -199,8 +199,11 @@ type CloudEventTypeSummary struct { // GetCloudEventTypeSummaries returns per-type counts and time ranges for the given search options. func (s *Service) GetCloudEventTypeSummaries(ctx context.Context, opts *grpc.SearchOptions) ([]CloudEventTypeSummary, error) { - advancedOpts := convertSearchOptionsToAdvanced(opts) + return s.GetCloudEventTypeSummariesAdvanced(ctx, convertSearchOptionsToAdvanced(opts)) +} +// GetCloudEventTypeSummariesAdvanced returns event type summaries filtered by advanced search options. +func (s *Service) GetCloudEventTypeSummariesAdvanced(ctx context.Context, advancedOpts *grpc.AdvancedSearchOptions) ([]CloudEventTypeSummary, error) { mods := []qm.QueryMod{ qm.Select( chindexer.TypeColumn, diff --git a/schema/base.graphqls b/schema/base.graphqls index 8a09c4c..ace6652 100644 --- a/schema/base.graphqls +++ b/schema/base.graphqls @@ -93,11 +93,13 @@ type CloudEventIndex { } """ -Filter for cloud event queries. +Filter for cloud event queries. """ input CloudEventFilter { id: String type: String + """List of event types to match (OR semantics). Combined with `type` if both are set.""" + types: [String!] dataversion: String source: String producer: String