diff --git a/charts/lfx-v2-query-service/templates/ruleset.yaml b/charts/lfx-v2-query-service/templates/ruleset.yaml index fd6ad92..2b1c1ba 100644 --- a/charts/lfx-v2-query-service/templates/ruleset.yaml +++ b/charts/lfx-v2-query-service/templates/ruleset.yaml @@ -45,6 +45,23 @@ spec: config: values: aud: lfx-v2-query-service + - id: "rule:lfx:lfx-v2-query-service:resources-count" + match: + methods: + - GET + routes: + - path: /query/resources/count + execute: + - authenticator: oidc + - authenticator: anonymous_authenticator + {{- if .Values.app.use_oidc_contextualizer }} + - contextualizer: oidc_contextualizer + {{- end }} + - authorizer: allow_all + - finalizer: create_jwt + config: + values: + aud: lfx-v2-query-service - id: "rule:lfx:lfx-v2-query-service:org-search" match: methods: diff --git a/cmd/service/converters.go b/cmd/service/converters.go index 0de1ca2..1709287 100644 --- a/cmd/service/converters.go +++ b/cmd/service/converters.go @@ -79,6 +79,67 @@ func (s *querySvcsrvc) domainResultToResponse(result *model.SearchResult) *query return response } +func (s *querySvcsrvc) payloadToCountPublicCriteria(payload *querysvc.QueryResourcesCountPayload) model.SearchCriteria { + // Parameters used for //_count search. + criteria := model.SearchCriteria{ + GroupBySize: constants.DefaultBucketSize, + // Page size is not passed to this endpoint. + PageSize: -1, + // For _count, we only want public resources. + PublicOnly: true, + } + + // Set the criteria from the payload + criteria.Tags = payload.Tags + if payload.Name != nil { + criteria.Name = payload.Name + } + if payload.Type != nil { + criteria.ResourceType = payload.Type + } + if payload.Parent != nil { + criteria.ParentRef = payload.Parent + } + + return criteria +} + +func (s *querySvcsrvc) payloadToCountAggregationCriteria(payload *querysvc.QueryResourcesCountPayload) model.SearchCriteria { + // Parameters used for the "group by" aggregated //_search search. + criteria := model.SearchCriteria{ + GroupBySize: constants.DefaultBucketSize, + // We only want the aggregation, not the actual results. + PageSize: 0, + // The aggregation results will only count private resources. + PrivateOnly: true, + // Set the attribute to aggregate on. + // Use .keyword subfield for aggregation on text fields + GroupBy: "access_check_query.keyword", + } + + // Set the criteria from the payload + criteria.Tags = payload.Tags + if payload.Name != nil { + criteria.Name = payload.Name + } + if payload.Type != nil { + criteria.ResourceType = payload.Type + } + if payload.Parent != nil { + criteria.ParentRef = payload.Parent + } + + return criteria +} + +func (s *querySvcsrvc) domainCountResultToResponse(result *model.CountResult) *querysvc.QueryResourcesCountResult { + return &querysvc.QueryResourcesCountResult{ + Count: uint64(result.Count), + HasMore: result.HasMore, + CacheControl: result.CacheControl, + } +} + // payloadToOrganizationCriteria converts the generated payload to domain organization search criteria func (s *querySvcsrvc) payloadToOrganizationCriteria(ctx context.Context, p *querysvc.QueryOrgsPayload) model.OrganizationSearchCriteria { criteria := model.OrganizationSearchCriteria{ diff --git a/cmd/service/service.go b/cmd/service/service.go index e26c6b5..80855e7 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -66,6 +66,28 @@ func (s *querySvcsrvc) QueryResources(ctx context.Context, p *querysvc.QueryReso return res, nil } +// QueryResourcesCount returns an aggregate count of resources the user hase +// access to, by implementing an aggregation over the stored OpenFGA +// relationship. +func (s *querySvcsrvc) QueryResourcesCount(ctx context.Context, p *querysvc.QueryResourcesCountPayload) (*querysvc.QueryResourcesCountResult, error) { + + slog.DebugContext(ctx, "querySvc.query-resource-counts", + "name", p.Name, + ) + + // Convert payload to domain criteria + countCriteria := s.payloadToCountPublicCriteria(p) + aggregationCriteria := s.payloadToCountAggregationCriteria(p) + + // Execute search using the service layer + result, errQueryResources := s.resourceService.QueryResourcesCount(ctx, countCriteria, aggregationCriteria) + if errQueryResources != nil { + return nil, wrapError(ctx, errQueryResources) + } + + return s.domainCountResultToResponse(result), nil +} + // Locate a single organization by name or domain. func (s *querySvcsrvc) QueryOrgs(ctx context.Context, p *querysvc.QueryOrgsPayload) (res *querysvc.Organization, err error) { diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index be613d7..26a1513 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -5,6 +5,7 @@ package service import ( "context" + "fmt" "testing" querysvc "github.com/linuxfoundation/lfx-v2-query-service/gen/query_svc" @@ -172,6 +173,129 @@ func TestQuerySvcsrvc_QueryResources(t *testing.T) { } } +func TestQuerySvcsrvc_QueryResourcesCount(t *testing.T) { + tests := []struct { + name string + payload *querysvc.QueryResourcesCountPayload + setupMocks func(*mock.MockResourceSearcher, *mock.MockAccessControlChecker) + expectedError bool + expectedErrorType interface{} + expectedCount uint64 + }{ + { + name: "successful count query", + payload: &querysvc.QueryResourcesCountPayload{ + Version: "1", + Type: stringPtr("project"), + }, + setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + searcher.SetQueryResourcesCountResponse(&model.CountResult{ + Count: 5, + HasMore: false, + }) + accessChecker.DefaultResult = "allowed" + }, + expectedError: false, + expectedCount: 5, + }, + { + name: "successful count query with name filter", + payload: &querysvc.QueryResourcesCountPayload{ + Version: "1", + Name: stringPtr("Test"), + Type: stringPtr("committee"), + }, + setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + searcher.SetQueryResourcesCountResponse(&model.CountResult{ + Count: 2, + HasMore: false, + }) + accessChecker.DefaultResult = "allowed" + }, + expectedError: false, + expectedCount: 2, + }, + { + name: "count query with tags", + payload: &querysvc.QueryResourcesCountPayload{ + Version: "1", + Tags: []string{"active", "governance"}, + }, + setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + searcher.SetQueryResourcesCountResponse(&model.CountResult{ + Count: 10, + HasMore: true, + }) + accessChecker.DefaultResult = "allowed" + }, + expectedError: false, + expectedCount: 10, + }, + { + name: "count query with parent filter", + payload: &querysvc.QueryResourcesCountPayload{ + Version: "1", + Parent: stringPtr("project:123"), + }, + setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + searcher.SetQueryResourcesCountResponse(&model.CountResult{ + Count: 3, + HasMore: false, + }) + accessChecker.DefaultResult = "allowed" + }, + expectedError: false, + expectedCount: 3, + }, + { + name: "count query with service error", + payload: &querysvc.QueryResourcesCountPayload{ + Version: "1", + Type: stringPtr("invalid"), + }, + setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + searcher.SetQueryResourcesCountError(fmt.Errorf("service error")) + }, + expectedError: true, + expectedErrorType: &querysvc.InternalServerError{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup mocks + mockResourceSearcher := mock.NewMockResourceSearcher() + mockAccessChecker := mock.NewMockAccessControlChecker() + mockOrgSearcher := mock.NewMockOrganizationSearcher() + tc.setupMocks(mockResourceSearcher, mockAccessChecker) + + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + svc, ok := service.(*querySvcsrvc) + assert.True(t, ok) + + ctx := context.WithValue(context.Background(), constants.PrincipalContextID, "test-user") + + // Execute + result, err := svc.QueryResourcesCount(ctx, tc.payload) + + // Verify + if tc.expectedError { + assert.Error(t, err) + if tc.expectedErrorType != nil { + assert.IsType(t, tc.expectedErrorType, err) + } + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tc.expectedCount, result.Count) + // HasMore is returned from the service + assert.NotNil(t, result.HasMore) + } + }) + } +} + func TestQuerySvcsrvc_QueryOrgs(t *testing.T) { tests := []struct { name string diff --git a/design/query-svc.go b/design/query-svc.go index c919f8a..56f123a 100644 --- a/design/query-svc.go +++ b/design/query-svc.go @@ -86,6 +86,68 @@ var _ = dsl.Service("query-svc", func() { }) }) + dsl.Method("query-resources-count", func() { + dsl.Description("Count matching resources by query.") + + dsl.Security(JWTAuth) + + dsl.Payload(func() { + dsl.Token("bearer_token", dsl.String, func() { + dsl.Description("JWT token issued by Heimdall") + dsl.Example("eyJhbGci...") + }) + dsl.Attribute("version", dsl.String, "Version of the API", func() { + dsl.Enum("1") + dsl.Example("1") + }) + dsl.Attribute("name", dsl.String, "Resource name or alias; supports typeahead", func() { + dsl.Example("gov board") + dsl.MinLength(1) + }) + dsl.Attribute("parent", dsl.String, "Parent (for navigation; varies by object type)", func() { + dsl.Example("project:123") + }) + dsl.Attribute("type", dsl.String, "Resource type to search", func() { + dsl.Example("committee") + }) + dsl.Attribute("tags", dsl.ArrayOf(dsl.String), "Tags to search (varies by object type)", func() { + dsl.Example([]string{"active"}) + }) + dsl.Required("bearer_token", "version") + }) + + dsl.Result(func() { + dsl.Attribute("count", dsl.UInt64, "Count of resources found", func() { + dsl.Example(1234) + }) + dsl.Attribute("has_more", dsl.Boolean, "True if count is not guaranteed to be exhaustive: client should request a narrower query", func() { + dsl.Example(false) + }) + dsl.Attribute("cache_control", dsl.String, "Cache control header", func() { + dsl.Example("public, max-age=300") + }) + dsl.Required("count", "has_more") + }) + + dsl.Error("BadRequest", dsl.ErrorResult, "Bad request") + + dsl.HTTP(func() { + dsl.GET("/query/resources/count") + dsl.Param("version:v") + dsl.Param("name") + dsl.Param("parent") + dsl.Param("type") + dsl.Param("tags") + dsl.Header("bearer_token:Authorization") + dsl.Response(dsl.StatusOK, func() { + dsl.Header("cache_control:Cache-Control") + }) + dsl.Response("BadRequest", dsl.StatusBadRequest) + dsl.Response("InternalServerError", dsl.StatusInternalServerError) + dsl.Response("ServiceUnavailable", dsl.StatusServiceUnavailable) + }) + }) + dsl.Method("query-orgs", func() { dsl.Description("Locate a single organization by name or domain.") diff --git a/gen/http/cli/lfx_v2_query_service/cli.go b/gen/http/cli/lfx_v2_query_service/cli.go index f5fe1aa..88dca80 100644 --- a/gen/http/cli/lfx_v2_query_service/cli.go +++ b/gen/http/cli/lfx_v2_query_service/cli.go @@ -22,7 +22,7 @@ import ( // // command (subcommand1|subcommand2|...) func UsageCommands() string { - return `query-svc (query-resources|query-orgs|suggest-orgs|readyz|livez) + return `query-svc (query-resources|query-resources-count|query-orgs|suggest-orgs|readyz|livez) ` } @@ -56,6 +56,14 @@ func ParseEndpoint( querySvcQueryResourcesPageTokenFlag = querySvcQueryResourcesFlags.String("page-token", "", "") querySvcQueryResourcesBearerTokenFlag = querySvcQueryResourcesFlags.String("bearer-token", "REQUIRED", "") + querySvcQueryResourcesCountFlags = flag.NewFlagSet("query-resources-count", flag.ExitOnError) + querySvcQueryResourcesCountVersionFlag = querySvcQueryResourcesCountFlags.String("version", "REQUIRED", "") + querySvcQueryResourcesCountNameFlag = querySvcQueryResourcesCountFlags.String("name", "", "") + querySvcQueryResourcesCountParentFlag = querySvcQueryResourcesCountFlags.String("parent", "", "") + querySvcQueryResourcesCountTypeFlag = querySvcQueryResourcesCountFlags.String("type", "", "") + querySvcQueryResourcesCountTagsFlag = querySvcQueryResourcesCountFlags.String("tags", "", "") + querySvcQueryResourcesCountBearerTokenFlag = querySvcQueryResourcesCountFlags.String("bearer-token", "REQUIRED", "") + querySvcQueryOrgsFlags = flag.NewFlagSet("query-orgs", flag.ExitOnError) querySvcQueryOrgsVersionFlag = querySvcQueryOrgsFlags.String("version", "REQUIRED", "") querySvcQueryOrgsNameFlag = querySvcQueryOrgsFlags.String("name", "", "") @@ -73,6 +81,7 @@ func ParseEndpoint( ) querySvcFlags.Usage = querySvcUsage querySvcQueryResourcesFlags.Usage = querySvcQueryResourcesUsage + querySvcQueryResourcesCountFlags.Usage = querySvcQueryResourcesCountUsage querySvcQueryOrgsFlags.Usage = querySvcQueryOrgsUsage querySvcSuggestOrgsFlags.Usage = querySvcSuggestOrgsUsage querySvcReadyzFlags.Usage = querySvcReadyzUsage @@ -115,6 +124,9 @@ func ParseEndpoint( case "query-resources": epf = querySvcQueryResourcesFlags + case "query-resources-count": + epf = querySvcQueryResourcesCountFlags + case "query-orgs": epf = querySvcQueryOrgsFlags @@ -155,6 +167,9 @@ func ParseEndpoint( case "query-resources": endpoint = c.QueryResources() data, err = querysvcc.BuildQueryResourcesPayload(*querySvcQueryResourcesVersionFlag, *querySvcQueryResourcesNameFlag, *querySvcQueryResourcesParentFlag, *querySvcQueryResourcesTypeFlag, *querySvcQueryResourcesTagsFlag, *querySvcQueryResourcesSortFlag, *querySvcQueryResourcesPageTokenFlag, *querySvcQueryResourcesBearerTokenFlag) + case "query-resources-count": + endpoint = c.QueryResourcesCount() + data, err = querysvcc.BuildQueryResourcesCountPayload(*querySvcQueryResourcesCountVersionFlag, *querySvcQueryResourcesCountNameFlag, *querySvcQueryResourcesCountParentFlag, *querySvcQueryResourcesCountTypeFlag, *querySvcQueryResourcesCountTagsFlag, *querySvcQueryResourcesCountBearerTokenFlag) case "query-orgs": endpoint = c.QueryOrgs() data, err = querysvcc.BuildQueryOrgsPayload(*querySvcQueryOrgsVersionFlag, *querySvcQueryOrgsNameFlag, *querySvcQueryOrgsDomainFlag, *querySvcQueryOrgsBearerTokenFlag) @@ -184,6 +199,7 @@ Usage: COMMAND: query-resources: Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias. + query-resources-count: Count matching resources by query. query-orgs: Locate a single organization by name or domain. suggest-orgs: Get organization suggestions for typeahead search based on a query. readyz: Check if the service is able to take inbound requests. @@ -213,6 +229,24 @@ Example: `, os.Args[0]) } +func querySvcQueryResourcesCountUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] query-svc query-resources-count -version STRING -name STRING -parent STRING -type STRING -tags JSON -bearer-token STRING + +Count matching resources by query. + -version STRING: + -name STRING: + -parent STRING: + -type STRING: + -tags JSON: + -bearer-token STRING: + +Example: + %[1]s query-svc query-resources-count --version "1" --name "gov board" --parent "project:123" --type "committee" --tags '[ + "active" + ]' --bearer-token "eyJhbGci..." +`, os.Args[0]) +} + func querySvcQueryOrgsUsage() { fmt.Fprintf(os.Stderr, `%[1]s [flags] query-svc query-orgs -version STRING -name STRING -domain STRING -bearer-token STRING diff --git a/gen/http/openapi.json b/gen/http/openapi.json index 636ff60..4aaaf7f 100644 --- a/gen/http/openapi.json +++ b/gen/http/openapi.json @@ -1 +1 @@ -{"swagger":"2.0","info":{"title":"LFX V2 - Query Service","description":"Query indexed resources","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/query/orgs":{"get":{"tags":["query-svc"],"summary":"query-orgs query-svc","description":"Locate a single organization by name or domain.","operationId":"query-svc#query-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Organization name","required":false,"type":"string","minLength":1},{"name":"domain","in":"query","description":"Organization domain or website URL","required":false,"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]*\\.[a-zA-Z]{2,}$"},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Organization"}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"404":{"description":"Not Found response.","schema":{"$ref":"#/definitions/NotFoundError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}},"/query/orgs/suggest":{"get":{"tags":["query-svc"],"summary":"suggest-orgs query-svc","description":"Get organization suggestions for typeahead search based on a query.","operationId":"query-svc#suggest-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"query","in":"query","description":"Search query for organization suggestions","required":true,"type":"string","minLength":1},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/QuerySvcSuggestOrgsResponseBody","required":["suggestions"]}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}},"/query/resources":{"get":{"tags":["query-svc"],"summary":"query-resources query-svc","description":"Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias.","operationId":"query-svc#query-resources","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","required":false,"type":"string","minLength":1},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","required":false,"type":"string","pattern":"^[a-zA-Z]+:[a-zA-Z0-9_-]+$"},{"name":"type","in":"query","description":"Resource type to search","required":false,"type":"string"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"sort","in":"query","description":"Sort order for results","required":false,"type":"string","default":"name_asc","enum":["name_asc","name_desc","updated_asc","updated_desc"]},{"name":"page_token","in":"query","description":"Opaque token for pagination","required":false,"type":"string"},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/QuerySvcQueryResourcesResponseBody","required":["resources"]},"headers":{"Cache-Control":{"description":"Cache control header","type":"string"}}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}}},"definitions":{"BadRequestError":{"title":"BadRequestError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The request was invalid."}},"description":"Bad request","example":{"message":"The request was invalid."},"required":["message"]},"InternalServerError":{"title":"InternalServerError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"An internal server error occurred."}},"description":"Internal server error","example":{"message":"An internal server error occurred."},"required":["message"]},"NotFoundError":{"title":"NotFoundError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The requested resource was not found."}},"description":"Not found","example":{"message":"The requested resource was not found."},"required":["message"]},"Organization":{"title":"Organization","type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"employees":{"type":"string","description":"Employee count or range","example":"100-499"},"industry":{"type":"string","description":"Organization industry classification","example":"Non-Profit"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"},"sector":{"type":"string","description":"Business sector classification","example":"Technology"}},"example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}},"OrganizationSuggestion":{"title":"OrganizationSuggestion","type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"logo":{"type":"string","description":"Organization logo URL","example":"https://example.com/logo.png"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"}},"description":"An organization suggestion for typeahead search.","example":{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},"required":["name","domain"]},"QuerySvcQueryResourcesResponseBody":{"title":"QuerySvcQueryResourcesResponseBody","type":"object","properties":{"page_token":{"type":"string","description":"Opaque token if more results are available","example":"****"},"resources":{"type":"array","items":{"$ref":"#/definitions/Resource"},"description":"Resources found","example":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]},"required":["resources"]},"QuerySvcSuggestOrgsResponseBody":{"title":"QuerySvcSuggestOrgsResponseBody","type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/definitions/OrganizationSuggestion"},"description":"Organization suggestions","example":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]},"required":["suggestions"]},"Resource":{"title":"Resource","type":"object","properties":{"data":{"description":"Resource data snapshot","example":{"id":"123","name":"My committee","description":"a committee"}},"id":{"type":"string","description":"Resource ID (within its resource collection)","example":"123"},"type":{"type":"string","description":"Resource type","example":"committee"}},"description":"A resource is a universal representation of an LFX API resource for indexing.","example":{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}},"ServiceUnavailableError":{"title":"ServiceUnavailableError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The service is unavailable."}},"description":"Service unavailable","example":{"message":"The service is unavailable."},"required":["message"]}},"securityDefinitions":{"jwt_header_Authorization":{"type":"apiKey","description":"Heimdall authorization","name":"Authorization","in":"header"}}} \ No newline at end of file +{"swagger":"2.0","info":{"title":"LFX V2 - Query Service","description":"Query indexed resources","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/query/orgs":{"get":{"tags":["query-svc"],"summary":"query-orgs query-svc","description":"Locate a single organization by name or domain.","operationId":"query-svc#query-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Organization name","required":false,"type":"string","minLength":1},{"name":"domain","in":"query","description":"Organization domain or website URL","required":false,"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]*\\.[a-zA-Z]{2,}$"},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Organization"}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"404":{"description":"Not Found response.","schema":{"$ref":"#/definitions/NotFoundError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}},"/query/orgs/suggest":{"get":{"tags":["query-svc"],"summary":"suggest-orgs query-svc","description":"Get organization suggestions for typeahead search based on a query.","operationId":"query-svc#suggest-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"query","in":"query","description":"Search query for organization suggestions","required":true,"type":"string","minLength":1},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/QuerySvcSuggestOrgsResponseBody","required":["suggestions"]}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}},"/query/resources":{"get":{"tags":["query-svc"],"summary":"query-resources query-svc","description":"Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias.","operationId":"query-svc#query-resources","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","required":false,"type":"string","minLength":1},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","required":false,"type":"string","pattern":"^[a-zA-Z]+:[a-zA-Z0-9_-]+$"},{"name":"type","in":"query","description":"Resource type to search","required":false,"type":"string"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"sort","in":"query","description":"Sort order for results","required":false,"type":"string","default":"name_asc","enum":["name_asc","name_desc","updated_asc","updated_desc"]},{"name":"page_token","in":"query","description":"Opaque token for pagination","required":false,"type":"string"},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/QuerySvcQueryResourcesResponseBody","required":["resources"]},"headers":{"Cache-Control":{"description":"Cache control header","type":"string"}}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}},"/query/resources/count":{"get":{"tags":["query-svc"],"summary":"query-resources-count query-svc","description":"Count matching resources by query.","operationId":"query-svc#query-resources-count","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","required":false,"type":"string","minLength":1},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","required":false,"type":"string"},{"name":"type","in":"query","description":"Resource type to search","required":false,"type":"string"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"Authorization","in":"header","description":"JWT token issued by Heimdall","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/QuerySvcQueryResourcesCountResponseBody","required":["count","has_more"]},"headers":{"Cache-Control":{"description":"Cache control header","type":"string"}}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/QuerySvcQueryResourcesCountBadRequestResponseBody"}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}}},"definitions":{"BadRequestError":{"title":"BadRequestError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The request was invalid."}},"description":"Bad request","example":{"message":"The request was invalid."},"required":["message"]},"InternalServerError":{"title":"InternalServerError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"An internal server error occurred."}},"description":"Internal server error","example":{"message":"An internal server error occurred."},"required":["message"]},"NotFoundError":{"title":"NotFoundError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The requested resource was not found."}},"description":"Not found","example":{"message":"The requested resource was not found."},"required":["message"]},"Organization":{"title":"Organization","type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"employees":{"type":"string","description":"Employee count or range","example":"100-499"},"industry":{"type":"string","description":"Organization industry classification","example":"Non-Profit"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"},"sector":{"type":"string","description":"Business sector classification","example":"Technology"}},"example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}},"OrganizationSuggestion":{"title":"OrganizationSuggestion","type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"logo":{"type":"string","description":"Organization logo URL","example":"https://example.com/logo.png"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"}},"description":"An organization suggestion for the search.","example":{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},"required":["name","domain"]},"QuerySvcQueryResourcesCountBadRequestResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Bad request (default view)","example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":true,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"QuerySvcQueryResourcesCountResponseBody":{"title":"QuerySvcQueryResourcesCountResponseBody","type":"object","properties":{"count":{"type":"integer","description":"Count of resources found","example":1234,"format":"int64"},"has_more":{"type":"boolean","description":"True if count is not guaranteed to be exhaustive: client should request a narrower query","example":false}},"example":{"count":1234,"has_more":false},"required":["count","has_more"]},"QuerySvcQueryResourcesResponseBody":{"title":"QuerySvcQueryResourcesResponseBody","type":"object","properties":{"page_token":{"type":"string","description":"Opaque token if more results are available","example":"****"},"resources":{"type":"array","items":{"$ref":"#/definitions/Resource"},"description":"Resources found","example":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]},"required":["resources"]},"QuerySvcSuggestOrgsResponseBody":{"title":"QuerySvcSuggestOrgsResponseBody","type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/definitions/OrganizationSuggestion"},"description":"Organization suggestions","example":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]},"required":["suggestions"]},"Resource":{"title":"Resource","type":"object","properties":{"data":{"description":"Resource data snapshot","example":{"id":"123","name":"My committee","description":"a committee"}},"id":{"type":"string","description":"Resource ID (within its resource collection)","example":"123"},"type":{"type":"string","description":"Resource type","example":"committee"}},"description":"A resource is a universal representation of an LFX API resource for indexing.","example":{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}},"ServiceUnavailableError":{"title":"ServiceUnavailableError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The service is unavailable."}},"description":"Service unavailable","example":{"message":"The service is unavailable."},"required":["message"]}},"securityDefinitions":{"jwt_header_Authorization":{"type":"apiKey","description":"Heimdall authorization","name":"Authorization","in":"header"}}} \ No newline at end of file diff --git a/gen/http/openapi.yaml b/gen/http/openapi.yaml index 0d71266..e698f0d 100644 --- a/gen/http/openapi.yaml +++ b/gen/http/openapi.yaml @@ -227,6 +227,82 @@ paths: - http security: - jwt_header_Authorization: [] + /query/resources/count: + get: + tags: + - query-svc + summary: query-resources-count query-svc + description: Count matching resources by query. + operationId: query-svc#query-resources-count + parameters: + - name: v + in: query + description: Version of the API + required: true + type: string + enum: + - "1" + - name: name + in: query + description: Resource name or alias; supports typeahead + required: false + type: string + minLength: 1 + - name: parent + in: query + description: Parent (for navigation; varies by object type) + required: false + type: string + - name: type + in: query + description: Resource type to search + required: false + type: string + - name: tags + in: query + description: Tags to search (varies by object type) + required: false + type: array + items: + type: string + collectionFormat: multi + - name: Authorization + in: header + description: JWT token issued by Heimdall + required: true + type: string + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/QuerySvcQueryResourcesCountResponseBody' + required: + - count + - has_more + headers: + Cache-Control: + description: Cache control header + type: string + "400": + description: Bad Request response. + schema: + $ref: '#/definitions/QuerySvcQueryResourcesCountBadRequestResponseBody' + "500": + description: Internal Server Error response. + schema: + $ref: '#/definitions/InternalServerError' + required: + - message + "503": + description: Service Unavailable response. + schema: + $ref: '#/definitions/ServiceUnavailableError' + required: + - message + schemes: + - http + security: + - jwt_header_Authorization: [] definitions: BadRequestError: title: BadRequestError @@ -313,7 +389,7 @@ definitions: type: string description: Organization name example: Linux Foundation - description: An organization suggestion for typeahead search. + description: An organization suggestion for the search. example: domain: linuxfoundation.org logo: https://example.com/logo.png @@ -321,6 +397,68 @@ definitions: required: - name - domain + QuerySvcQueryResourcesCountBadRequestResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Bad request (default view) + example: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + QuerySvcQueryResourcesCountResponseBody: + title: QuerySvcQueryResourcesCountResponseBody + type: object + properties: + count: + type: integer + description: Count of resources found + example: 1234 + format: int64 + has_more: + type: boolean + description: 'True if count is not guaranteed to be exhaustive: client should request a narrower query' + example: false + example: + count: 1234 + has_more: false + required: + - count + - has_more QuerySvcQueryResourcesResponseBody: title: QuerySvcQueryResourcesResponseBody type: object @@ -404,9 +542,6 @@ definitions: - domain: linuxfoundation.org logo: https://example.com/logo.png name: Linux Foundation - - domain: linuxfoundation.org - logo: https://example.com/logo.png - name: Linux Foundation example: suggestions: - domain: linuxfoundation.org diff --git a/gen/http/openapi3.json b/gen/http/openapi3.json index 5e78ff1..837d1ac 100644 --- a/gen/http/openapi3.json +++ b/gen/http/openapi3.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"LFX V2 - Query Service","description":"Query indexed resources","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for lfx-v2-query-service"}],"paths":{"/query/orgs":{"get":{"tags":["query-svc"],"summary":"query-orgs query-svc","description":"Locate a single organization by name or domain.","operationId":"query-svc#query-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Organization name","allowEmptyValue":true,"schema":{"type":"string","description":"Organization name","example":"The Linux Foundation","minLength":1},"example":"The Linux Foundation"},{"name":"domain","in":"query","description":"Organization domain or website URL","allowEmptyValue":true,"schema":{"type":"string","description":"Organization domain or website URL","example":"linuxfoundation.org","pattern":"^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]*\\.[a-zA-Z]{2,}$"},"example":"linuxfoundation.org"}],"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"},"example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"404":{"description":"NotFound: Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotFoundError"},"example":{"message":"The requested resource was not found."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}},"/query/orgs/suggest":{"get":{"tags":["query-svc"],"summary":"suggest-orgs query-svc","description":"Get organization suggestions for typeahead search based on a query.","operationId":"query-svc#suggest-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"query","in":"query","description":"Search query for organization suggestions","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Search query for organization suggestions","example":"linux","minLength":1},"example":"linux"}],"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuggestOrgsResponseBody"},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}},"/query/resources":{"get":{"tags":["query-svc"],"summary":"query-resources query-svc","description":"Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias.","operationId":"query-svc#query-resources","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","allowEmptyValue":true,"schema":{"type":"string","description":"Resource name or alias; supports typeahead","example":"gov board","minLength":1},"example":"gov board"},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","allowEmptyValue":true,"schema":{"type":"string","description":"Parent (for navigation; varies by object type)","example":"project:123","pattern":"^[a-zA-Z]+:[a-zA-Z0-9_-]+$"},"example":"project:123"},{"name":"type","in":"query","description":"Resource type to search","allowEmptyValue":true,"schema":{"type":"string","description":"Resource type to search","example":"committee"},"example":"committee"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Animi aspernatur."},"description":"Tags to search (varies by object type)","example":["active"]},"example":["active"]},{"name":"sort","in":"query","description":"Sort order for results","allowEmptyValue":true,"schema":{"type":"string","description":"Sort order for results","default":"name_asc","example":"updated_desc","enum":["name_asc","name_desc","updated_asc","updated_desc"]},"example":"updated_desc"},{"name":"page_token","in":"query","description":"Opaque token for pagination","allowEmptyValue":true,"schema":{"type":"string","description":"Opaque token for pagination","example":"****"},"example":"****"}],"responses":{"200":{"description":"OK response.","headers":{"Cache-Control":{"description":"Cache control header","schema":{"type":"string","description":"Cache control header","example":"public, max-age=300"},"example":"public, max-age=300"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResourcesResponseBody"},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}}},"components":{"schemas":{"BadRequestError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The request was invalid."}},"example":{"message":"The request was invalid."},"required":["message"]},"InternalServerError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"An internal server error occurred."}},"example":{"message":"An internal server error occurred."},"required":["message"]},"NotFoundError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The requested resource was not found."}},"example":{"message":"The requested resource was not found."},"required":["message"]},"Organization":{"type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"employees":{"type":"string","description":"Employee count or range","example":"100-499"},"industry":{"type":"string","description":"Organization industry classification","example":"Non-Profit"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"},"sector":{"type":"string","description":"Business sector classification","example":"Technology"}},"description":"An organization is a universal representation of an LFX API organization.","example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}},"OrganizationSuggestion":{"type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"logo":{"type":"string","description":"Organization logo URL","example":"https://example.com/logo.png"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"}},"description":"An organization suggestion for typeahead search.","example":{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},"required":["name","domain"]},"QueryResourcesResponseBody":{"type":"object","properties":{"page_token":{"type":"string","description":"Opaque token if more results are available","example":"****"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/Resource"},"description":"Resources found","example":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]},"required":["resources"]},"Resource":{"type":"object","properties":{"data":{"description":"Resource data snapshot","example":{"id":"123","name":"My committee","description":"a committee"}},"id":{"type":"string","description":"Resource ID (within its resource collection)","example":"123"},"type":{"type":"string","description":"Resource type","example":"committee"}},"description":"A resource is a universal representation of an LFX API resource for indexing.","example":{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}},"ServiceUnavailableError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The service is unavailable."}},"example":{"message":"The service is unavailable."},"required":["message"]},"Sortable":{"type":"object","properties":{"page_token":{"type":"string","description":"Opaque token for pagination","example":"****"},"sort":{"type":"string","description":"Sort order for results","default":"name_asc","example":"updated_desc","enum":["name_asc","name_desc","updated_asc","updated_desc"]}},"example":{"page_token":"****","sort":"updated_desc"}},"SuggestOrgsResponseBody":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/OrganizationSuggestion"},"description":"Organization suggestions","example":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]},"required":["suggestions"]}},"securitySchemes":{"jwt_header_Authorization":{"type":"http","description":"Heimdall authorization","scheme":"bearer"}}},"tags":[{"name":"query-svc","description":"The query service provides resource and user queries."}]} \ No newline at end of file +{"openapi":"3.0.3","info":{"title":"LFX V2 - Query Service","description":"Query indexed resources","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for lfx-v2-query-service"}],"paths":{"/query/orgs":{"get":{"tags":["query-svc"],"summary":"query-orgs query-svc","description":"Locate a single organization by name or domain.","operationId":"query-svc#query-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Organization name","allowEmptyValue":true,"schema":{"type":"string","description":"Organization name","example":"The Linux Foundation","minLength":1},"example":"The Linux Foundation"},{"name":"domain","in":"query","description":"Organization domain or website URL","allowEmptyValue":true,"schema":{"type":"string","description":"Organization domain or website URL","example":"linuxfoundation.org","pattern":"^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]*\\.[a-zA-Z]{2,}$"},"example":"linuxfoundation.org"}],"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"},"example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"404":{"description":"NotFound: Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotFoundError"},"example":{"message":"The requested resource was not found."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}},"/query/orgs/suggest":{"get":{"tags":["query-svc"],"summary":"suggest-orgs query-svc","description":"Get organization suggestions for typeahead search based on a query.","operationId":"query-svc#suggest-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"query","in":"query","description":"Search query for organization suggestions","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Search query for organization suggestions","example":"linux","minLength":1},"example":"linux"}],"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuggestOrgsResponseBody"},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}},"/query/resources":{"get":{"tags":["query-svc"],"summary":"query-resources query-svc","description":"Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias.","operationId":"query-svc#query-resources","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","allowEmptyValue":true,"schema":{"type":"string","description":"Resource name or alias; supports typeahead","example":"gov board","minLength":1},"example":"gov board"},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","allowEmptyValue":true,"schema":{"type":"string","description":"Parent (for navigation; varies by object type)","example":"project:123","pattern":"^[a-zA-Z]+:[a-zA-Z0-9_-]+$"},"example":"project:123"},{"name":"type","in":"query","description":"Resource type to search","allowEmptyValue":true,"schema":{"type":"string","description":"Resource type to search","example":"committee"},"example":"committee"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Sequi doloribus voluptatem ipsa."},"description":"Tags to search (varies by object type)","example":["active"]},"example":["active"]},{"name":"sort","in":"query","description":"Sort order for results","allowEmptyValue":true,"schema":{"type":"string","description":"Sort order for results","default":"name_asc","example":"updated_desc","enum":["name_asc","name_desc","updated_asc","updated_desc"]},"example":"updated_desc"},{"name":"page_token","in":"query","description":"Opaque token for pagination","allowEmptyValue":true,"schema":{"type":"string","description":"Opaque token for pagination","example":"****"},"example":"****"}],"responses":{"200":{"description":"OK response.","headers":{"Cache-Control":{"description":"Cache control header","schema":{"type":"string","description":"Cache control header","example":"public, max-age=300"},"example":"public, max-age=300"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResourcesResponseBody"},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}},"/query/resources/count":{"get":{"tags":["query-svc"],"summary":"query-resources-count query-svc","description":"Count matching resources by query.","operationId":"query-svc#query-resources-count","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","allowEmptyValue":true,"schema":{"type":"string","description":"Resource name or alias; supports typeahead","example":"gov board","minLength":1},"example":"gov board"},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","allowEmptyValue":true,"schema":{"type":"string","description":"Parent (for navigation; varies by object type)","example":"project:123"},"example":"project:123"},{"name":"type","in":"query","description":"Resource type to search","allowEmptyValue":true,"schema":{"type":"string","description":"Resource type to search","example":"committee"},"example":"committee"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Voluptatem nobis corporis aperiam."},"description":"Tags to search (varies by object type)","example":["active"]},"example":["active"]}],"responses":{"200":{"description":"OK response.","headers":{"Cache-Control":{"description":"Cache control header","schema":{"type":"string","description":"Cache control header","example":"public, max-age=300"},"example":"public, max-age=300"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResourcesCountResponseBody"},"example":{"count":1234,"has_more":false}}}},"400":{"description":"BadRequest: Bad request","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}}},"components":{"schemas":{"BadRequestError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The request was invalid."}},"example":{"message":"The request was invalid."},"required":["message"]},"Error":{"type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"Bad request","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"InternalServerError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"An internal server error occurred."}},"example":{"message":"An internal server error occurred."},"required":["message"]},"NotFoundError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The requested resource was not found."}},"example":{"message":"The requested resource was not found."},"required":["message"]},"Organization":{"type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"employees":{"type":"string","description":"Employee count or range","example":"100-499"},"industry":{"type":"string","description":"Organization industry classification","example":"Non-Profit"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"},"sector":{"type":"string","description":"Business sector classification","example":"Technology"}},"description":"An organization is a universal representation of an LFX API organization.","example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}},"OrganizationSuggestion":{"type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"logo":{"type":"string","description":"Organization logo URL","example":"https://example.com/logo.png"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"}},"description":"An organization suggestion for the search.","example":{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},"required":["name","domain"]},"QueryResourcesCountResponseBody":{"type":"object","properties":{"count":{"type":"integer","description":"Count of resources found","example":1234,"format":"int64"},"has_more":{"type":"boolean","description":"True if count is not guaranteed to be exhaustive: client should request a narrower query","example":false}},"example":{"count":1234,"has_more":false},"required":["count","has_more"]},"QueryResourcesResponseBody":{"type":"object","properties":{"page_token":{"type":"string","description":"Opaque token if more results are available","example":"****"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/Resource"},"description":"Resources found","example":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]},"required":["resources"]},"Resource":{"type":"object","properties":{"data":{"description":"Resource data snapshot","example":{"id":"123","name":"My committee","description":"a committee"}},"id":{"type":"string","description":"Resource ID (within its resource collection)","example":"123"},"type":{"type":"string","description":"Resource type","example":"committee"}},"description":"A resource is a universal representation of an LFX API resource for indexing.","example":{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}},"ServiceUnavailableError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The service is unavailable."}},"example":{"message":"The service is unavailable."},"required":["message"]},"Sortable":{"type":"object","properties":{"page_token":{"type":"string","description":"Opaque token for pagination","example":"****"},"sort":{"type":"string","description":"Sort order for results","default":"name_asc","example":"updated_desc","enum":["name_asc","name_desc","updated_asc","updated_desc"]}},"example":{"page_token":"****","sort":"updated_desc"}},"SuggestOrgsResponseBody":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/OrganizationSuggestion"},"description":"Organization suggestions","example":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]},"required":["suggestions"]}},"securitySchemes":{"jwt_header_Authorization":{"type":"http","description":"Heimdall authorization","scheme":"bearer"}}},"tags":[{"name":"query-svc","description":"The query service provides resource and user queries."}]} \ No newline at end of file diff --git a/gen/http/openapi3.yaml b/gen/http/openapi3.yaml index d6109bc..4bc5616 100644 --- a/gen/http/openapi3.yaml +++ b/gen/http/openapi3.yaml @@ -143,6 +143,9 @@ paths: - domain: linuxfoundation.org logo: https://example.com/logo.png name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation "400": description: 'BadRequest: Bad request' content: @@ -226,7 +229,7 @@ paths: type: array items: type: string - example: Animi aspernatur. + example: Sequi doloribus voluptatem ipsa. description: Tags to search (varies by object type) example: - active @@ -318,6 +321,110 @@ paths: message: The service is unavailable. security: - jwt_header_Authorization: [] + /query/resources/count: + get: + tags: + - query-svc + summary: query-resources-count query-svc + description: Count matching resources by query. + operationId: query-svc#query-resources-count + parameters: + - name: v + in: query + description: Version of the API + allowEmptyValue: true + required: true + schema: + type: string + description: Version of the API + example: "1" + enum: + - "1" + example: "1" + - name: name + in: query + description: Resource name or alias; supports typeahead + allowEmptyValue: true + schema: + type: string + description: Resource name or alias; supports typeahead + example: gov board + minLength: 1 + example: gov board + - name: parent + in: query + description: Parent (for navigation; varies by object type) + allowEmptyValue: true + schema: + type: string + description: Parent (for navigation; varies by object type) + example: project:123 + example: project:123 + - name: type + in: query + description: Resource type to search + allowEmptyValue: true + schema: + type: string + description: Resource type to search + example: committee + example: committee + - name: tags + in: query + description: Tags to search (varies by object type) + allowEmptyValue: true + schema: + type: array + items: + type: string + example: Voluptatem nobis corporis aperiam. + description: Tags to search (varies by object type) + example: + - active + example: + - active + responses: + "200": + description: OK response. + headers: + Cache-Control: + description: Cache control header + schema: + type: string + description: Cache control header + example: public, max-age=300 + example: public, max-age=300 + content: + application/json: + schema: + $ref: '#/components/schemas/QueryResourcesCountResponseBody' + example: + count: 1234 + has_more: false + "400": + description: 'BadRequest: Bad request' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + "500": + description: 'InternalServerError: Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerError' + example: + message: An internal server error occurred. + "503": + description: 'ServiceUnavailable: Service unavailable' + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceUnavailableError' + example: + message: The service is unavailable. + security: + - jwt_header_Authorization: [] components: schemas: BadRequestError: @@ -331,6 +438,48 @@ components: message: The request was invalid. required: - message + Error: + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: true + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Bad request + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault InternalServerError: type: object properties: @@ -398,7 +547,7 @@ components: type: string description: Organization name example: Linux Foundation - description: An organization suggestion for typeahead search. + description: An organization suggestion for the search. example: domain: linuxfoundation.org logo: https://example.com/logo.png @@ -406,6 +555,24 @@ components: required: - name - domain + QueryResourcesCountResponseBody: + type: object + properties: + count: + type: integer + description: Count of resources found + example: 1234 + format: int64 + has_more: + type: boolean + description: 'True if count is not guaranteed to be exhaustive: client should request a narrower query' + example: false + example: + count: 1234 + has_more: false + required: + - count + - has_more QueryResourcesResponseBody: type: object properties: @@ -437,12 +604,6 @@ components: description: a committee id: "123" type: committee - - data: - id: "123" - name: My committee - description: a committee - id: "123" - type: committee example: page_token: '****' resources: @@ -464,12 +625,6 @@ components: description: a committee id: "123" type: committee - - data: - id: "123" - name: My committee - description: a committee - id: "123" - type: committee required: - resources Resource: @@ -543,6 +698,12 @@ components: - domain: linuxfoundation.org logo: https://example.com/logo.png name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation example: suggestions: - domain: linuxfoundation.org @@ -551,9 +712,6 @@ components: - domain: linuxfoundation.org logo: https://example.com/logo.png name: Linux Foundation - - domain: linuxfoundation.org - logo: https://example.com/logo.png - name: Linux Foundation required: - suggestions securitySchemes: diff --git a/gen/http/query_svc/client/cli.go b/gen/http/query_svc/client/cli.go index de78f91..e1f09b9 100644 --- a/gen/http/query_svc/client/cli.go +++ b/gen/http/query_svc/client/cli.go @@ -102,6 +102,68 @@ func BuildQueryResourcesPayload(querySvcQueryResourcesVersion string, querySvcQu return v, nil } +// BuildQueryResourcesCountPayload builds the payload for the query-svc +// query-resources-count endpoint from CLI flags. +func BuildQueryResourcesCountPayload(querySvcQueryResourcesCountVersion string, querySvcQueryResourcesCountName string, querySvcQueryResourcesCountParent string, querySvcQueryResourcesCountType string, querySvcQueryResourcesCountTags string, querySvcQueryResourcesCountBearerToken string) (*querysvc.QueryResourcesCountPayload, error) { + var err error + var version string + { + version = querySvcQueryResourcesCountVersion + if !(version == "1") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("version", version, []any{"1"})) + } + if err != nil { + return nil, err + } + } + var name *string + { + if querySvcQueryResourcesCountName != "" { + name = &querySvcQueryResourcesCountName + if utf8.RuneCountInString(*name) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("name", *name, utf8.RuneCountInString(*name), 1, true)) + } + if err != nil { + return nil, err + } + } + } + var parent *string + { + if querySvcQueryResourcesCountParent != "" { + parent = &querySvcQueryResourcesCountParent + } + } + var type_ *string + { + if querySvcQueryResourcesCountType != "" { + type_ = &querySvcQueryResourcesCountType + } + } + var tags []string + { + if querySvcQueryResourcesCountTags != "" { + err = json.Unmarshal([]byte(querySvcQueryResourcesCountTags), &tags) + if err != nil { + return nil, fmt.Errorf("invalid JSON for tags, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"active\"\n ]'") + } + } + } + var bearerToken string + { + bearerToken = querySvcQueryResourcesCountBearerToken + } + v := &querysvc.QueryResourcesCountPayload{} + v.Version = version + v.Name = name + v.Parent = parent + v.Type = type_ + v.Tags = tags + v.BearerToken = bearerToken + + return v, nil +} + // BuildQueryOrgsPayload builds the payload for the query-svc query-orgs // endpoint from CLI flags. func BuildQueryOrgsPayload(querySvcQueryOrgsVersion string, querySvcQueryOrgsName string, querySvcQueryOrgsDomain string, querySvcQueryOrgsBearerToken string) (*querysvc.QueryOrgsPayload, error) { diff --git a/gen/http/query_svc/client/client.go b/gen/http/query_svc/client/client.go index bd1e931..535a67f 100644 --- a/gen/http/query_svc/client/client.go +++ b/gen/http/query_svc/client/client.go @@ -21,6 +21,10 @@ type Client struct { // query-resources endpoint. QueryResourcesDoer goahttp.Doer + // QueryResourcesCount Doer is the HTTP client used to make requests to the + // query-resources-count endpoint. + QueryResourcesCountDoer goahttp.Doer + // QueryOrgs Doer is the HTTP client used to make requests to the query-orgs // endpoint. QueryOrgsDoer goahttp.Doer @@ -55,16 +59,17 @@ func NewClient( restoreBody bool, ) *Client { return &Client{ - QueryResourcesDoer: doer, - QueryOrgsDoer: doer, - SuggestOrgsDoer: doer, - ReadyzDoer: doer, - LivezDoer: doer, - RestoreResponseBody: restoreBody, - scheme: scheme, - host: host, - decoder: dec, - encoder: enc, + QueryResourcesDoer: doer, + QueryResourcesCountDoer: doer, + QueryOrgsDoer: doer, + SuggestOrgsDoer: doer, + ReadyzDoer: doer, + LivezDoer: doer, + RestoreResponseBody: restoreBody, + scheme: scheme, + host: host, + decoder: dec, + encoder: enc, } } @@ -92,6 +97,30 @@ func (c *Client) QueryResources() goa.Endpoint { } } +// QueryResourcesCount returns an endpoint that makes HTTP requests to the +// query-svc service query-resources-count server. +func (c *Client) QueryResourcesCount() goa.Endpoint { + var ( + encodeRequest = EncodeQueryResourcesCountRequest(c.encoder) + decodeResponse = DecodeQueryResourcesCountResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildQueryResourcesCountRequest(ctx, v) + if err != nil { + return nil, err + } + err = encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.QueryResourcesCountDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("query-svc", "query-resources-count", err) + } + return decodeResponse(resp) + } +} + // QueryOrgs returns an endpoint that makes HTTP requests to the query-svc // service query-orgs server. func (c *Client) QueryOrgs() goa.Endpoint { diff --git a/gen/http/query_svc/client/encode_decode.go b/gen/http/query_svc/client/encode_decode.go index a51dab1..7497c2a 100644 --- a/gen/http/query_svc/client/encode_decode.go +++ b/gen/http/query_svc/client/encode_decode.go @@ -167,6 +167,151 @@ func DecodeQueryResourcesResponse(decoder func(*http.Response) goahttp.Decoder, } } +// BuildQueryResourcesCountRequest instantiates a HTTP request object with +// method and path set to call the "query-svc" service "query-resources-count" +// endpoint +func (c *Client) BuildQueryResourcesCountRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: QueryResourcesCountQuerySvcPath()} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("query-svc", "query-resources-count", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeQueryResourcesCountRequest returns an encoder for requests sent to the +// query-svc query-resources-count server. +func EncodeQueryResourcesCountRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*querysvc.QueryResourcesCountPayload) + if !ok { + return goahttp.ErrInvalidType("query-svc", "query-resources-count", "*querysvc.QueryResourcesCountPayload", v) + } + { + head := p.BearerToken + if !strings.Contains(head, " ") { + req.Header.Set("Authorization", "Bearer "+head) + } else { + req.Header.Set("Authorization", head) + } + } + values := req.URL.Query() + values.Add("v", p.Version) + if p.Name != nil { + values.Add("name", *p.Name) + } + if p.Parent != nil { + values.Add("parent", *p.Parent) + } + if p.Type != nil { + values.Add("type", *p.Type) + } + for _, value := range p.Tags { + values.Add("tags", value) + } + req.URL.RawQuery = values.Encode() + return nil + } +} + +// DecodeQueryResourcesCountResponse returns a decoder for responses returned +// by the query-svc query-resources-count endpoint. restoreBody controls +// whether the response body should be restored after having been read. +// DecodeQueryResourcesCountResponse may return the following errors: +// - "BadRequest" (type *goa.ServiceError): http.StatusBadRequest +// - "InternalServerError" (type *querysvc.InternalServerError): http.StatusInternalServerError +// - "ServiceUnavailable" (type *querysvc.ServiceUnavailableError): http.StatusServiceUnavailable +// - error: internal error +func DecodeQueryResourcesCountResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body QueryResourcesCountResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("query-svc", "query-resources-count", err) + } + err = ValidateQueryResourcesCountResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("query-svc", "query-resources-count", err) + } + var ( + cacheControl *string + ) + cacheControlRaw := resp.Header.Get("Cache-Control") + if cacheControlRaw != "" { + cacheControl = &cacheControlRaw + } + res := NewQueryResourcesCountResultOK(&body, cacheControl) + return res, nil + case http.StatusBadRequest: + var ( + body QueryResourcesCountBadRequestResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("query-svc", "query-resources-count", err) + } + err = ValidateQueryResourcesCountBadRequestResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("query-svc", "query-resources-count", err) + } + return nil, NewQueryResourcesCountBadRequest(&body) + case http.StatusInternalServerError: + var ( + body QueryResourcesCountInternalServerErrorResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("query-svc", "query-resources-count", err) + } + err = ValidateQueryResourcesCountInternalServerErrorResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("query-svc", "query-resources-count", err) + } + return nil, NewQueryResourcesCountInternalServerError(&body) + case http.StatusServiceUnavailable: + var ( + body QueryResourcesCountServiceUnavailableResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("query-svc", "query-resources-count", err) + } + err = ValidateQueryResourcesCountServiceUnavailableResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("query-svc", "query-resources-count", err) + } + return nil, NewQueryResourcesCountServiceUnavailable(&body) + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("query-svc", "query-resources-count", resp.StatusCode, string(body)) + } + } +} + // BuildQueryOrgsRequest instantiates a HTTP request object with method and // path set to call the "query-svc" service "query-orgs" endpoint func (c *Client) BuildQueryOrgsRequest(ctx context.Context, v any) (*http.Request, error) { diff --git a/gen/http/query_svc/client/paths.go b/gen/http/query_svc/client/paths.go index 6f016e5..8365e65 100644 --- a/gen/http/query_svc/client/paths.go +++ b/gen/http/query_svc/client/paths.go @@ -12,6 +12,11 @@ func QueryResourcesQuerySvcPath() string { return "/query/resources" } +// QueryResourcesCountQuerySvcPath returns the URL path to the query-svc service query-resources-count HTTP endpoint. +func QueryResourcesCountQuerySvcPath() string { + return "/query/resources/count" +} + // QueryOrgsQuerySvcPath returns the URL path to the query-svc service query-orgs HTTP endpoint. func QueryOrgsQuerySvcPath() string { return "/query/orgs" diff --git a/gen/http/query_svc/client/types.go b/gen/http/query_svc/client/types.go index 478f0ba..0d9c9b5 100644 --- a/gen/http/query_svc/client/types.go +++ b/gen/http/query_svc/client/types.go @@ -21,6 +21,16 @@ type QueryResourcesResponseBody struct { PageToken *string `form:"page_token,omitempty" json:"page_token,omitempty" xml:"page_token,omitempty"` } +// QueryResourcesCountResponseBody is the type of the "query-svc" service +// "query-resources-count" endpoint HTTP response body. +type QueryResourcesCountResponseBody struct { + // Count of resources found + Count *uint64 `form:"count,omitempty" json:"count,omitempty" xml:"count,omitempty"` + // True if count is not guaranteed to be exhaustive: client should request a + // narrower query + HasMore *bool `form:"has_more,omitempty" json:"has_more,omitempty" xml:"has_more,omitempty"` +} + // QueryOrgsResponseBody is the type of the "query-svc" service "query-orgs" // endpoint HTTP response body. type QueryOrgsResponseBody struct { @@ -66,6 +76,41 @@ type QueryResourcesServiceUnavailableResponseBody struct { Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` } +// QueryResourcesCountBadRequestResponseBody is the type of the "query-svc" +// service "query-resources-count" endpoint HTTP response body for the +// "BadRequest" error. +type QueryResourcesCountBadRequestResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + +// QueryResourcesCountInternalServerErrorResponseBody is the type of the +// "query-svc" service "query-resources-count" endpoint HTTP response body for +// the "InternalServerError" error. +type QueryResourcesCountInternalServerErrorResponseBody struct { + // Error message + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` +} + +// QueryResourcesCountServiceUnavailableResponseBody is the type of the +// "query-svc" service "query-resources-count" endpoint HTTP response body for +// the "ServiceUnavailable" error. +type QueryResourcesCountServiceUnavailableResponseBody struct { + // Error message + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` +} + // QueryOrgsBadRequestResponseBody is the type of the "query-svc" service // "query-orgs" endpoint HTTP response body for the "BadRequest" error. type QueryOrgsBadRequestResponseBody struct { @@ -203,6 +248,53 @@ func NewQueryResourcesServiceUnavailable(body *QueryResourcesServiceUnavailableR return v } +// NewQueryResourcesCountResultOK builds a "query-svc" service +// "query-resources-count" endpoint result from a HTTP "OK" response. +func NewQueryResourcesCountResultOK(body *QueryResourcesCountResponseBody, cacheControl *string) *querysvc.QueryResourcesCountResult { + v := &querysvc.QueryResourcesCountResult{ + Count: *body.Count, + HasMore: *body.HasMore, + } + v.CacheControl = cacheControl + + return v +} + +// NewQueryResourcesCountBadRequest builds a query-svc service +// query-resources-count endpoint BadRequest error. +func NewQueryResourcesCountBadRequest(body *QueryResourcesCountBadRequestResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + +// NewQueryResourcesCountInternalServerError builds a query-svc service +// query-resources-count endpoint InternalServerError error. +func NewQueryResourcesCountInternalServerError(body *QueryResourcesCountInternalServerErrorResponseBody) *querysvc.InternalServerError { + v := &querysvc.InternalServerError{ + Message: *body.Message, + } + + return v +} + +// NewQueryResourcesCountServiceUnavailable builds a query-svc service +// query-resources-count endpoint ServiceUnavailable error. +func NewQueryResourcesCountServiceUnavailable(body *QueryResourcesCountServiceUnavailableResponseBody) *querysvc.ServiceUnavailableError { + v := &querysvc.ServiceUnavailableError{ + Message: *body.Message, + } + + return v +} + // NewQueryOrgsOrganizationOK builds a "query-svc" service "query-orgs" // endpoint result from a HTTP "OK" response. func NewQueryOrgsOrganizationOK(body *QueryOrgsResponseBody) *querysvc.Organization { @@ -322,6 +414,18 @@ func ValidateQueryResourcesResponseBody(body *QueryResourcesResponseBody) (err e return } +// ValidateQueryResourcesCountResponseBody runs the validations defined on +// Query-Resources-CountResponseBody +func ValidateQueryResourcesCountResponseBody(body *QueryResourcesCountResponseBody) (err error) { + if body.Count == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("count", "body")) + } + if body.HasMore == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("has_more", "body")) + } + return +} + // ValidateSuggestOrgsResponseBody runs the validations defined on // Suggest-OrgsResponseBody func ValidateSuggestOrgsResponseBody(body *SuggestOrgsResponseBody) (err error) { @@ -365,6 +469,49 @@ func ValidateQueryResourcesServiceUnavailableResponseBody(body *QueryResourcesSe return } +// ValidateQueryResourcesCountBadRequestResponseBody runs the validations +// defined on query-resources-count_BadRequest_response_body +func ValidateQueryResourcesCountBadRequestResponseBody(body *QueryResourcesCountBadRequestResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} + +// ValidateQueryResourcesCountInternalServerErrorResponseBody runs the +// validations defined on +// query-resources-count_InternalServerError_response_body +func ValidateQueryResourcesCountInternalServerErrorResponseBody(body *QueryResourcesCountInternalServerErrorResponseBody) (err error) { + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + return +} + +// ValidateQueryResourcesCountServiceUnavailableResponseBody runs the +// validations defined on query-resources-count_ServiceUnavailable_response_body +func ValidateQueryResourcesCountServiceUnavailableResponseBody(body *QueryResourcesCountServiceUnavailableResponseBody) (err error) { + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + return +} + // ValidateQueryOrgsBadRequestResponseBody runs the validations defined on // query-orgs_BadRequest_response_body func ValidateQueryOrgsBadRequestResponseBody(body *QueryOrgsBadRequestResponseBody) (err error) { diff --git a/gen/http/query_svc/server/encode_decode.go b/gen/http/query_svc/server/encode_decode.go index 6b92c6f..d1c8cfc 100644 --- a/gen/http/query_svc/server/encode_decode.go +++ b/gen/http/query_svc/server/encode_decode.go @@ -164,6 +164,133 @@ func EncodeQueryResourcesError(encoder func(context.Context, http.ResponseWriter } } +// EncodeQueryResourcesCountResponse returns an encoder for responses returned +// by the query-svc query-resources-count endpoint. +func EncodeQueryResourcesCountResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*querysvc.QueryResourcesCountResult) + enc := encoder(ctx, w) + body := NewQueryResourcesCountResponseBody(res) + if res.CacheControl != nil { + w.Header().Set("Cache-Control", *res.CacheControl) + } + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} + +// DecodeQueryResourcesCountRequest returns a decoder for requests sent to the +// query-svc query-resources-count endpoint. +func DecodeQueryResourcesCountRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (any, error) { + return func(r *http.Request) (any, error) { + var ( + version string + name *string + parent *string + type_ *string + tags []string + bearerToken string + err error + ) + qp := r.URL.Query() + version = qp.Get("v") + if version == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("version", "query string")) + } + if !(version == "1") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("version", version, []any{"1"})) + } + nameRaw := qp.Get("name") + if nameRaw != "" { + name = &nameRaw + } + if name != nil { + if utf8.RuneCountInString(*name) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("name", *name, utf8.RuneCountInString(*name), 1, true)) + } + } + parentRaw := qp.Get("parent") + if parentRaw != "" { + parent = &parentRaw + } + type_Raw := qp.Get("type") + if type_Raw != "" { + type_ = &type_Raw + } + tags = qp["tags"] + bearerToken = r.Header.Get("Authorization") + if bearerToken == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("bearer_token", "header")) + } + if err != nil { + return nil, err + } + payload := NewQueryResourcesCountPayload(version, name, parent, type_, tags, bearerToken) + if strings.Contains(payload.BearerToken, " ") { + // Remove authorization scheme prefix (e.g. "Bearer") + cred := strings.SplitN(payload.BearerToken, " ", 2)[1] + payload.BearerToken = cred + } + + return payload, nil + } +} + +// EncodeQueryResourcesCountError returns an encoder for errors returned by the +// query-resources-count query-svc endpoint. +func EncodeQueryResourcesCountError(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { + encodeError := goahttp.ErrorEncoder(encoder, formatter) + return func(ctx context.Context, w http.ResponseWriter, v error) error { + var en goa.GoaErrorNamer + if !errors.As(v, &en) { + return encodeError(ctx, w, v) + } + switch en.GoaErrorName() { + case "BadRequest": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewQueryResourcesCountBadRequestResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusBadRequest) + return enc.Encode(body) + case "InternalServerError": + var res *querysvc.InternalServerError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewQueryResourcesCountInternalServerErrorResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusInternalServerError) + return enc.Encode(body) + case "ServiceUnavailable": + var res *querysvc.ServiceUnavailableError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewQueryResourcesCountServiceUnavailableResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusServiceUnavailable) + return enc.Encode(body) + default: + return encodeError(ctx, w, v) + } + } +} + // EncodeQueryOrgsResponse returns an encoder for responses returned by the // query-svc query-orgs endpoint. func EncodeQueryOrgsResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { diff --git a/gen/http/query_svc/server/paths.go b/gen/http/query_svc/server/paths.go index a0a6408..c1546df 100644 --- a/gen/http/query_svc/server/paths.go +++ b/gen/http/query_svc/server/paths.go @@ -12,6 +12,11 @@ func QueryResourcesQuerySvcPath() string { return "/query/resources" } +// QueryResourcesCountQuerySvcPath returns the URL path to the query-svc service query-resources-count HTTP endpoint. +func QueryResourcesCountQuerySvcPath() string { + return "/query/resources/count" +} + // QueryOrgsQuerySvcPath returns the URL path to the query-svc service query-orgs HTTP endpoint. func QueryOrgsQuerySvcPath() string { return "/query/orgs" diff --git a/gen/http/query_svc/server/server.go b/gen/http/query_svc/server/server.go index a051e6d..963dd19 100644 --- a/gen/http/query_svc/server/server.go +++ b/gen/http/query_svc/server/server.go @@ -21,6 +21,7 @@ import ( type Server struct { Mounts []*MountPoint QueryResources http.Handler + QueryResourcesCount http.Handler QueryOrgs http.Handler SuggestOrgs http.Handler Readyz http.Handler @@ -79,6 +80,7 @@ func New( return &Server{ Mounts: []*MountPoint{ {"QueryResources", "GET", "/query/resources"}, + {"QueryResourcesCount", "GET", "/query/resources/count"}, {"QueryOrgs", "GET", "/query/orgs"}, {"SuggestOrgs", "GET", "/query/orgs/suggest"}, {"Readyz", "GET", "/readyz"}, @@ -89,6 +91,7 @@ func New( {"Serve gen/http/openapi3.yaml", "GET", "/_query/openapi3.yaml"}, }, QueryResources: NewQueryResourcesHandler(e.QueryResources, mux, decoder, encoder, errhandler, formatter), + QueryResourcesCount: NewQueryResourcesCountHandler(e.QueryResourcesCount, mux, decoder, encoder, errhandler, formatter), QueryOrgs: NewQueryOrgsHandler(e.QueryOrgs, mux, decoder, encoder, errhandler, formatter), SuggestOrgs: NewSuggestOrgsHandler(e.SuggestOrgs, mux, decoder, encoder, errhandler, formatter), Readyz: NewReadyzHandler(e.Readyz, mux, decoder, encoder, errhandler, formatter), @@ -106,6 +109,7 @@ func (s *Server) Service() string { return "query-svc" } // Use wraps the server handlers with the given middleware. func (s *Server) Use(m func(http.Handler) http.Handler) { s.QueryResources = m(s.QueryResources) + s.QueryResourcesCount = m(s.QueryResourcesCount) s.QueryOrgs = m(s.QueryOrgs) s.SuggestOrgs = m(s.SuggestOrgs) s.Readyz = m(s.Readyz) @@ -118,6 +122,7 @@ func (s *Server) MethodNames() []string { return querysvc.MethodNames[:] } // Mount configures the mux to serve the query-svc endpoints. func Mount(mux goahttp.Muxer, h *Server) { MountQueryResourcesHandler(mux, h.QueryResources) + MountQueryResourcesCountHandler(mux, h.QueryResourcesCount) MountQueryOrgsHandler(mux, h.QueryOrgs) MountSuggestOrgsHandler(mux, h.SuggestOrgs) MountReadyzHandler(mux, h.Readyz) @@ -184,6 +189,57 @@ func NewQueryResourcesHandler( }) } +// MountQueryResourcesCountHandler configures the mux to serve the "query-svc" +// service "query-resources-count" endpoint. +func MountQueryResourcesCountHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/query/resources/count", f) +} + +// NewQueryResourcesCountHandler creates a HTTP handler which loads the HTTP +// request and calls the "query-svc" service "query-resources-count" endpoint. +func NewQueryResourcesCountHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + decodeRequest = DecodeQueryResourcesCountRequest(mux, decoder) + encodeResponse = EncodeQueryResourcesCountResponse(encoder) + encodeError = EncodeQueryResourcesCountError(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "query-resources-count") + ctx = context.WithValue(ctx, goa.ServiceKey, "query-svc") + payload, err := decodeRequest(r) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + res, err := endpoint(ctx, payload) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + errhandler(ctx, w, err) + } + }) +} + // MountQueryOrgsHandler configures the mux to serve the "query-svc" service // "query-orgs" endpoint. func MountQueryOrgsHandler(mux goahttp.Muxer, h http.Handler) { diff --git a/gen/http/query_svc/server/types.go b/gen/http/query_svc/server/types.go index 8e8e7f8..3ac61f9 100644 --- a/gen/http/query_svc/server/types.go +++ b/gen/http/query_svc/server/types.go @@ -21,6 +21,16 @@ type QueryResourcesResponseBody struct { PageToken *string `form:"page_token,omitempty" json:"page_token,omitempty" xml:"page_token,omitempty"` } +// QueryResourcesCountResponseBody is the type of the "query-svc" service +// "query-resources-count" endpoint HTTP response body. +type QueryResourcesCountResponseBody struct { + // Count of resources found + Count uint64 `form:"count" json:"count" xml:"count"` + // True if count is not guaranteed to be exhaustive: client should request a + // narrower query + HasMore bool `form:"has_more" json:"has_more" xml:"has_more"` +} + // QueryOrgsResponseBody is the type of the "query-svc" service "query-orgs" // endpoint HTTP response body. type QueryOrgsResponseBody struct { @@ -66,6 +76,41 @@ type QueryResourcesServiceUnavailableResponseBody struct { Message string `form:"message" json:"message" xml:"message"` } +// QueryResourcesCountBadRequestResponseBody is the type of the "query-svc" +// service "query-resources-count" endpoint HTTP response body for the +// "BadRequest" error. +type QueryResourcesCountBadRequestResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + +// QueryResourcesCountInternalServerErrorResponseBody is the type of the +// "query-svc" service "query-resources-count" endpoint HTTP response body for +// the "InternalServerError" error. +type QueryResourcesCountInternalServerErrorResponseBody struct { + // Error message + Message string `form:"message" json:"message" xml:"message"` +} + +// QueryResourcesCountServiceUnavailableResponseBody is the type of the +// "query-svc" service "query-resources-count" endpoint HTTP response body for +// the "ServiceUnavailable" error. +type QueryResourcesCountServiceUnavailableResponseBody struct { + // Error message + Message string `form:"message" json:"message" xml:"message"` +} + // QueryOrgsBadRequestResponseBody is the type of the "query-svc" service // "query-orgs" endpoint HTTP response body for the "BadRequest" error. type QueryOrgsBadRequestResponseBody struct { @@ -175,6 +220,16 @@ func NewQueryResourcesResponseBody(res *querysvc.QueryResourcesResult) *QueryRes return body } +// NewQueryResourcesCountResponseBody builds the HTTP response body from the +// result of the "query-resources-count" endpoint of the "query-svc" service. +func NewQueryResourcesCountResponseBody(res *querysvc.QueryResourcesCountResult) *QueryResourcesCountResponseBody { + body := &QueryResourcesCountResponseBody{ + Count: res.Count, + HasMore: res.HasMore, + } + return body +} + // NewQueryOrgsResponseBody builds the HTTP response body from the result of // the "query-orgs" endpoint of the "query-svc" service. func NewQueryOrgsResponseBody(res *querysvc.Organization) *QueryOrgsResponseBody { @@ -232,6 +287,41 @@ func NewQueryResourcesServiceUnavailableResponseBody(res *querysvc.ServiceUnavai return body } +// NewQueryResourcesCountBadRequestResponseBody builds the HTTP response body +// from the result of the "query-resources-count" endpoint of the "query-svc" +// service. +func NewQueryResourcesCountBadRequestResponseBody(res *goa.ServiceError) *QueryResourcesCountBadRequestResponseBody { + body := &QueryResourcesCountBadRequestResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + +// NewQueryResourcesCountInternalServerErrorResponseBody builds the HTTP +// response body from the result of the "query-resources-count" endpoint of the +// "query-svc" service. +func NewQueryResourcesCountInternalServerErrorResponseBody(res *querysvc.InternalServerError) *QueryResourcesCountInternalServerErrorResponseBody { + body := &QueryResourcesCountInternalServerErrorResponseBody{ + Message: res.Message, + } + return body +} + +// NewQueryResourcesCountServiceUnavailableResponseBody builds the HTTP +// response body from the result of the "query-resources-count" endpoint of the +// "query-svc" service. +func NewQueryResourcesCountServiceUnavailableResponseBody(res *querysvc.ServiceUnavailableError) *QueryResourcesCountServiceUnavailableResponseBody { + body := &QueryResourcesCountServiceUnavailableResponseBody{ + Message: res.Message, + } + return body +} + // NewQueryOrgsBadRequestResponseBody builds the HTTP response body from the // result of the "query-orgs" endpoint of the "query-svc" service. func NewQueryOrgsBadRequestResponseBody(res *querysvc.BadRequestError) *QueryOrgsBadRequestResponseBody { @@ -325,6 +415,20 @@ func NewQueryResourcesPayload(version string, name *string, parent *string, type return v } +// NewQueryResourcesCountPayload builds a query-svc service +// query-resources-count endpoint payload. +func NewQueryResourcesCountPayload(version string, name *string, parent *string, type_ *string, tags []string, bearerToken string) *querysvc.QueryResourcesCountPayload { + v := &querysvc.QueryResourcesCountPayload{} + v.Version = version + v.Name = name + v.Parent = parent + v.Type = type_ + v.Tags = tags + v.BearerToken = bearerToken + + return v +} + // NewQueryOrgsPayload builds a query-svc service query-orgs endpoint payload. func NewQueryOrgsPayload(version string, name *string, domain *string, bearerToken string) *querysvc.QueryOrgsPayload { v := &querysvc.QueryOrgsPayload{} diff --git a/gen/query_svc/client.go b/gen/query_svc/client.go index d67cd62..571147f 100644 --- a/gen/query_svc/client.go +++ b/gen/query_svc/client.go @@ -15,21 +15,23 @@ import ( // Client is the "query-svc" service client. type Client struct { - QueryResourcesEndpoint goa.Endpoint - QueryOrgsEndpoint goa.Endpoint - SuggestOrgsEndpoint goa.Endpoint - ReadyzEndpoint goa.Endpoint - LivezEndpoint goa.Endpoint + QueryResourcesEndpoint goa.Endpoint + QueryResourcesCountEndpoint goa.Endpoint + QueryOrgsEndpoint goa.Endpoint + SuggestOrgsEndpoint goa.Endpoint + ReadyzEndpoint goa.Endpoint + LivezEndpoint goa.Endpoint } // NewClient initializes a "query-svc" service client given the endpoints. -func NewClient(queryResources, queryOrgs, suggestOrgs, readyz, livez goa.Endpoint) *Client { +func NewClient(queryResources, queryResourcesCount, queryOrgs, suggestOrgs, readyz, livez goa.Endpoint) *Client { return &Client{ - QueryResourcesEndpoint: queryResources, - QueryOrgsEndpoint: queryOrgs, - SuggestOrgsEndpoint: suggestOrgs, - ReadyzEndpoint: readyz, - LivezEndpoint: livez, + QueryResourcesEndpoint: queryResources, + QueryResourcesCountEndpoint: queryResourcesCount, + QueryOrgsEndpoint: queryOrgs, + SuggestOrgsEndpoint: suggestOrgs, + ReadyzEndpoint: readyz, + LivezEndpoint: livez, } } @@ -50,6 +52,23 @@ func (c *Client) QueryResources(ctx context.Context, p *QueryResourcesPayload) ( return ires.(*QueryResourcesResult), nil } +// QueryResourcesCount calls the "query-resources-count" endpoint of the +// "query-svc" service. +// QueryResourcesCount may return the following errors: +// - "BadRequest" (type *goa.ServiceError): Bad request +// - "NotFound" (type *NotFoundError): Not found +// - "InternalServerError" (type *InternalServerError): Internal server error +// - "ServiceUnavailable" (type *ServiceUnavailableError): Service unavailable +// - error: internal error +func (c *Client) QueryResourcesCount(ctx context.Context, p *QueryResourcesCountPayload) (res *QueryResourcesCountResult, err error) { + var ires any + ires, err = c.QueryResourcesCountEndpoint(ctx, p) + if err != nil { + return + } + return ires.(*QueryResourcesCountResult), nil +} + // QueryOrgs calls the "query-orgs" endpoint of the "query-svc" service. // QueryOrgs may return the following errors: // - "BadRequest" (type *BadRequestError): Bad request diff --git a/gen/query_svc/endpoints.go b/gen/query_svc/endpoints.go index 00e5b42..f832c3f 100644 --- a/gen/query_svc/endpoints.go +++ b/gen/query_svc/endpoints.go @@ -16,11 +16,12 @@ import ( // Endpoints wraps the "query-svc" service endpoints. type Endpoints struct { - QueryResources goa.Endpoint - QueryOrgs goa.Endpoint - SuggestOrgs goa.Endpoint - Readyz goa.Endpoint - Livez goa.Endpoint + QueryResources goa.Endpoint + QueryResourcesCount goa.Endpoint + QueryOrgs goa.Endpoint + SuggestOrgs goa.Endpoint + Readyz goa.Endpoint + Livez goa.Endpoint } // NewEndpoints wraps the methods of the "query-svc" service with endpoints. @@ -28,17 +29,19 @@ func NewEndpoints(s Service) *Endpoints { // Casting service to Auther interface a := s.(Auther) return &Endpoints{ - QueryResources: NewQueryResourcesEndpoint(s, a.JWTAuth), - QueryOrgs: NewQueryOrgsEndpoint(s, a.JWTAuth), - SuggestOrgs: NewSuggestOrgsEndpoint(s, a.JWTAuth), - Readyz: NewReadyzEndpoint(s), - Livez: NewLivezEndpoint(s), + QueryResources: NewQueryResourcesEndpoint(s, a.JWTAuth), + QueryResourcesCount: NewQueryResourcesCountEndpoint(s, a.JWTAuth), + QueryOrgs: NewQueryOrgsEndpoint(s, a.JWTAuth), + SuggestOrgs: NewSuggestOrgsEndpoint(s, a.JWTAuth), + Readyz: NewReadyzEndpoint(s), + Livez: NewLivezEndpoint(s), } } // Use applies the given middleware to all the "query-svc" service endpoints. func (e *Endpoints) Use(m func(goa.Endpoint) goa.Endpoint) { e.QueryResources = m(e.QueryResources) + e.QueryResourcesCount = m(e.QueryResourcesCount) e.QueryOrgs = m(e.QueryOrgs) e.SuggestOrgs = m(e.SuggestOrgs) e.Readyz = m(e.Readyz) @@ -64,6 +67,25 @@ func NewQueryResourcesEndpoint(s Service, authJWTFn security.AuthJWTFunc) goa.En } } +// NewQueryResourcesCountEndpoint returns an endpoint function that calls the +// method "query-resources-count" of service "query-svc". +func NewQueryResourcesCountEndpoint(s Service, authJWTFn security.AuthJWTFunc) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + p := req.(*QueryResourcesCountPayload) + var err error + sc := security.JWTScheme{ + Name: "jwt", + Scopes: []string{}, + RequiredScopes: []string{}, + } + ctx, err = authJWTFn(ctx, p.BearerToken, &sc) + if err != nil { + return nil, err + } + return s.QueryResourcesCount(ctx, p) + } +} + // NewQueryOrgsEndpoint returns an endpoint function that calls the method // "query-orgs" of service "query-svc". func NewQueryOrgsEndpoint(s Service, authJWTFn security.AuthJWTFunc) goa.Endpoint { diff --git a/gen/query_svc/service.go b/gen/query_svc/service.go index c3f38cb..6de3d49 100644 --- a/gen/query_svc/service.go +++ b/gen/query_svc/service.go @@ -19,6 +19,8 @@ type Service interface { // Locate resources by their type or parent, or use typeahead search to query // resources by a display name or similar alias. QueryResources(context.Context, *QueryResourcesPayload) (res *QueryResourcesResult, err error) + // Count matching resources by query. + QueryResourcesCount(context.Context, *QueryResourcesCountPayload) (res *QueryResourcesCountResult, err error) // Locate a single organization by name or domain. QueryOrgs(context.Context, *QueryOrgsPayload) (res *Organization, err error) // Get organization suggestions for typeahead search based on a query. @@ -49,7 +51,7 @@ const ServiceName = "query-svc" // MethodNames lists the service method names as defined in the design. These // are the same values that are set in the endpoint request contexts under the // MethodKey key. -var MethodNames = [5]string{"query-resources", "query-orgs", "suggest-orgs", "readyz", "livez"} +var MethodNames = [6]string{"query-resources", "query-resources-count", "query-orgs", "suggest-orgs", "readyz", "livez"} type BadRequestError struct { // Error message @@ -80,7 +82,7 @@ type Organization struct { Employees *string } -// An organization suggestion for typeahead search. +// An organization suggestion for the search. type OrganizationSuggestion struct { // Organization name Name string @@ -103,6 +105,35 @@ type QueryOrgsPayload struct { Domain *string } +// QueryResourcesCountPayload is the payload type of the query-svc service +// query-resources-count method. +type QueryResourcesCountPayload struct { + // JWT token issued by Heimdall + BearerToken string + // Version of the API + Version string + // Resource name or alias; supports typeahead + Name *string + // Parent (for navigation; varies by object type) + Parent *string + // Resource type to search + Type *string + // Tags to search (varies by object type) + Tags []string +} + +// QueryResourcesCountResult is the result type of the query-svc service +// query-resources-count method. +type QueryResourcesCountResult struct { + // Count of resources found + Count uint64 + // True if count is not guaranteed to be exhaustive: client should request a + // narrower query + HasMore bool + // Cache control header + CacheControl *string +} + // QueryResourcesPayload is the payload type of the query-svc service // query-resources method. type QueryResourcesPayload struct { @@ -236,6 +267,11 @@ func (e *ServiceUnavailableError) GoaErrorName() string { return "ServiceUnavailable" } +// MakeBadRequest builds a goa.ServiceError from an error. +func MakeBadRequest(err error) *goa.ServiceError { + return goa.NewServiceError(err, "BadRequest", false, false, false) +} + // MakeNotReady builds a goa.ServiceError from an error. func MakeNotReady(err error) *goa.ServiceError { return goa.NewServiceError(err, "NotReady", false, true, true) diff --git a/internal/domain/model/count.go b/internal/domain/model/count.go new file mode 100644 index 0000000..33d3b78 --- /dev/null +++ b/internal/domain/model/count.go @@ -0,0 +1,22 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package model + +// AggregationBucket represents a single aggregation bucket. +type AggregationBucket struct { + Key string `json:"key"` + DocCount uint64 `json:"doc_count"` +} + +// TermsAggregation represents a terms aggregation response. +type TermsAggregation struct { + DocCountErrorUpperBound uint64 `json:"doc_count_error_upper_bound"` + SumOtherDocCount uint64 `json:"sum_other_doc_count"` + Buckets []AggregationBucket `json:"buckets"` +} + +// AggregationResponse represents the aggregations in a search response. +type AggregationResponse struct { + GroupBy TermsAggregation `json:"group_by"` +} diff --git a/internal/domain/model/resource.go b/internal/domain/model/resource.go index 0f219c3..c99220e 100644 --- a/internal/domain/model/resource.go +++ b/internal/domain/model/resource.go @@ -29,4 +29,6 @@ type TransactionBodyStub struct { AccessCheckRelation string `json:"access_check_relation"` HistoryCheckObject string `json:"history_check_object"` HistoryCheckRelation string `json:"history_check_relation"` + AccessCheckQuery string `json:"access_check_query"` + HistoryCheckQuery string `json:"history_check_query"` } diff --git a/internal/domain/model/search_criteria.go b/internal/domain/model/search_criteria.go index ad02998..f310ff9 100644 --- a/internal/domain/model/search_criteria.go +++ b/internal/domain/model/search_criteria.go @@ -27,6 +27,12 @@ type SearchCriteria struct { PageSize int // PublicOnly indicates if only public resources should be returned PublicOnly bool + // PrivateOnly indicates if only private resources should be returned + PrivateOnly bool + // GroupBy indicates the field to group by + GroupBy string + // GroupBySize indicates the size of the group by + GroupBySize int } // SearchResult contains the results of a resource search @@ -41,6 +47,18 @@ type SearchResult struct { Total int } +// CountResult contains the results of a resource count search +type CountResult struct { + // Count number of resources found + Count int + // Aggregations + Aggregation TermsAggregation + // HasMore indicates if there are more results + HasMore bool + // Cache control header + CacheControl *string +} + // OrganizationSearchCriteria encapsulates search parameters for organizations type OrganizationSearchCriteria struct { // Organization name diff --git a/internal/domain/port/searcher.go b/internal/domain/port/searcher.go index 76cdfd5..4e70b6a 100644 --- a/internal/domain/port/searcher.go +++ b/internal/domain/port/searcher.go @@ -16,6 +16,9 @@ type ResourceSearcher interface { // QueryResources searches for resources based on the provided criteria QueryResources(ctx context.Context, criteria model.SearchCriteria) (*model.SearchResult, error) + // QueryResourcesCount searches for resources based on the provided criteria + QueryResourcesCount(ctx context.Context, countCriteria model.SearchCriteria, aggregationCriteria model.SearchCriteria, publicOnly bool) (*model.CountResult, error) + // IsReady checks if the search service is ready IsReady(ctx context.Context) error } diff --git a/internal/infrastructure/mock/access_control.go b/internal/infrastructure/mock/access_control.go index 27ba13b..afd8c4c 100644 --- a/internal/infrastructure/mock/access_control.go +++ b/internal/infrastructure/mock/access_control.go @@ -25,6 +25,10 @@ type MockAccessControlChecker struct { SimulateErrors bool // DefaultResult is the default access result ("allowed" or "denied") DefaultResult string + // Test helper fields + checkAccessResponse map[string]string + checkAccessError error + isReadyError error } // CheckAccess implements the AccessControlChecker interface with mock behavior @@ -36,6 +40,16 @@ func (m *MockAccessControlChecker) CheckAccess(ctx context.Context, subj string, "public_only", m.PublicResourcesOnly, ) + // If test has set a mock error, return it + if m.checkAccessError != nil { + return nil, m.checkAccessError + } + + // If test has set a mock response, return it + if m.checkAccessResponse != nil { + return m.checkAccessResponse, nil + } + result := make(model.AccessCheckResult) // Parse the input data - expecting line-separated permission requests @@ -85,6 +99,9 @@ func (m *MockAccessControlChecker) Close() error { // IsReady implements the AccessControlChecker interface (always ready for mock) func (m *MockAccessControlChecker) IsReady(ctx context.Context) error { + if m.isReadyError != nil { + return m.isReadyError + } return nil } @@ -171,3 +188,20 @@ func NewMockAccessControlCheckerDenyAll() *MockAccessControlChecker { DefaultResult: "denied", } } + +// Test helper methods for setting up mock responses + +// SetCheckAccessResponse sets the mock response for CheckAccess calls +func (m *MockAccessControlChecker) SetCheckAccessResponse(response map[string]string) { + m.checkAccessResponse = response +} + +// SetCheckAccessError sets the mock error for CheckAccess calls +func (m *MockAccessControlChecker) SetCheckAccessError(err error) { + m.checkAccessError = err +} + +// SetIsReadyError sets the mock error for IsReady calls +func (m *MockAccessControlChecker) SetIsReadyError(err error) { + m.isReadyError = err +} diff --git a/internal/infrastructure/mock/resource_searcher.go b/internal/infrastructure/mock/resource_searcher.go index 522339e..bcf5e3c 100644 --- a/internal/infrastructure/mock/resource_searcher.go +++ b/internal/infrastructure/mock/resource_searcher.go @@ -14,7 +14,10 @@ import ( // MockResourceSearcher is a mock implementation of ResourceSearcher for testing // This demonstrates how the clean architecture allows easy swapping of implementations type MockResourceSearcher struct { - resources []model.Resource + resources []model.Resource + queryResourcesCountResponse *model.CountResult + queryResourcesCountError error + isReadyError error } // NewMockResourceSearcher creates a new mock searcher with some sample data @@ -215,8 +218,135 @@ func (m *MockResourceSearcher) QueryResources(ctx context.Context, criteria mode return result, nil } +// QueryResourcesCount implements the ResourceSearcher interface with mock data +func (m *MockResourceSearcher) QueryResourcesCount(ctx context.Context, countCriteria model.SearchCriteria, aggregationCriteria model.SearchCriteria, publicOnly bool) (*model.CountResult, error) { + slog.DebugContext(ctx, "executing mock count search", "countCriteria", countCriteria, "aggregationCriteria", aggregationCriteria, "publicOnly", publicOnly) + + // If test has set a mock error, return it + if m.queryResourcesCountError != nil { + return nil, m.queryResourcesCountError + } + + // If test has set a mock response, return it + if m.queryResourcesCountResponse != nil { + return m.queryResourcesCountResponse, nil + } + + // Filter resources based on countCriteria + var filteredResources []model.Resource + + // Filter by public only if requested + for _, resource := range m.resources { + if publicOnly && !resource.Public { + continue + } + filteredResources = append(filteredResources, resource) + } + + // Apply count criteria filters + // Filter by type + if countCriteria.ResourceType != nil { + var typeFiltered []model.Resource + for _, resource := range filteredResources { + if resource.Type == *countCriteria.ResourceType { + typeFiltered = append(typeFiltered, resource) + } + } + filteredResources = typeFiltered + } + + // Filter by name + if countCriteria.Name != nil { + var nameFiltered []model.Resource + searchName := strings.ToLower(*countCriteria.Name) + for _, resource := range filteredResources { + if data, ok := resource.Data.(map[string]interface{}); ok { + nameMatch := false + if name, ok := data["name"].(string); ok { + if strings.Contains(strings.ToLower(name), searchName) { + nameMatch = true + } + } + if !nameMatch && resource.Type == "project" { + if slug, ok := data["slug"].(string); ok { + if strings.Contains(strings.ToLower(slug), searchName) { + nameMatch = true + } + } + } + if nameMatch { + nameFiltered = append(nameFiltered, resource) + } + } + } + filteredResources = nameFiltered + } + + // Filter by tags + if len(countCriteria.Tags) > 0 { + var tagFiltered []model.Resource + for _, resource := range filteredResources { + if data, ok := resource.Data.(map[string]interface{}); ok { + if resourceTags, ok := data["tags"].([]string); ok { + for _, requestedTag := range countCriteria.Tags { + for _, resourceTag := range resourceTags { + if requestedTag == resourceTag { + tagFiltered = append(tagFiltered, resource) + goto nextResourceCount + } + } + } + } + } + nextResourceCount: + } + filteredResources = tagFiltered + } + + // Build aggregation based on aggregationCriteria + aggregationBuckets := make(map[string]uint64) + + // If aggregation criteria has a resource type, group by that type + if aggregationCriteria.ResourceType != nil && *aggregationCriteria.ResourceType != "" { + // Group resources by type + for _, resource := range filteredResources { + aggregationBuckets[resource.Type]++ + } + } else { + // Default aggregation by resource type + for _, resource := range filteredResources { + aggregationBuckets[resource.Type]++ + } + } + + // Convert map to buckets slice + var buckets []model.AggregationBucket + for key, count := range aggregationBuckets { + buckets = append(buckets, model.AggregationBucket{ + Key: key, + DocCount: count, + }) + } + + result := &model.CountResult{ + Count: len(filteredResources), + Aggregation: model.TermsAggregation{ + DocCountErrorUpperBound: 0, + SumOtherDocCount: 0, + Buckets: buckets, + }, + HasMore: false, + } + + slog.DebugContext(ctx, "mock count search completed", "total_count", result.Count, "buckets", len(buckets)) + return result, nil +} + // IsReady implements the ResourceSearcher interface (always ready for mock) func (m *MockResourceSearcher) IsReady(ctx context.Context) error { + if m.isReadyError != nil { + return m.isReadyError + } return nil } @@ -314,3 +444,20 @@ func (m *MockResourceSearcher) ClearResources() { func (m *MockResourceSearcher) GetResourceCount() int { return len(m.resources) } + +// Test helper methods for setting up mock responses + +// SetQueryResourcesCountResponse sets the mock response for QueryResourcesCount calls +func (m *MockResourceSearcher) SetQueryResourcesCountResponse(response *model.CountResult) { + m.queryResourcesCountResponse = response +} + +// SetQueryResourcesCountError sets the mock error for QueryResourcesCount calls +func (m *MockResourceSearcher) SetQueryResourcesCountError(err error) { + m.queryResourcesCountError = err +} + +// SetIsReadyError sets the mock error for IsReady calls +func (m *MockResourceSearcher) SetIsReadyError(err error) { + m.isReadyError = err +} diff --git a/internal/infrastructure/mock/resource_searcher_test.go b/internal/infrastructure/mock/resource_searcher_test.go new file mode 100644 index 0000000..855de7e --- /dev/null +++ b/internal/infrastructure/mock/resource_searcher_test.go @@ -0,0 +1,190 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package mock + +import ( + "context" + "testing" + + "github.com/linuxfoundation/lfx-v2-query-service/internal/domain/model" + "github.com/stretchr/testify/assert" +) + +func TestMockResourceSearcherQueryResourcesCount(t *testing.T) { + tests := []struct { + name string + countCriteria model.SearchCriteria + aggregationCriteria model.SearchCriteria + publicOnly bool + expectedCount int + expectedError bool + }{ + { + name: "count all resources", + countCriteria: model.SearchCriteria{ + PageSize: -1, + }, + aggregationCriteria: model.SearchCriteria{}, + publicOnly: false, + expectedCount: 5, // Total resources in mock data + expectedError: false, + }, + { + name: "count public only resources", + countCriteria: model.SearchCriteria{ + PageSize: -1, + }, + aggregationCriteria: model.SearchCriteria{}, + publicOnly: true, + expectedCount: 1, // Only one public resource in mock data + expectedError: false, + }, + { + name: "count resources by type", + countCriteria: model.SearchCriteria{ + ResourceType: stringPtr("committee"), + PageSize: -1, + }, + aggregationCriteria: model.SearchCriteria{}, + publicOnly: false, + expectedCount: 2, // Two committees in mock data + expectedError: false, + }, + { + name: "count resources by name", + countCriteria: model.SearchCriteria{ + Name: stringPtr("Security"), + PageSize: -1, + }, + aggregationCriteria: model.SearchCriteria{}, + publicOnly: false, + expectedCount: 2, // Resources containing "Security" in name + expectedError: false, + }, + { + name: "count resources by tags", + countCriteria: model.SearchCriteria{ + Tags: []string{"active"}, + PageSize: -1, + }, + aggregationCriteria: model.SearchCriteria{}, + publicOnly: false, + expectedCount: 5, // All resources have "active" tag + expectedError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertion := assert.New(t) + + // Create mock searcher + searcher := NewMockResourceSearcher() + + // Execute + ctx := context.Background() + result, err := searcher.QueryResourcesCount(ctx, tc.countCriteria, tc.aggregationCriteria, tc.publicOnly) + + // Verify + if tc.expectedError { + assertion.Error(err) + assertion.Nil(result) + } else { + assertion.NoError(err) + assertion.NotNil(result) + assertion.Equal(tc.expectedCount, result.Count) + assertion.NotNil(result.Aggregation) + assertion.False(result.HasMore) // Mock always returns false for HasMore + } + }) + } +} + +func TestMockResourceSearcherQueryResourcesCountWithAggregation(t *testing.T) { + assertion := assert.New(t) + + // Create mock searcher + searcher := NewMockResourceSearcher() + + // Test aggregation by resource type + countCriteria := model.SearchCriteria{ + PageSize: -1, + } + aggregationCriteria := model.SearchCriteria{ + ResourceType: stringPtr(""), + } + + ctx := context.Background() + result, err := searcher.QueryResourcesCount(ctx, countCriteria, aggregationCriteria, false) + + assertion.NoError(err) + assertion.NotNil(result) + assertion.Equal(5, result.Count) // Total count + assertion.NotNil(result.Aggregation) + assertion.Greater(len(result.Aggregation.Buckets), 0) // Should have aggregation buckets + + // Verify aggregation buckets contain expected types + bucketKeys := make([]string, len(result.Aggregation.Buckets)) + for i, bucket := range result.Aggregation.Buckets { + bucketKeys[i] = bucket.Key + } + assertion.Contains(bucketKeys, "committee") + assertion.Contains(bucketKeys, "project") + assertion.Contains(bucketKeys, "meeting") +} + +func TestMockResourceSearcherAddResource(t *testing.T) { + assertion := assert.New(t) + + // Create mock searcher + searcher := NewMockResourceSearcher() + initialCount := searcher.GetResourceCount() + + // Add a new resource + newResource := NewResourceWithDefaults("test-type", "test-id", map[string]any{ + "name": "Test Resource", + }, true) + + searcher.AddResource(newResource) + + // Verify count increased + assertion.Equal(initialCount+1, searcher.GetResourceCount()) + + // Verify the resource can be found + ctx := context.Background() + result, err := searcher.QueryResources(ctx, model.SearchCriteria{ + ResourceType: stringPtr("test-type"), + }) + + assertion.NoError(err) + assertion.Equal(1, len(result.Resources)) + assertion.Equal("test-id", result.Resources[0].ID) + assertion.Equal("test-type", result.Resources[0].Type) +} + +func TestMockResourceSearcherClearResources(t *testing.T) { + assertion := assert.New(t) + + // Create mock searcher + searcher := NewMockResourceSearcher() + assertion.Greater(searcher.GetResourceCount(), 0) + + // Clear resources + searcher.ClearResources() + + // Verify count is zero + assertion.Equal(0, searcher.GetResourceCount()) + + // Verify search returns empty + ctx := context.Background() + result, err := searcher.QueryResources(ctx, model.SearchCriteria{}) + + assertion.NoError(err) + assertion.Equal(0, len(result.Resources)) +} + +// Helper function to create string pointers +func stringPtr(s string) *string { + return &s +} \ No newline at end of file diff --git a/internal/infrastructure/opensearch/client.go b/internal/infrastructure/opensearch/client.go index 0f2ed3a..8edf384 100644 --- a/internal/infrastructure/opensearch/client.go +++ b/internal/infrastructure/opensearch/client.go @@ -6,6 +6,7 @@ package opensearch import ( "bytes" "context" + "encoding/json" "fmt" "log/slog" "net/http" @@ -90,6 +91,46 @@ func (c *httpClient) Search(ctx context.Context, index string, query []byte) (*S return result, nil } +func (c *httpClient) AggregationSearch(ctx context.Context, index string, query []byte) (*AggregationResponse, error) { + searchRequest := opensearchapi.SearchReq{ + Indices: []string{index}, + Body: bytes.NewReader(query), + } + + // Perform the search. + searchResponse, err := c.client.Search(ctx, &searchRequest) + if err != nil { + return nil, fmt.Errorf("opensearch search failed: %w", err) + } + + if searchResponse.Errors { + return nil, fmt.Errorf("opensearch search returned errors") + } + + // First, unmarshal the aggregations from raw JSON. + var aggregations AggregationResponse + if err := json.Unmarshal(searchResponse.Aggregations, &aggregations); err != nil { + slog.ErrorContext(ctx, "failed to unmarshal aggregations", "error", err) + return nil, fmt.Errorf("unrecoverable aggregation processing error: %w", err) + } + + return &aggregations, nil +} + +func (c *httpClient) Count(ctx context.Context, index string, query []byte) (*CountResponse, error) { + countRequest := opensearchapi.IndicesCountReq{ + Indices: []string{index}, + Body: bytes.NewReader(query), + } + countResponse, err := c.client.Indices.Count(ctx, &countRequest) + if err != nil { + return nil, fmt.Errorf("opensearch count failed: %w", err) + } + return &CountResponse{ + Count: countResponse.Count, + }, nil +} + func (c *httpClient) IsReady(ctx context.Context) error { pingReq := &opensearchapi.PingReq{ Params: opensearchapi.PingParams{ diff --git a/internal/infrastructure/opensearch/models.go b/internal/infrastructure/opensearch/models.go index f1098f6..170b2ff 100644 --- a/internal/infrastructure/opensearch/models.go +++ b/internal/infrastructure/opensearch/models.go @@ -17,6 +17,28 @@ type SearchResponse struct { PageToken *string `json:"last_item_id,omitempty"` } +type CountResponse struct { + Count int `json:"count"` +} + +// AggregationBucket represents a single aggregation bucket. +type AggregationBucket struct { + Key string `json:"key"` + DocCount uint64 `json:"doc_count"` +} + +// TermsAggregation represents a terms aggregation response. +type TermsAggregation struct { + DocCountErrorUpperBound uint64 `json:"doc_count_error_upper_bound"` + SumOtherDocCount uint64 `json:"sum_other_doc_count"` + Buckets []AggregationBucket `json:"buckets"` +} + +// AggregationResponse represents the aggregations in a search response. +type AggregationResponse struct { + GroupBy TermsAggregation `json:"group_by"` +} + // Hits represents the hits in the search response type Hits struct { Total `json:"total"` diff --git a/internal/infrastructure/opensearch/searcher.go b/internal/infrastructure/opensearch/searcher.go index 4b780c3..bf1638f 100644 --- a/internal/infrastructure/opensearch/searcher.go +++ b/internal/infrastructure/opensearch/searcher.go @@ -40,6 +40,8 @@ type OpenSearchSearcher struct { // This allows for easy mocking and testing type OpenSearchClientRetriever interface { Search(ctx context.Context, index string, query []byte) (*SearchResponse, error) + Count(ctx context.Context, index string, query []byte) (*CountResponse, error) + AggregationSearch(ctx context.Context, index string, query []byte) (*AggregationResponse, error) IsReady(ctx context.Context) error } @@ -62,7 +64,7 @@ func (os *OpenSearchSearcher) QueryResources(ctx context.Context, criteria model } // Convert response to domain objects - result, err := os.convertResponse(ctx, response) + result, err := os.convertSearchResponse(ctx, response) if err != nil { return nil, fmt.Errorf("failed to convert search response: %w", err) } @@ -73,6 +75,62 @@ func (os *OpenSearchSearcher) QueryResources(ctx context.Context, criteria model return result, nil } +func (os *OpenSearchSearcher) QueryResourcesCount( + ctx context.Context, + publicCountCriteria model.SearchCriteria, + aggregationCriteria model.SearchCriteria, + publicOnly bool, +) (*model.CountResult, error) { + slog.DebugContext(ctx, "executing opensearch query for criteria", + "public_count_criteria", publicCountCriteria, + "aggregation_criteria", aggregationCriteria, + ) + + parsedCount, err := os.Render(ctx, publicCountCriteria) + if err != nil { + // Not expected to happen: this is an error with our interpolation logic. + slog.ErrorContext(ctx, "unrecoverable request parsing error", "error", err) + return nil, fmt.Errorf("failed to render query: %w", err) + } + slog.DebugContext(ctx, "public resource count query", "query", string(parsedCount)) + + // Execute the search + countResponse, err := os.client.Count(ctx, os.index, parsedCount) + if err != nil { + return nil, fmt.Errorf("opensearch search failed: %w", err) + } + + if publicOnly { + return &model.CountResult{ + Count: countResponse.Count, + }, nil + } + + parsedSearch, err := os.Render(ctx, aggregationCriteria) + if err != nil { + // Not expected to happen: this is an error with our interpolation logic. + slog.ErrorContext(ctx, "unrecoverable request parsing error", "error", err) + return nil, fmt.Errorf("failed to render query: %w", err) + } + slog.DebugContext(ctx, "resource aggregation query", "query", string(parsedSearch)) + + aggregationResponse, err := os.client.AggregationSearch(ctx, os.index, parsedSearch) + if err != nil { + return nil, fmt.Errorf("opensearch search failed: %w", err) + } + + slog.DebugContext(ctx, "aggregation response", "response", aggregationResponse) + + result, err := os.convertCountResponse(ctx, countResponse, aggregationResponse) + if err != nil { + return nil, fmt.Errorf("failed to convert count response: %w", err) + } + + slog.DebugContext(ctx, "converted count response", "response", result) + + return result, nil +} + // Render generates the OpenSearch query based on the provided search criteria func (os *OpenSearchSearcher) Render(ctx context.Context, criteria model.SearchCriteria) ([]byte, error) { var buf bytes.Buffer @@ -91,7 +149,7 @@ func (os *OpenSearchSearcher) Render(ctx context.Context, criteria model.SearchC } // convertResponse converts OpenSearch response to domain objects -func (os *OpenSearchSearcher) convertResponse(ctx context.Context, response *SearchResponse) (*model.SearchResult, error) { +func (os *OpenSearchSearcher) convertSearchResponse(ctx context.Context, response *SearchResponse) (*model.SearchResult, error) { result := &model.SearchResult{ Resources: make([]model.Resource, 0, len(response.Hits.Hits)), @@ -147,6 +205,25 @@ func (os *OpenSearchSearcher) convertHit(hit Hit) (model.Resource, error) { return resource, nil } +func (os *OpenSearchSearcher) convertCountResponse(ctx context.Context, response *CountResponse, aggregationResponse *AggregationResponse) (*model.CountResult, error) { + aggregation := model.TermsAggregation{ + DocCountErrorUpperBound: aggregationResponse.GroupBy.DocCountErrorUpperBound, + SumOtherDocCount: aggregationResponse.GroupBy.SumOtherDocCount, + } + aggregationBuckets := make([]model.AggregationBucket, len(aggregationResponse.GroupBy.Buckets)) + for i, bucket := range aggregationResponse.GroupBy.Buckets { + aggregationBuckets[i] = model.AggregationBucket{ + Key: bucket.Key, + DocCount: bucket.DocCount, + } + } + aggregation.Buckets = aggregationBuckets + return &model.CountResult{ + Count: response.Count, + Aggregation: aggregation, + }, nil +} + func (o *OpenSearchSearcher) IsReady(ctx context.Context) error { if err := o.client.IsReady(ctx); err != nil { slog.ErrorContext(ctx, "opensearch client is not ready", "error", err) diff --git a/internal/infrastructure/opensearch/searcher_test.go b/internal/infrastructure/opensearch/searcher_test.go index 68caa8e..b7254b7 100644 --- a/internal/infrastructure/opensearch/searcher_test.go +++ b/internal/infrastructure/opensearch/searcher_test.go @@ -16,8 +16,12 @@ import ( // MockOpenSearchClient is a mock implementation of OpenSearchClientRetriever type MockOpenSearchClient struct { - searchResponse *SearchResponse - searchError error + searchResponse *SearchResponse + searchError error + countResponse *CountResponse + countError error + aggregationResponse *AggregationResponse + aggregationError error } func NewMockOpenSearchClient() *MockOpenSearchClient { @@ -31,6 +35,20 @@ func (m *MockOpenSearchClient) Search(ctx context.Context, index string, query [ return m.searchResponse, nil } +func (m *MockOpenSearchClient) Count(ctx context.Context, index string, query []byte) (*CountResponse, error) { + if m.countError != nil { + return nil, m.countError + } + return m.countResponse, nil +} + +func (m *MockOpenSearchClient) AggregationSearch(ctx context.Context, index string, query []byte) (*AggregationResponse, error) { + if m.aggregationError != nil { + return nil, m.aggregationError + } + return m.aggregationResponse, nil +} + func (m *MockOpenSearchClient) SetSearchResponse(response *SearchResponse) { m.searchResponse = response } @@ -39,6 +57,22 @@ func (m *MockOpenSearchClient) SetSearchError(err error) { m.searchError = err } +func (m *MockOpenSearchClient) SetCountResponse(response *CountResponse) { + m.countResponse = response +} + +func (m *MockOpenSearchClient) SetCountError(err error) { + m.countError = err +} + +func (m *MockOpenSearchClient) SetAggregationResponse(response *AggregationResponse) { + m.aggregationResponse = response +} + +func (m *MockOpenSearchClient) SetAggregationError(err error) { + m.aggregationError = err +} + func (m *MockOpenSearchClient) IsReady(ctx context.Context) error { return nil } @@ -383,7 +417,7 @@ func TestOpenSearchSearcherConvertResponse(t *testing.T) { // Execute ctx := context.Background() - result, err := searcher.convertResponse(ctx, tc.response) + result, err := searcher.convertSearchResponse(ctx, tc.response) // Verify if tc.expectedError { @@ -646,6 +680,139 @@ func TestOpenSearchSearcherIntegration(t *testing.T) { }) } +func TestOpenSearchSearcherQueryResourcesCount(t *testing.T) { + tests := []struct { + name string + countCriteria model.SearchCriteria + aggregationCriteria model.SearchCriteria + publicOnly bool + setupMock func(*MockOpenSearchClient) + expectedError bool + expectedCount int + expectedAggregationLen int + }{ + { + name: "successful public only count", + countCriteria: model.SearchCriteria{ + ResourceType: stringPtr("project"), + PageSize: -1, + PublicOnly: true, + }, + aggregationCriteria: model.SearchCriteria{}, + publicOnly: true, + setupMock: func(mock *MockOpenSearchClient) { + mock.SetCountResponse(&CountResponse{ + Count: 5, + }) + }, + expectedError: false, + expectedCount: 5, + }, + { + name: "successful count with aggregation", + countCriteria: model.SearchCriteria{ + ResourceType: stringPtr("committee"), + PageSize: -1, + PublicOnly: true, + }, + aggregationCriteria: model.SearchCriteria{ + GroupBy: "access_check_query.keyword", + PageSize: 0, + PrivateOnly: true, + }, + publicOnly: false, + setupMock: func(mock *MockOpenSearchClient) { + mock.SetCountResponse(&CountResponse{ + Count: 3, + }) + mock.SetAggregationResponse(&AggregationResponse{ + GroupBy: TermsAggregation{ + DocCountErrorUpperBound: 0, + SumOtherDocCount: 0, + Buckets: []AggregationBucket{ + { + Key: "committee:123#viewer@user:test-user", + DocCount: 2, + }, + { + Key: "committee:456#member@user:test-user", + DocCount: 1, + }, + }, + }, + }) + }, + expectedError: false, + expectedCount: 3, + expectedAggregationLen: 2, + }, + { + name: "count error", + countCriteria: model.SearchCriteria{ + ResourceType: stringPtr("project"), + }, + aggregationCriteria: model.SearchCriteria{}, + publicOnly: true, + setupMock: func(mock *MockOpenSearchClient) { + mock.SetCountError(errors.New("opensearch count failed")) + }, + expectedError: true, + }, + { + name: "aggregation error", + countCriteria: model.SearchCriteria{ + ResourceType: stringPtr("committee"), + }, + aggregationCriteria: model.SearchCriteria{ + GroupBy: "access_check_query.keyword", + }, + publicOnly: false, + setupMock: func(mock *MockOpenSearchClient) { + mock.SetCountResponse(&CountResponse{ + Count: 2, + }) + mock.SetAggregationError(errors.New("opensearch aggregation failed")) + }, + expectedError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertion := assert.New(t) + + // Setup mock + mockClient := NewMockOpenSearchClient() + tc.setupMock(mockClient) + + // Create searcher + searcher := &OpenSearchSearcher{ + client: mockClient, + index: "test-index", + } + + // Execute + ctx := context.Background() + result, err := searcher.QueryResourcesCount(ctx, tc.countCriteria, tc.aggregationCriteria, tc.publicOnly) + + // Verify + if tc.expectedError { + assertion.Error(err) + assertion.Nil(result) + } else { + assertion.NoError(err) + assertion.NotNil(result) + assertion.Equal(tc.expectedCount, result.Count) + + if tc.expectedAggregationLen > 0 { + assertion.Equal(tc.expectedAggregationLen, len(result.Aggregation.Buckets)) + } + } + }) + } +} + + // Helper function to create string pointers func stringPtr(s string) *string { return &s diff --git a/internal/infrastructure/opensearch/template.go b/internal/infrastructure/opensearch/template.go index 91e930d..2ccd3b8 100644 --- a/internal/infrastructure/opensearch/template.go +++ b/internal/infrastructure/opensearch/template.go @@ -4,7 +4,9 @@ package opensearch const queryResourceSource = `{ + {{- if ge .PageSize 0 }} "size": {{ .PageSize }}, + {{- end }} "query": { "bool": { "must": [ @@ -65,7 +67,8 @@ const queryResourceSource = `{ } {{- if .SearchAfter }}, "search_after": {{ .SearchAfter }} - {{- end }}, + {{- end }} + {{- if gt .PageSize 0 }}, "sort": [ { {{ .SortBy | quote }}: { @@ -74,4 +77,15 @@ const queryResourceSource = `{ }, {"_id": "asc"} ] + {{- end }} + {{- if .GroupBy }}, + "aggs": { + "group_by": { + "terms": { + "field": {{ .GroupBy | quote }}, + "size": {{ .GroupBySize }} + } + } + } + {{- end }} }` diff --git a/internal/service/resource_search.go b/internal/service/resource_search.go index 03329ca..e26d445 100644 --- a/internal/service/resource_search.go +++ b/internal/service/resource_search.go @@ -22,6 +22,9 @@ type ResourceSearcher interface { // QueryResources searches for resources based on the provided criteria QueryResources(ctx context.Context, criteria model.SearchCriteria) (*model.SearchResult, error) + // QueryResourcesCount searches for resources based on the provided criteria + QueryResourcesCount(ctx context.Context, countCriteria model.SearchCriteria, aggregationCriteria model.SearchCriteria) (*model.CountResult, error) + // IsReady checks if the search service is ready IsReady(ctx context.Context) error } @@ -206,6 +209,134 @@ func (s *ResourceSearch) CheckAccess(ctx context.Context, principal string, reso } +func (s *ResourceSearch) QueryResourcesCount( + ctx context.Context, + publicCountCriteria model.SearchCriteria, + aggregationCriteria model.SearchCriteria, +) (*model.CountResult, error) { + + slog.DebugContext(ctx, "starting resource count search", + "count_criteria", publicCountCriteria, + "aggregation_criteria", aggregationCriteria, + ) + + // Grab the principal which was stored into the context by the security handler. + principal, ok := ctx.Value(constants.PrincipalContextID).(string) + if !ok { + // This should not happen; the Auther always sets this or errors. + return nil, errors.NewValidation("missing principal in context") + } + + // Log the search operation + slog.DebugContext(ctx, "validated search criteria, proceeding with count search") + + // Delegate to the search implementation + publicOnly := principal == constants.AnonymousPrincipal + result, err := s.resourceSearcher.QueryResourcesCount(ctx, publicCountCriteria, aggregationCriteria, publicOnly) + if err != nil { + slog.ErrorContext(ctx, "search operation failed while executing query resources", + "error", err, + ) + return nil, fmt.Errorf("search operation failed: %w", err) + } + + // If the principal is anonymous, we can return the result immediately without checking access control + // since we already retrieved the public-only count. + if principal == constants.AnonymousPrincipal { + slog.DebugContext(ctx, "returning anonymous count result", + "count", result.Count, + ) + // Set a cache control header for anonymous users. + cacheControl := constants.AnonymousCacheControlHeader + result.CacheControl = &cacheControl + return result, nil + } + + slog.DebugContext(ctx, "checking access control for private resources", + "aggregations", result.Aggregation, + ) + + messageCheckAccess := s.BuildCountMessage(ctx, principal, result, aggregationCriteria) + + // Check access control for the resources to determine the authorized response count + privateCount, err := s.CheckCountAccess(ctx, principal, result, messageCheckAccess) + if err != nil { + slog.ErrorContext(ctx, "access control check failed", + "error", err, + ) + return nil, fmt.Errorf("access control check failed: %w", err) + } + // The count already contains the count of public resources, so we need to add the count of private resources. + result.Count += int(privateCount) + + // Check for bucket overflow. + // There could be more buckets than the page size, and therefore more results. + if result.Aggregation.SumOtherDocCount > 0 { + result.HasMore = true + } + + return result, nil +} + +func (s *ResourceSearch) BuildCountMessage(ctx context.Context, principal string, result *model.CountResult, aggregationCriteria model.SearchCriteria) []byte { + + // Create a map to store the "doc_count" of each aggregation bucket. + docCountMap := make(map[string]uint64, aggregationCriteria.PageSize) + + // estimate the size of each line in the access check message + accessCheckMessage := make([]byte, 0, 80*aggregationCriteria.PageSize) + + for _, bucket := range result.Aggregation.Buckets { + docCountMap[bucket.Key] = bucket.DocCount + accessCheckMessage = append(accessCheckMessage, bucket.Key...) + accessCheckMessage = append(accessCheckMessage, []byte("@user:")...) + accessCheckMessage = append(accessCheckMessage, []byte(principal)...) + accessCheckMessage = append(accessCheckMessage, '\n') + } + + return accessCheckMessage +} + +func (s *ResourceSearch) CheckCountAccess(ctx context.Context, principal string, result *model.CountResult, accessCheckMessage []byte) (uint64, error) { + var accessCheckResponses map[string]string + if len(accessCheckMessage) > 0 { + slog.DebugContext(ctx, "performing access control checks", + "message", string(accessCheckMessage), + ) + + // Trim trailing newline. + accessCheckMessage = accessCheckMessage[:len(accessCheckMessage)-1] + accessCheckResult, errCheckAccess := s.accessChecker.CheckAccess(ctx, constants.AccessCheckSubject, accessCheckMessage, 15*time.Second) + if errCheckAccess != nil { + slog.ErrorContext(ctx, "access control check failed", + "error", errCheckAccess, + "message", string(accessCheckMessage), + ) + return 0, fmt.Errorf("access control check failed: %w", errCheckAccess) + } + accessCheckResponses = accessCheckResult + } + slog.DebugContext(ctx, "access check responses", "responses", accessCheckResponses) + + var count uint64 + for _, bucket := range result.Aggregation.Buckets { + // The bucket.Key already contains the full access check query including the principal + // e.g.: "committee:830513f8-0e77-4a48-a8e4-ede4c1a61f98#viewer@user:project_super_admin" + // The BuildCountMessage function appends "@user:" + principal to create the access check key + // So we need to use the same format here + accessCheckKey := bucket.Key + "@user:" + principal + slog.DebugContext(ctx, "checking access control for bucket", + "bucket", bucket.Key, + "access_check_key", accessCheckKey, + ) + if allowed, ok := accessCheckResponses[accessCheckKey]; ok && allowed == "true" { + count += bucket.DocCount + } + } + + return count, nil +} + func (s *ResourceSearch) IsReady(ctx context.Context) error { if err := s.resourceSearcher.IsReady(ctx); err != nil { return err diff --git a/internal/service/resource_search_test.go b/internal/service/resource_search_test.go index 4995f0b..7ddb8f6 100644 --- a/internal/service/resource_search_test.go +++ b/internal/service/resource_search_test.go @@ -749,6 +749,311 @@ func TestResourceSearchQueryResourcesEdgeCases(t *testing.T) { }) } +func TestResourceCountQueryResourcesCount(t *testing.T) { + tests := []struct { + name string + countCriteria model.SearchCriteria + aggregationCriteria model.SearchCriteria + principal string + setupMocks func(*mock.MockResourceSearcher, *mock.MockAccessControlChecker) + expectedError bool + expectedCount int + expectedCacheControl bool + }{ + { + name: "successful count with anonymous user", + countCriteria: model.SearchCriteria{ + ResourceType: stringPtr("project"), + PageSize: -1, + PublicOnly: true, + }, + aggregationCriteria: model.SearchCriteria{}, + principal: constants.AnonymousPrincipal, + setupMocks: func(resourceSearcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + resourceSearcher.SetQueryResourcesCountResponse(&model.CountResult{ + Count: 3, + HasMore: false, + }) + }, + expectedError: false, + expectedCount: 3, + expectedCacheControl: true, + }, + { + name: "successful count with authenticated user - public only", + countCriteria: model.SearchCriteria{ + ResourceType: stringPtr("project"), + PageSize: -1, + PublicOnly: true, + }, + aggregationCriteria: model.SearchCriteria{ + GroupBy: "access_check_query.keyword", + PageSize: 0, + PrivateOnly: true, + }, + principal: "user:test-user", + setupMocks: func(resourceSearcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + resourceSearcher.SetQueryResourcesCountResponse(&model.CountResult{ + Count: 2, + Aggregation: model.TermsAggregation{ + Buckets: []model.AggregationBucket{ + {Key: "project:123#viewer", DocCount: 1}, + {Key: "project:456#contributor", DocCount: 2}, + }, + }, + HasMore: false, + }) + accessChecker.SetCheckAccessResponse(map[string]string{ + "project:123#viewer@user:test-user": "true", + "project:456#contributor@user:test-user": "false", + }) + }, + expectedError: false, + expectedCount: 2, + expectedCacheControl: false, + }, + { + name: "successful count with authenticated user - with private access", + countCriteria: model.SearchCriteria{ + PageSize: -1, + PublicOnly: true, + }, + aggregationCriteria: model.SearchCriteria{ + GroupBy: "access_check_query.keyword", + PageSize: 0, + PrivateOnly: true, + }, + principal: "user:admin", + setupMocks: func(resourceSearcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + resourceSearcher.SetQueryResourcesCountResponse(&model.CountResult{ + Count: 5, + Aggregation: model.TermsAggregation{ + Buckets: []model.AggregationBucket{ + {Key: "committee:789#member", DocCount: 3}, + {Key: "project:101#viewer", DocCount: 2}, + }, + }, + HasMore: false, + }) + accessChecker.SetCheckAccessResponse(map[string]string{ + "committee:789#member@user:admin": "true", + "project:101#viewer@user:admin": "true", + }) + }, + expectedError: false, + expectedCount: 5, + expectedCacheControl: false, + }, + { + name: "search error", + countCriteria: model.SearchCriteria{ + ResourceType: stringPtr("invalid"), + }, + aggregationCriteria: model.SearchCriteria{}, + principal: "user:test-user", + setupMocks: func(resourceSearcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + resourceSearcher.SetQueryResourcesCountError(assert.AnError) + }, + expectedError: true, + }, + { + name: "access control check error", + countCriteria: model.SearchCriteria{ + PageSize: -1, + PublicOnly: true, + }, + aggregationCriteria: model.SearchCriteria{ + GroupBy: "access_check_query.keyword", + PageSize: 0, + PrivateOnly: true, + }, + principal: "user:test-user", + setupMocks: func(resourceSearcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) { + resourceSearcher.SetQueryResourcesCountResponse(&model.CountResult{ + Count: 2, + Aggregation: model.TermsAggregation{ + Buckets: []model.AggregationBucket{ + {Key: "project:123#viewer", DocCount: 1}, + }, + }, + HasMore: false, + }) + accessChecker.SetCheckAccessError(assert.AnError) + }, + expectedError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertion := assert.New(t) + + // Setup mocks + resourceSearcher := mock.NewMockResourceSearcher() + accessChecker := mock.NewMockAccessControlChecker() + tc.setupMocks(resourceSearcher, accessChecker) + + // Create service + service := NewResourceSearch(resourceSearcher, accessChecker) + + // Create context with principal + ctx := context.WithValue(context.Background(), constants.PrincipalContextID, tc.principal) + + // Execute + result, err := service.QueryResourcesCount(ctx, tc.countCriteria, tc.aggregationCriteria) + + // Verify + if tc.expectedError { + assertion.Error(err) + assertion.Nil(result) + } else { + assertion.NoError(err) + assertion.NotNil(result) + assertion.Equal(tc.expectedCount, result.Count) + + if tc.expectedCacheControl { + assertion.NotNil(result.CacheControl) + } else { + // For non-anonymous users, CacheControl might be nil + // This depends on implementation + } + } + }) + } +} + +func TestResourceCountBuildMessage(t *testing.T) { + assertion := assert.New(t) + + // Setup + resourceSearcher := mock.NewMockResourceSearcher() + accessChecker := mock.NewMockAccessControlChecker() + service := &ResourceSearch{ + resourceSearcher: resourceSearcher, + accessChecker: accessChecker, + } + + // Test data + result := &model.CountResult{ + Aggregation: model.TermsAggregation{ + Buckets: []model.AggregationBucket{ + {Key: "committee:123#member", DocCount: 2}, + {Key: "project:456#viewer", DocCount: 3}, + }, + }, + } + + criteria := model.SearchCriteria{ + PageSize: 10, + } + + // Execute + ctx := context.Background() + message := service.BuildCountMessage(ctx, "test-user", result, criteria) + + // Verify + assertion.NotNil(message) + messageStr := string(message) + assertion.Contains(messageStr, "committee:123#member@user:test-user") + assertion.Contains(messageStr, "project:456#viewer@user:test-user") + assertion.Contains(messageStr, "\n") +} + +func TestResourceCountCheckAccess(t *testing.T) { + tests := []struct { + name string + result *model.CountResult + accessResponses map[string]string + expectedCount uint64 + expectedError bool + setupAccessChecker func(*mock.MockAccessControlChecker) + }{ + { + name: "successful access check with allowed resources", + result: &model.CountResult{ + Aggregation: model.TermsAggregation{ + Buckets: []model.AggregationBucket{ + {Key: "committee:123#member", DocCount: 2}, + {Key: "project:456#viewer", DocCount: 3}, + }, + }, + }, + setupAccessChecker: func(checker *mock.MockAccessControlChecker) { + checker.SetCheckAccessResponse(map[string]string{ + "committee:123#member@user:test-user": "true", + "project:456#viewer@user:test-user": "false", + }) + }, + expectedCount: 2, // Only committee:123#member is allowed + expectedError: false, + }, + { + name: "successful access check with all denied", + result: &model.CountResult{ + Aggregation: model.TermsAggregation{ + Buckets: []model.AggregationBucket{ + {Key: "committee:123#member", DocCount: 2}, + {Key: "project:456#viewer", DocCount: 3}, + }, + }, + }, + setupAccessChecker: func(checker *mock.MockAccessControlChecker) { + checker.SetCheckAccessResponse(map[string]string{ + "committee:123#member@user:test-user": "false", + "project:456#viewer@user:test-user": "false", + }) + }, + expectedCount: 0, + expectedError: false, + }, + { + name: "access check error", + result: &model.CountResult{ + Aggregation: model.TermsAggregation{ + Buckets: []model.AggregationBucket{ + {Key: "committee:123#member", DocCount: 2}, + }, + }, + }, + setupAccessChecker: func(checker *mock.MockAccessControlChecker) { + checker.SetCheckAccessError(assert.AnError) + }, + expectedError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertion := assert.New(t) + + // Setup + resourceSearcher := mock.NewMockResourceSearcher() + accessChecker := mock.NewMockAccessControlChecker() + tc.setupAccessChecker(accessChecker) + + service := &ResourceSearch{ + resourceSearcher: resourceSearcher, + accessChecker: accessChecker, + } + + // Build message + ctx := context.Background() + message := service.BuildCountMessage(ctx, "test-user", tc.result, model.SearchCriteria{PageSize: 10}) + + // Execute + count, err := service.CheckCountAccess(ctx, "test-user", tc.result, message) + + // Verify + if tc.expectedError { + assertion.Error(err) + } else { + assertion.NoError(err) + assertion.Equal(tc.expectedCount, count) + } + }) + } +} + // Helper function to create string pointers func stringPtr(s string) *string { return &s diff --git a/pkg/constants/query.go b/pkg/constants/query.go index ec3d59f..8d85981 100644 --- a/pkg/constants/query.go +++ b/pkg/constants/query.go @@ -7,4 +7,6 @@ const ( // DefaultPageSize is the default number of results per page for queries DefaultPageSize = 50 + // DefaultBucketSize is the default size of the bucket for queries + DefaultBucketSize = 10 )