diff --git a/CLAUDE.md b/CLAUDE.md index c83f053..8ec7750 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,3 +110,76 @@ Environment variables control implementation selection: - Unit tests use mock implementations - Integration tests can switch between real and mock implementations - Test files follow `*_test.go` pattern alongside implementation files + +## CEL Filter Feature + +The service supports Common Expression Language (CEL) filtering for post-query resource filtering. + +### Overview + +CEL filtering allows API consumers to filter resources on arbitrary data fields using a safe, non-Turing complete expression language. The filter is applied after the OpenSearch query but before access control checks. + +### Implementation Details + +**Location**: `internal/infrastructure/filter/cel_filter.go` + +**Key Components**: +- **ResourceFilter Interface** (`internal/domain/port/filter.go`): Domain interface for filtering +- **CELFilter Implementation**: Uses `google/cel-go` library for expression evaluation +- **Expression Caching**: LRU cache with TTL for compiled CEL programs +- **Security Features**: Max expression length (1000 chars), evaluation timeout (100ms per resource) + +**Integration Point**: `internal/service/resource_search.go` (lines 84-102) +- CEL filter applied after OpenSearch query +- Filters resources before access control checks +- Reduces number of access control checks needed + +### Available Variables in CEL Expressions + +- `data` (map): Resource data object +- `resource_type` (string): Resource type +- `id` (string): Resource ID + +Note: `type` is a reserved word in CEL, so we use `resource_type` instead. + +### Example Usage + +```go +// API call +GET /query/resources?type=project&cel_filter=data.slug == "tlf" + +// Expression is evaluated against each resource after OpenSearch query +// Only matching resources proceed to access control checks +``` + +### Adding CEL Filter Tests + +When writing tests that involve resource search: + +```go +// Use MockResourceFilter for testing +mockFilter := mock.NewMockResourceFilter() + +// Pass to service constructor +service := service.NewResourceSearch(mockSearcher, mockAccessChecker, mockFilter) +``` + +### Common CEL Operations + +- Equality: `data.status == "active"` +- Comparison: `data.priority > 5` +- Boolean logic: `data.status == "active" && data.priority > 5` +- String operations: `data.name.contains("LF")` +- List membership: `data.category in ["security", "networking"]` +- Field existence: `has(data.archived)` + +### Performance Considerations + +- Compiled CEL programs are cached (100 max entries, 5-minute TTL) +- Each resource evaluation has 100ms timeout +- Post-query filtering means pagination may return fewer results than page size +- For best performance, use specific OpenSearch criteria first, then CEL for refinement + +### Important Limitations + +**Pagination**: CEL filters apply only to results from each OpenSearch page. If the target resource is not in the first page of OpenSearch results, it won't be found even if it matches the CEL filter. Always use specific primary search criteria (`type`, `name`, `parent`) to narrow OpenSearch results first. diff --git a/Makefile b/Makefile index 5edd2db..4ae91df 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,8 @@ GOLANGCI_LINT_VERSION := v2.2.2 LINT_TIMEOUT := 10m LINT_TOOL=$(shell go env GOPATH)/bin/golangci-lint +GOA_VERSION := v3.22.6 + ##@ Development .PHONY: setup-dev @@ -43,7 +45,7 @@ setup: ## Setup development environment .PHONY: deps deps: ## Install dependencies @echo "Installing dependencies..." - go install goa.design/goa/v3/cmd/goa@latest + go install goa.design/goa/v3/cmd/goa@$(GOA_VERSION) .PHONY: apigen apigen: deps #@ Generate API code using Goa diff --git a/README.md b/README.md index eb0e17f..e0564da 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ The authentication system provides JWT-based authentication with support for Hei - Bypasses JWT validation for local development **Authentication Configuration:** + - `AUTH_SOURCE`: Choose between "mock" or "jwt" (default: "jwt") - `JWKS_URL`: JSON Web Key Set endpoint URL - `JWT_AUDIENCE`: Intended audience for JWT tokens @@ -186,9 +187,9 @@ go run cmd/main.go **Authentication Configuration:** -- `AUTH_SOURCE`: Choose between "mock" or "jwt" +- `AUTH_SOURCE`: Choose between "mock" or "jwt" - `JWKS_URL`: JSON Web Key Set endpoint URL -- `JWT_AUDIENCE`: Intended audience for JWT tokens +- `JWT_AUDIENCE`: Intended audience for JWT tokens - `JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL`: Mock principal for development (required when AUTH_SOURCE=mock) **Server Configuration:** @@ -213,7 +214,9 @@ Authorization: Bearer - `name`: Resource name or alias (supports typeahead search) - `type`: Resource type to filter by - `parent`: Parent resource for hierarchical queries -- `tags`: Array of tags to filter by +- `tags`: Array of tags to filter by (OR logic) +- `tags_all`: Array of tags where all must match (AND logic) +- `cel_filter`: CEL expression for advanced post-query filtering (see [CEL Filter](#cel-filter) section) - `sort`: Sort order (name_asc, name_desc, updated_asc, updated_desc) - `page_token`: Pagination token - `v`: API version (required) @@ -238,6 +241,119 @@ Authorization: Bearer } ``` +#### CEL Filter + +The `cel_filter` query parameter enables advanced filtering of search results using Common Expression Language (CEL). CEL is a non-Turing complete expression language designed for safe, fast evaluation of expressions in performance-critical applications. + +**Why CEL Filter?** + +CEL filtering was added to provide flexible, dynamic filtering capabilities on arbitrary resource data fields without modifying the OpenSearch query structure. This allows API consumers to: + +- Filter on any field within the resource data +- Combine multiple conditions with boolean logic +- Perform complex comparisons beyond simple equality checks +- Apply filters without requiring backend code changes + +**What is CEL?** + +CEL (Common Expression Language) is an open-source expression language developed by Google. It provides: + +- **Safety**: Non-Turing complete, no side effects, no infinite loops +- **Performance**: Linear time evaluation with compilation and caching +- **Portability**: Language-agnostic with implementations in multiple languages +- **Security**: Execution timeouts and resource constraints + +Learn more: [CEL Specification](https://github.com/google/cel-spec) | [CEL-Go Documentation](https://github.com/google/cel-go) + +**How It Works** + +CEL filters are applied **after** the OpenSearch query executes but **before** access control checks. This means: + +1. OpenSearch returns initial results based on primary search criteria (`type`, `name`, `parent`, `tags`) +2. CEL filter evaluates each resource and removes non-matching items +3. Access control checks are performed only on filtered results (improved performance) +4. Final results are returned to the client + +**Available Variables** + +CEL expressions have access to the following variables for each resource: + +- `data` (map): The resource's data object containing all custom fields +- `resource_type` (string): The type of the resource (e.g., "project", "committee") +- `id` (string): The unique identifier of the resource + +**Security Constraints** + +- **Maximum expression length**: 1000 characters +- **Evaluation timeout**: 100ms per resource +- **Expression caching**: Compiled programs cached with LRU and 5-minute TTL +- **No external access**: Cannot make network calls or access filesystem + +**Usage Examples** + +Filter projects by slug: +``` +GET /query/resources?type=project&cel_filter=data.slug == "tlf"&v=1 +``` + +Filter by status and priority: +``` +GET /query/resources?type=project&cel_filter=data.status == "active" && data.priority > 5&v=1 +``` + +Filter by resource type: +``` +GET /query/resources?parent=org:123&cel_filter=resource_type == "committee"&v=1 +``` + +Complex boolean logic: +``` +GET /query/resources?type=project&cel_filter=data.status == "active" || (data.priority > 8 && data.category == "security")&v=1 +``` + +String operations: +``` +GET /query/resources?type=project&cel_filter=data.name.contains("LF") && data.description.startsWith("Open")&v=1 +``` + +Check field existence: +``` +GET /query/resources?type=project&cel_filter=has(data.archived) && data.archived == false&v=1 +``` + +List membership: +``` +GET /query/resources?type=project&cel_filter=data.category in ["security", "networking", "storage"]&v=1 +``` + +Nested field access: +``` +GET /query/resources?type=project&cel_filter=data.metadata.owner == "admin" && data.metadata.region == "us-west"&v=1 +``` + +**Supported Operators** + +- **Comparison**: `==`, `!=`, `<`, `<=`, `>`, `>=` +- **Logical**: `&&` (AND), `||` (OR), `!` (NOT) +- **Arithmetic**: `+`, `-`, `*`, `/`, `%` +- **String**: `contains()`, `startsWith()`, `endsWith()`, `matches()` (regex) +- **Membership**: `in` +- **Field check**: `has()` + +**Important Limitations** + +⚠️ **Pagination Consideration**: CEL filters are applied to the results from each OpenSearch page. If you're looking for a specific resource that matches your CEL filter but it's not in the first page of OpenSearch results, it may not be found. For best results when using CEL filters, use more specific primary search parameters (`type`, `name`, `parent`, `tags`) to narrow down the OpenSearch results first. + +**Error Handling** + +Invalid CEL expressions return a 400 Bad Request with details: + +```json +{ + "error": "filter expression failed: ERROR: :1:6: Syntax error: mismatched input 'invalid' expecting {'[', '{', '(', '.', '-', '!', 'true', 'false', 'null', NUM_FLOAT, NUM_INT, NUM_UINT, STRING, BYTES, IDENTIFIER}" +} +``` + #### Organization Search API **Query Organizations:** @@ -328,10 +444,12 @@ export ORG_SEARCH_SOURCE=clearbit The Clearbit integration supports the following search operations: **Search by Company Name:** + - Searches for companies using their registered business name - Falls back to domain-based search for additional data enrichment **Search by Domain:** + - More accurate search method using company domain names - Provides comprehensive company information @@ -384,7 +502,7 @@ This project uses the [GOA Framework](https://goa.design/) for API generation. Y #### Installing GOA Framework -Follow the [GOA installation guide](https://goa.design/docs/2-getting-started/1-installation/) to install GOA: +Follow the [GOA installation guide](https://goa.design/docs/1-goa/quickstart/) to install GOA: ```bash go install goa.design/goa/v3/cmd/goa@latest diff --git a/charts/lfx-v2-query-service/Chart.yaml b/charts/lfx-v2-query-service/Chart.yaml index cf68ef7..6b83303 100644 --- a/charts/lfx-v2-query-service/Chart.yaml +++ b/charts/lfx-v2-query-service/Chart.yaml @@ -5,5 +5,5 @@ apiVersion: v2 name: lfx-v2-query-service description: LFX Platform V2 Query Service chart type: application -version: 0.4.9 +version: 0.4.10 appVersion: "latest" diff --git a/cmd/main.go b/cmd/main.go index 5e43571..575a4a7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -59,13 +59,14 @@ func main() { accessControlChecker := service.AccessControlCheckerImpl(ctx) organizationSearcher := service.OrganizationSearcherImpl(ctx) authService := service.AuthServiceImpl(ctx) + resourceFilter := service.ResourceFilterImpl(ctx) // Initialize the services. var ( querySvcSvc querysvc.Service ) { - querySvcSvc = service.NewQuerySvc(resourceSearcher, accessControlChecker, organizationSearcher, authService) + querySvcSvc = service.NewQuerySvc(resourceSearcher, accessControlChecker, resourceFilter, organizationSearcher, authService) } // Wrap the services in endpoints that can be invoked from other services diff --git a/cmd/service/converters.go b/cmd/service/converters.go index 056b160..da2de32 100644 --- a/cmd/service/converters.go +++ b/cmd/service/converters.go @@ -23,6 +23,7 @@ func (s *querySvcsrvc) payloadToCriteria(ctx context.Context, p *querysvc.QueryR ResourceType: p.Type, Tags: p.Tags, TagsAll: p.TagsAll, + CelFilter: p.CelFilter, SortBy: p.Sort, PageToken: p.PageToken, PageSize: constants.DefaultPageSize, diff --git a/cmd/service/converters_test.go b/cmd/service/converters_test.go index 1de2cd2..c133115 100644 --- a/cmd/service/converters_test.go +++ b/cmd/service/converters_test.go @@ -20,7 +20,7 @@ func TestPayloadToCriteria(t *testing.T) { mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() mockAuth := mock.NewMockAuthService() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth) svc := service.(*querySvcsrvc) // Setup environment variable for page token secret @@ -165,7 +165,7 @@ func TestDomainResultToResponse(t *testing.T) { mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() mockAuth := mock.NewMockAuthService() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth) svc := service.(*querySvcsrvc) tests := []struct { @@ -290,7 +290,7 @@ func TestPayloadToOrganizationCriteria(t *testing.T) { mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() mockAuth := mock.NewMockAuthService() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth) svc := service.(*querySvcsrvc) tests := []struct { @@ -357,7 +357,7 @@ func TestDomainOrganizationToResponse(t *testing.T) { mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() mockAuth := mock.NewMockAuthService() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth) svc := service.(*querySvcsrvc) tests := []struct { @@ -437,7 +437,7 @@ func TestPayloadToOrganizationSuggestionCriteria(t *testing.T) { mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() mockAuth := mock.NewMockAuthService() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth) svc := service.(*querySvcsrvc) tests := []struct { @@ -493,7 +493,7 @@ func TestDomainOrganizationSuggestionsToResponse(t *testing.T) { mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() mockAuth := mock.NewMockAuthService() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth) svc := service.(*querySvcsrvc) tests := []struct { diff --git a/cmd/service/providers.go b/cmd/service/providers.go index 96ea625..f6c65f6 100644 --- a/cmd/service/providers.go +++ b/cmd/service/providers.go @@ -14,6 +14,7 @@ import ( "github.com/linuxfoundation/lfx-v2-query-service/internal/domain/port" "github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/auth" "github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/clearbit" + "github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/filter" "github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/mock" "github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/nats" "github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/opensearch" @@ -242,3 +243,15 @@ func OrganizationSearcherImpl(ctx context.Context) port.OrganizationSearcher { return organizationSearcher } + +// ResourceFilterImpl injects the resource filter implementation +func ResourceFilterImpl(ctx context.Context) port.ResourceFilter { + slog.InfoContext(ctx, "initializing CEL resource filter") + + celFilter, err := filter.NewCELFilter() + if err != nil { + log.Fatalf("failed to initialize CEL filter: %v", err) + } + + return celFilter +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 80855e7..164a030 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -154,10 +154,11 @@ func (s *querySvcsrvc) Livez(ctx context.Context) (res []byte, err error) { // NewQuerySvc returns the query-svc service implementation. func NewQuerySvc(resourceSearcher port.ResourceSearcher, accessControlChecker port.AccessControlChecker, + resourceFilter port.ResourceFilter, organizationSearcher port.OrganizationSearcher, auth port.Authenticator, ) querysvc.Service { - resourceService := service.NewResourceSearch(resourceSearcher, accessControlChecker) + resourceService := service.NewResourceSearch(resourceSearcher, accessControlChecker, resourceFilter) organizationService := service.NewOrganizationSearch(organizationSearcher) return &querySvcsrvc{ resourceService: resourceService, diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 26a1513..04cca41 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -59,7 +59,7 @@ func TestQuerySvcsrvc_JWTAuth(t *testing.T) { mockResourceSearcher := mock.NewMockResourceSearcher() mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mock.NewMockAuthService()) svc, ok := service.(*querySvcsrvc) assert.True(t, ok) @@ -148,7 +148,7 @@ func TestQuerySvcsrvc_QueryResources(t *testing.T) { mockOrgSearcher := mock.NewMockOrganizationSearcher() tc.setupMocks(mockResourceSearcher, mockAccessChecker) - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mock.NewMockAuthService()) svc, ok := service.(*querySvcsrvc) assert.True(t, ok) @@ -269,7 +269,7 @@ func TestQuerySvcsrvc_QueryResourcesCount(t *testing.T) { mockOrgSearcher := mock.NewMockOrganizationSearcher() tc.setupMocks(mockResourceSearcher, mockAccessChecker) - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mock.NewMockAuthService()) svc, ok := service.(*querySvcsrvc) assert.True(t, ok) @@ -359,7 +359,7 @@ func TestQuerySvcsrvc_QueryOrgs(t *testing.T) { mockOrgSearcher := mock.NewMockOrganizationSearcher() tc.setupMocks(mockOrgSearcher) - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mock.NewMockAuthService()) svc, ok := service.(*querySvcsrvc) assert.True(t, ok) @@ -426,7 +426,7 @@ func TestQuerySvcsrvc_SuggestOrgs(t *testing.T) { mockOrgSearcher := mock.NewMockOrganizationSearcher() tc.setupMocks(mockOrgSearcher) - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mock.NewMockAuthService()) svc, ok := service.(*querySvcsrvc) assert.True(t, ok) @@ -478,7 +478,7 @@ func TestQuerySvcsrvc_Readyz(t *testing.T) { mockOrgSearcher := mock.NewMockOrganizationSearcher() tc.setupMocks(mockResourceSearcher) - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mock.NewMockAuthService()) svc, ok := service.(*querySvcsrvc) assert.True(t, ok) @@ -520,7 +520,7 @@ func TestQuerySvcsrvc_Livez(t *testing.T) { mockResourceSearcher := mock.NewMockResourceSearcher() mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mock.NewMockAuthService()) svc, ok := service.(*querySvcsrvc) assert.True(t, ok) @@ -568,7 +568,7 @@ func TestNewQuerySvc(t *testing.T) { resourceSearcher, accessChecker, orgSearcher := tc.setupMocks() // Execute - result := NewQuerySvc(resourceSearcher, accessChecker, orgSearcher, mock.NewMockAuthService()) + result := NewQuerySvc(resourceSearcher, accessChecker, mock.NewMockResourceFilter(), orgSearcher, mock.NewMockAuthService()) // Verify if tc.expectNonNil { @@ -592,7 +592,7 @@ func TestQuerySvcsrvc_InterfaceCompliance(t *testing.T) { mockResourceSearcher := mock.NewMockResourceSearcher() mockAccessChecker := mock.NewMockAccessControlChecker() mockOrgSearcher := mock.NewMockOrganizationSearcher() - service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService()) + service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mock.NewMockAuthService()) // This will fail to compile if querySvcsrvc doesn't implement querysvc.Service var _ querysvc.Service = service diff --git a/design/query-svc.go b/design/query-svc.go index e62b447..f294fc8 100644 --- a/design/query-svc.go +++ b/design/query-svc.go @@ -56,6 +56,10 @@ var _ = dsl.Service("query-svc", func() { dsl.Attribute("tags_all", dsl.ArrayOf(dsl.String), "Tags to search with AND logic - matches resources that have all of these tags", func() { dsl.Example([]string{"governance", "security"}) }) + dsl.Attribute("cel_filter", dsl.String, "CEL expression to filter results on resource data fields. Available variables: data (map), resource_type (string), id (string)", func() { + dsl.Example(`data.slug == "tlf"`) + dsl.MaxLength(1000) + }) dsl.Required("bearer_token", "version") }) @@ -78,6 +82,7 @@ var _ = dsl.Service("query-svc", func() { dsl.Param("type") dsl.Param("tags") dsl.Param("tags_all") + dsl.Param("cel_filter") dsl.Param("sort") dsl.Param("page_token") dsl.Header("bearer_token:Authorization") diff --git a/gen/http/cli/lfx_v2_query_service/cli.go b/gen/http/cli/lfx_v2_query_service/cli.go index e07f237..dc8f85f 100644 --- a/gen/http/cli/lfx_v2_query_service/cli.go +++ b/gen/http/cli/lfx_v2_query_service/cli.go @@ -34,7 +34,7 @@ func UsageExamples() string { ]' --tags-all '[ "governance", "security" - ]' --sort "updated_desc" --page-token "****" --bearer-token "eyJhbGci..."` + "\n" + + ]' --cel-filter "data.slug == \"tlf\"" --sort "updated_desc" --page-token "****" --bearer-token "eyJhbGci..."` + "\n" + "" } @@ -57,6 +57,7 @@ func ParseEndpoint( querySvcQueryResourcesTypeFlag = querySvcQueryResourcesFlags.String("type", "", "") querySvcQueryResourcesTagsFlag = querySvcQueryResourcesFlags.String("tags", "", "") querySvcQueryResourcesTagsAllFlag = querySvcQueryResourcesFlags.String("tags-all", "", "") + querySvcQueryResourcesCelFilterFlag = querySvcQueryResourcesFlags.String("cel-filter", "", "") querySvcQueryResourcesSortFlag = querySvcQueryResourcesFlags.String("sort", "name_asc", "") querySvcQueryResourcesPageTokenFlag = querySvcQueryResourcesFlags.String("page-token", "", "") querySvcQueryResourcesBearerTokenFlag = querySvcQueryResourcesFlags.String("bearer-token", "REQUIRED", "") @@ -172,7 +173,7 @@ func ParseEndpoint( switch epn { case "query-resources": endpoint = c.QueryResources() - data, err = querysvcc.BuildQueryResourcesPayload(*querySvcQueryResourcesVersionFlag, *querySvcQueryResourcesNameFlag, *querySvcQueryResourcesParentFlag, *querySvcQueryResourcesTypeFlag, *querySvcQueryResourcesTagsFlag, *querySvcQueryResourcesTagsAllFlag, *querySvcQueryResourcesSortFlag, *querySvcQueryResourcesPageTokenFlag, *querySvcQueryResourcesBearerTokenFlag) + data, err = querysvcc.BuildQueryResourcesPayload(*querySvcQueryResourcesVersionFlag, *querySvcQueryResourcesNameFlag, *querySvcQueryResourcesParentFlag, *querySvcQueryResourcesTypeFlag, *querySvcQueryResourcesTagsFlag, *querySvcQueryResourcesTagsAllFlag, *querySvcQueryResourcesCelFilterFlag, *querySvcQueryResourcesSortFlag, *querySvcQueryResourcesPageTokenFlag, *querySvcQueryResourcesBearerTokenFlag) case "query-resources-count": endpoint = c.QueryResourcesCount() data, err = querysvcc.BuildQueryResourcesCountPayload(*querySvcQueryResourcesCountVersionFlag, *querySvcQueryResourcesCountNameFlag, *querySvcQueryResourcesCountParentFlag, *querySvcQueryResourcesCountTypeFlag, *querySvcQueryResourcesCountTagsFlag, *querySvcQueryResourcesCountTagsAllFlag, *querySvcQueryResourcesCountBearerTokenFlag) @@ -216,7 +217,7 @@ Additional help: `, os.Args[0]) } func querySvcQueryResourcesUsage() { - fmt.Fprintf(os.Stderr, `%[1]s [flags] query-svc query-resources -version STRING -name STRING -parent STRING -type STRING -tags JSON -tags-all JSON -sort STRING -page-token STRING -bearer-token STRING + fmt.Fprintf(os.Stderr, `%[1]s [flags] query-svc query-resources -version STRING -name STRING -parent STRING -type STRING -tags JSON -tags-all JSON -cel-filter STRING -sort STRING -page-token STRING -bearer-token STRING Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias. -version STRING: @@ -225,6 +226,7 @@ Locate resources by their type or parent, or use typeahead search to query resou -type STRING: -tags JSON: -tags-all JSON: + -cel-filter STRING: -sort STRING: -page-token STRING: -bearer-token STRING: @@ -236,7 +238,7 @@ Example: ]' --tags-all '[ "governance", "security" - ]' --sort "updated_desc" --page-token "****" --bearer-token "eyJhbGci..." + ]' --cel-filter "data.slug == \"tlf\"" --sort "updated_desc" --page-token "****" --bearer-token "eyJhbGci..." `, os.Args[0]) } diff --git a/gen/http/openapi.json b/gen/http/openapi.json index 79ee80b..87e0824 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 with OR logic - matches resources with any of these tags","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"tags_all","in":"query","description":"Tags to search with AND logic - matches resources that have all of these tags","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 with OR logic - matches resources with any of these tags","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"tags_all","in":"query","description":"Tags to search with AND logic - matches resources that have all of these tags","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/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 the search.","example":{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},"required":["name","domain"]},"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"},{"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 with OR logic - matches resources with any of these tags","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"tags_all","in":"query","description":"Tags to search with AND logic - matches resources that have all of these tags","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"cel_filter","in":"query","description":"CEL expression to filter results on resource data fields. Available variables: data (map), resource_type (string), id (string)","required":false,"type":"string","maxLength":1000},{"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 with OR logic - matches resources with any of these tags","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"tags_all","in":"query","description":"Tags to search with AND logic - matches resources that have all of these tags","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/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 the search.","example":{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},"required":["name","domain"]},"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"},{"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 b88adfb..af1dcfe 100644 --- a/gen/http/openapi.yaml +++ b/gen/http/openapi.yaml @@ -181,6 +181,12 @@ paths: items: type: string collectionFormat: multi + - name: cel_filter + in: query + description: 'CEL expression to filter results on resource data fields. Available variables: data (map), resource_type (string), id (string)' + required: false + type: string + maxLength: 1000 - name: sort in: query description: Sort order for results diff --git a/gen/http/openapi3.json b/gen/http/openapi3.json index a03cb0c..1a494a3 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 with OR logic - matches resources with any of these tags","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Animi aspernatur."},"description":"Tags to search with OR logic - matches resources with any of these tags","example":["active","public"]},"example":["active","public"]},{"name":"tags_all","in":"query","description":"Tags to search with AND logic - matches resources that have all of these tags","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Ex itaque."},"description":"Tags to search with AND logic - matches resources that have all of these tags","example":["governance","security"]},"example":["governance","security"]},{"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 with OR logic - matches resources with any of these tags","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Sint commodi."},"description":"Tags to search with OR logic - matches resources with any of these tags","example":["active","public"]},"example":["active","public"]},{"name":"tags_all","in":"query","description":"Tags to search with AND logic - matches resources that have all of these tags","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Labore aperiam libero ipsam et ullam."},"description":"Tags to search with AND logic - matches resources that have all of these tags","example":["governance","security"]},"example":["governance","security"]}],"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/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 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"},{"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"}]}}}},"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 with OR logic - matches resources with any of these tags","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Animi aspernatur."},"description":"Tags to search with OR logic - matches resources with any of these tags","example":["active","public"]},"example":["active","public"]},{"name":"tags_all","in":"query","description":"Tags to search with AND logic - matches resources that have all of these tags","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Ex itaque."},"description":"Tags to search with AND logic - matches resources that have all of these tags","example":["governance","security"]},"example":["governance","security"]},{"name":"cel_filter","in":"query","description":"CEL expression to filter results on resource data fields. Available variables: data (map), resource_type (string), id (string)","allowEmptyValue":true,"schema":{"type":"string","description":"CEL expression to filter results on resource data fields. Available variables: data (map), resource_type (string), id (string)","example":"data.slug == \"tlf\"","maxLength":1000},"example":"data.slug == \"tlf\""},{"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 with OR logic - matches resources with any of these tags","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Sint commodi."},"description":"Tags to search with OR logic - matches resources with any of these tags","example":["active","public"]},"example":["active","public"]},{"name":"tags_all","in":"query","description":"Tags to search with AND logic - matches resources that have all of these tags","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Labore aperiam libero ipsam et ullam."},"description":"Tags to search with AND logic - matches resources that have all of these tags","example":["governance","security"]},"example":["governance","security"]}],"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/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 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"},{"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 diff --git a/gen/http/openapi3.yaml b/gen/http/openapi3.yaml index 17467cf..61b316a 100644 --- a/gen/http/openapi3.yaml +++ b/gen/http/openapi3.yaml @@ -250,6 +250,16 @@ paths: example: - governance - security + - name: cel_filter + in: query + description: 'CEL expression to filter results on resource data fields. Available variables: data (map), resource_type (string), id (string)' + allowEmptyValue: true + schema: + type: string + description: 'CEL expression to filter results on resource data fields. Available variables: data (map), resource_type (string), id (string)' + example: data.slug == "tlf" + maxLength: 1000 + example: data.slug == "tlf" - name: sort in: query description: Sort order for results diff --git a/gen/http/query_svc/client/cli.go b/gen/http/query_svc/client/cli.go index 3ee3583..f7a7704 100644 --- a/gen/http/query_svc/client/cli.go +++ b/gen/http/query_svc/client/cli.go @@ -18,7 +18,7 @@ import ( // BuildQueryResourcesPayload builds the payload for the query-svc // query-resources endpoint from CLI flags. -func BuildQueryResourcesPayload(querySvcQueryResourcesVersion string, querySvcQueryResourcesName string, querySvcQueryResourcesParent string, querySvcQueryResourcesType string, querySvcQueryResourcesTags string, querySvcQueryResourcesTagsAll string, querySvcQueryResourcesSort string, querySvcQueryResourcesPageToken string, querySvcQueryResourcesBearerToken string) (*querysvc.QueryResourcesPayload, error) { +func BuildQueryResourcesPayload(querySvcQueryResourcesVersion string, querySvcQueryResourcesName string, querySvcQueryResourcesParent string, querySvcQueryResourcesType string, querySvcQueryResourcesTags string, querySvcQueryResourcesTagsAll string, querySvcQueryResourcesCelFilter string, querySvcQueryResourcesSort string, querySvcQueryResourcesPageToken string, querySvcQueryResourcesBearerToken string) (*querysvc.QueryResourcesPayload, error) { var err error var version string { @@ -76,6 +76,18 @@ func BuildQueryResourcesPayload(querySvcQueryResourcesVersion string, querySvcQu } } } + var celFilter *string + { + if querySvcQueryResourcesCelFilter != "" { + celFilter = &querySvcQueryResourcesCelFilter + if utf8.RuneCountInString(*celFilter) > 1000 { + err = goa.MergeErrors(err, goa.InvalidLengthError("cel_filter", *celFilter, utf8.RuneCountInString(*celFilter), 1000, false)) + } + if err != nil { + return nil, err + } + } + } var sort string { if querySvcQueryResourcesSort != "" { @@ -105,6 +117,7 @@ func BuildQueryResourcesPayload(querySvcQueryResourcesVersion string, querySvcQu v.Type = type_ v.Tags = tags v.TagsAll = tagsAll + v.CelFilter = celFilter v.Sort = sort v.PageToken = pageToken v.BearerToken = bearerToken diff --git a/gen/http/query_svc/client/encode_decode.go b/gen/http/query_svc/client/encode_decode.go index d47bc1d..9d5171f 100644 --- a/gen/http/query_svc/client/encode_decode.go +++ b/gen/http/query_svc/client/encode_decode.go @@ -67,6 +67,9 @@ func EncodeQueryResourcesRequest(encoder func(*http.Request) goahttp.Encoder) fu for _, value := range p.TagsAll { values.Add("tags_all", value) } + if p.CelFilter != nil { + values.Add("cel_filter", *p.CelFilter) + } values.Add("sort", p.Sort) if p.PageToken != nil { values.Add("page_token", *p.PageToken) diff --git a/gen/http/query_svc/server/encode_decode.go b/gen/http/query_svc/server/encode_decode.go index edf18bb..f185487 100644 --- a/gen/http/query_svc/server/encode_decode.go +++ b/gen/http/query_svc/server/encode_decode.go @@ -45,6 +45,7 @@ func DecodeQueryResourcesRequest(mux goahttp.Muxer, decoder func(*http.Request) type_ *string tags []string tagsAll []string + celFilter *string sort string pageToken *string bearerToken string @@ -80,6 +81,15 @@ func DecodeQueryResourcesRequest(mux goahttp.Muxer, decoder func(*http.Request) } tags = qp["tags"] tagsAll = qp["tags_all"] + celFilterRaw := qp.Get("cel_filter") + if celFilterRaw != "" { + celFilter = &celFilterRaw + } + if celFilter != nil { + if utf8.RuneCountInString(*celFilter) > 1000 { + err = goa.MergeErrors(err, goa.InvalidLengthError("cel_filter", *celFilter, utf8.RuneCountInString(*celFilter), 1000, false)) + } + } sortRaw := qp.Get("sort") if sortRaw != "" { sort = sortRaw @@ -100,7 +110,7 @@ func DecodeQueryResourcesRequest(mux goahttp.Muxer, decoder func(*http.Request) if err != nil { return nil, err } - payload := NewQueryResourcesPayload(version, name, parent, type_, tags, tagsAll, sort, pageToken, bearerToken) + payload := NewQueryResourcesPayload(version, name, parent, type_, tags, tagsAll, celFilter, sort, pageToken, bearerToken) if strings.Contains(payload.BearerToken, " ") { // Remove authorization scheme prefix (e.g. "Bearer") cred := strings.SplitN(payload.BearerToken, " ", 2)[1] diff --git a/gen/http/query_svc/server/types.go b/gen/http/query_svc/server/types.go index 34c4c04..78a8693 100644 --- a/gen/http/query_svc/server/types.go +++ b/gen/http/query_svc/server/types.go @@ -385,7 +385,7 @@ func NewReadyzNotReadyResponseBody(res *goa.ServiceError) *ReadyzNotReadyRespons // NewQueryResourcesPayload builds a query-svc service query-resources endpoint // payload. -func NewQueryResourcesPayload(version string, name *string, parent *string, type_ *string, tags []string, tagsAll []string, sort string, pageToken *string, bearerToken string) *querysvc.QueryResourcesPayload { +func NewQueryResourcesPayload(version string, name *string, parent *string, type_ *string, tags []string, tagsAll []string, celFilter *string, sort string, pageToken *string, bearerToken string) *querysvc.QueryResourcesPayload { v := &querysvc.QueryResourcesPayload{} v.Version = version v.Name = name @@ -393,6 +393,7 @@ func NewQueryResourcesPayload(version string, name *string, parent *string, type v.Type = type_ v.Tags = tags v.TagsAll = tagsAll + v.CelFilter = celFilter v.Sort = sort v.PageToken = pageToken v.BearerToken = bearerToken diff --git a/gen/query_svc/service.go b/gen/query_svc/service.go index b36f31d..20403e6 100644 --- a/gen/query_svc/service.go +++ b/gen/query_svc/service.go @@ -153,6 +153,9 @@ type QueryResourcesPayload struct { Tags []string // Tags to search with AND logic - matches resources that have all of these tags TagsAll []string + // CEL expression to filter results on resource data fields. Available + // variables: data (map), resource_type (string), id (string) + CelFilter *string // Sort order for results Sort string // Opaque token for pagination diff --git a/go.mod b/go.mod index 8d480e3..4f812a7 100644 --- a/go.mod +++ b/go.mod @@ -17,20 +17,25 @@ require ( ) require ( + cel.dev/expr v0.24.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/aws/smithy-go v1.22.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 // indirect github.com/go-chi/chi/v5 v5.2.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/gohugoio/hashstructure v0.5.0 // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.15.0 // indirect @@ -38,6 +43,7 @@ require ( golang.org/x/term v0.32.0 // indirect golang.org/x/text v0.26.0 // indirect golang.org/x/tools v0.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index 85c6a62..bd16c31 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,14 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/auth0/go-jwt-middleware/v2 v2.3.0 h1:4QREj6cS3d8dS05bEm443jhnqQF97FX9sMBeWqnNRzE= github.com/auth0/go-jwt-middleware/v2 v2.3.0/go.mod h1:dL4ObBs1/dj4/W4cYxd8rqAdDGXYyd5rqbpMIxcbVrU= github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 h1:MGKhKyiYrvMDZsmLR/+RGffQSXwEkXgfLSA08qDn9AI= @@ -18,6 +23,8 @@ github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp4 github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -40,6 +47,10 @@ github.com/opensearch-project/opensearch-go/v4 v4.5.0 h1:26XckmmF6MhlXt91Bu1yY6R github.com/opensearch-project/opensearch-go/v4 v4.5.0/go.mod h1:VmFc7dqOEM3ZtLhrpleOzeq+cqUgNabqQG5gX0xId64= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -72,6 +83,8 @@ goa.design/goa/v3 v3.21.1 h1:tLwhbcNoEBJm1CcJc3ks6oZ8BHYl6vFuxEBnl2kC428= goa.design/goa/v3 v3.21.1/go.mod h1:E+97AYffVIvDi6LkuNdfdvMZb8UFb/+ie3V0/WBBdgc= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= @@ -86,6 +99,9 @@ golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 h1:qEFnJI6AnfZk0NNe8YTyXQh5i//Zxi4gBHwRgp76qpw= +google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= @@ -96,5 +112,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/domain/model/search_criteria.go b/internal/domain/model/search_criteria.go index 196067f..d2bfa06 100644 --- a/internal/domain/model/search_criteria.go +++ b/internal/domain/model/search_criteria.go @@ -9,6 +9,10 @@ type SearchCriteria struct { Tags []string // TagsAll to filter resources with AND logic (all tags must match) TagsAll []string + // CelFilter is a CEL expression for post-processing filter on resource data + // Example: data.slug == "tlf" || data.status == "active" && data.priority > 5 + // Available variables: data (map), resource_type (string), id (string) + CelFilter *string // Resource name or alias; supports typeahead Name *string // Parent (for navigation; varies by object type) diff --git a/internal/domain/port/filter.go b/internal/domain/port/filter.go new file mode 100644 index 0000000..43298b6 --- /dev/null +++ b/internal/domain/port/filter.go @@ -0,0 +1,18 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package port + +import ( + "context" + + "github.com/linuxfoundation/lfx-v2-query-service/internal/domain/model" +) + +// ResourceFilter defines the interface for filtering resources based on expressions +type ResourceFilter interface { + // Filter applies an expression filter to a list of resources + // Returns only resources that match the filter expression + // If expression is empty or nil, returns all resources unfiltered + Filter(ctx context.Context, resources []model.Resource, expression string) ([]model.Resource, error) +} diff --git a/internal/infrastructure/filter/cel_filter.go b/internal/infrastructure/filter/cel_filter.go new file mode 100644 index 0000000..9848668 --- /dev/null +++ b/internal/infrastructure/filter/cel_filter.go @@ -0,0 +1,253 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package filter + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/google/cel-go/cel" + "github.com/linuxfoundation/lfx-v2-query-service/internal/domain/model" +) + +const ( + // MaxExpressionLength is the maximum allowed length for a CEL expression + MaxExpressionLength = 1000 + + // EvaluationTimeout is the maximum time allowed to evaluate a single resource + EvaluationTimeout = 100 * time.Millisecond + + // MaxCacheSize is the maximum number of compiled programs to cache + MaxCacheSize = 100 + + // CacheTTL is the time-to-live for cached programs + CacheTTL = 5 * time.Minute +) + +// CELFilter implements ResourceFilter using Common Expression Language +type CELFilter struct { + env *cel.Env + programCache *programCache +} + +// programCache stores compiled CEL programs with TTL +type programCache struct { + mu sync.RWMutex + cache map[string]*cacheEntry + maxSize int +} + +type cacheEntry struct { + program cel.Program + expiresAt time.Time +} + +// isExpired checks if the cache entry has expired +func (ce *cacheEntry) isExpired() bool { + return time.Now().After(ce.expiresAt) +} + +// NewCELFilter creates a new CEL-based resource filter +func NewCELFilter() (*CELFilter, error) { + // Create CEL environment with safe variable exposure + env, err := cel.NewEnv( + cel.Variable("data", cel.MapType(cel.StringType, cel.DynType)), + cel.Variable("resource_type", cel.StringType), + cel.Variable("id", cel.StringType), + ) + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment: %w", err) + } + + return &CELFilter{ + env: env, + programCache: &programCache{ + cache: make(map[string]*cacheEntry), + maxSize: MaxCacheSize, + }, + }, nil +} + +// Filter applies a CEL expression filter to resources +func (f *CELFilter) Filter(ctx context.Context, resources []model.Resource, expression string) ([]model.Resource, error) { + // If no expression provided, return all resources + if expression == "" { + return resources, nil + } + + // Validate expression length + if len(expression) > MaxExpressionLength { + return nil, fmt.Errorf("filter expression exceeds maximum length of %d characters", MaxExpressionLength) + } + + // Get or compile program + prg, err := f.getOrCompileProgram(expression) + if err != nil { + return nil, fmt.Errorf("invalid filter expression: %w", err) + } + + // Filter resources + filtered := make([]model.Resource, 0, len(resources)) + for _, resource := range resources { + // Check context cancellation + if ctx.Err() != nil { + return nil, ctx.Err() + } + + // Evaluate with timeout + match, err := f.evaluateResource(ctx, prg, resource) + if err != nil { + // Log evaluation error but continue (lenient mode) + slog.WarnContext(ctx, "failed to evaluate resource against filter", + "resource_id", resource.ID, + "resource_type", resource.Type, + "error", err, + ) + continue + } + + if match { + filtered = append(filtered, resource) + } + } + + slog.DebugContext(ctx, "CEL filter applied", + "expression", expression, + "input_count", len(resources), + "output_count", len(filtered), + ) + + return filtered, nil +} + +// evaluateResource evaluates a single resource against the CEL program +func (f *CELFilter) evaluateResource(ctx context.Context, prg cel.Program, resource model.Resource) (bool, error) { + // Create timeout context + evalCtx, cancel := context.WithTimeout(ctx, EvaluationTimeout) + defer cancel() + + // Prepare evaluation variables + vars := map[string]any{ + "data": resource.Data, + "resource_type": resource.Type, + "id": resource.ID, + } + + // Evaluate expression + result, _, err := prg.ContextEval(evalCtx, vars) + if err != nil { + return false, fmt.Errorf("evaluation error: %w", err) + } + + // Check if result is boolean + boolResult, ok := result.Value().(bool) + if !ok { + return false, fmt.Errorf("expression must return boolean, got %T", result.Value()) + } + + return boolResult, nil +} + +// getOrCompileProgram retrieves a cached program or compiles a new one +// Uses double-checked locking to prevent race conditions +func (f *CELFilter) getOrCompileProgram(expression string) (cel.Program, error) { + // First check: try to get from cache without write lock (fast path) + if prg := f.programCache.get(expression); prg != nil { + return prg, nil + } + + // Acquire write lock for compilation + f.programCache.mu.Lock() + defer f.programCache.mu.Unlock() + + // Second check: another goroutine might have compiled it while we waited for the lock + if entry, exists := f.programCache.cache[expression]; exists && !entry.isExpired() { + return entry.program, nil + } + + // Compile new program (only one goroutine reaches here per expression) + ast, issues := f.env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("compilation error: %w", issues.Err()) + } + + // Check output type - must be boolean + if !ast.OutputType().IsExactType(cel.BoolType) { + return nil, fmt.Errorf("expression must return boolean, got %s", ast.OutputType()) + } + + // Create program + prg, err := f.env.Program(ast) + if err != nil { + return nil, fmt.Errorf("program creation error: %w", err) + } + + // Cache the program (already holding write lock) + f.programCache.putLocked(expression, prg) + + return prg, nil +} + +// get retrieves a program from cache if not expired +// Removes expired entries immediately when detected +func (pc *programCache) get(expression string) cel.Program { + // First attempt: fast path with read lock + pc.mu.RLock() + entry, exists := pc.cache[expression] + if !exists { + pc.mu.RUnlock() + return nil + } + + // Check if expired (still under read lock) + if entry.isExpired() { + pc.mu.RUnlock() + + // Upgrade to write lock to delete expired entry + pc.mu.Lock() + // Double-check it still exists and is still expired + if entry, exists := pc.cache[expression]; exists && entry.isExpired() { + delete(pc.cache, expression) + } + pc.mu.Unlock() + + return nil + } + + program := entry.program + pc.mu.RUnlock() + return program +} + +// putLocked adds a program to the cache with TTL (must be called with lock held) +func (pc *programCache) putLocked(expression string, program cel.Program) { + // Clean up expired entries if cache is full + if len(pc.cache) >= pc.maxSize { + pc.cleanupExpiredLocked() + } + + // If still full after cleanup, remove oldest entry + if len(pc.cache) >= pc.maxSize { + // Simple eviction: just skip caching this program + return + } + + pc.cache[expression] = &cacheEntry{ + program: program, + expiresAt: time.Now().Add(CacheTTL), + } +} + +// cleanupExpiredLocked removes expired entries (must be called with lock held) +func (pc *programCache) cleanupExpiredLocked() { + now := time.Now() + for key, entry := range pc.cache { + if now.After(entry.expiresAt) { + delete(pc.cache, key) + } + } +} diff --git a/internal/infrastructure/filter/cel_filter_test.go b/internal/infrastructure/filter/cel_filter_test.go new file mode 100644 index 0000000..879c284 --- /dev/null +++ b/internal/infrastructure/filter/cel_filter_test.go @@ -0,0 +1,428 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package filter + +import ( + "context" + "testing" + "time" + + "github.com/linuxfoundation/lfx-v2-query-service/internal/domain/model" + "github.com/stretchr/testify/assert" +) + +func TestNewCELFilter(t *testing.T) { + assertion := assert.New(t) + + filter, err := NewCELFilter() + + assertion.NoError(err) + assertion.NotNil(filter) + assertion.NotNil(filter.env) + assertion.NotNil(filter.programCache) +} + +func TestCELFilter_Filter_EmptyExpression(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Project 1"}}, + {ID: "2", Type: "project", Data: map[string]any{"name": "Project 2"}}, + } + + result, err := filter.Filter(context.Background(), resources, "") + + assertion.NoError(err) + assertion.Equal(2, len(result)) + assertion.Equal(resources, result) +} + +func TestCELFilter_Filter_SimpleEquality(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"slug": "tlf", "status": "active"}}, + {ID: "2", Type: "project", Data: map[string]any{"slug": "linux", "status": "active"}}, + {ID: "3", Type: "project", Data: map[string]any{"slug": "tlf", "status": "inactive"}}, + } + + result, err := filter.Filter(context.Background(), resources, `data.slug == "tlf"`) + + assertion.NoError(err) + assertion.Equal(2, len(result)) + assertion.Equal("1", result[0].ID) + assertion.Equal("3", result[1].ID) +} + +func TestCELFilter_Filter_MultipleConditions(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"status": "active", "priority": 10}}, + {ID: "2", Type: "project", Data: map[string]any{"status": "active", "priority": 3}}, + {ID: "3", Type: "project", Data: map[string]any{"status": "inactive", "priority": 10}}, + } + + result, err := filter.Filter(context.Background(), resources, `data.status == "active" && data.priority > 5`) + + assertion.NoError(err) + assertion.Equal(1, len(result)) + assertion.Equal("1", result[0].ID) +} + +func TestCELFilter_Filter_TypeVariable(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Project"}}, + {ID: "2", Type: "committee", Data: map[string]any{"name": "Committee"}}, + {ID: "3", Type: "project", Data: map[string]any{"name": "Another Project"}}, + } + + result, err := filter.Filter(context.Background(), resources, `resource_type == "project"`) + + assertion.NoError(err) + assertion.Equal(2, len(result)) + assertion.Equal("1", result[0].ID) + assertion.Equal("3", result[1].ID) +} + +func TestCELFilter_Filter_IDVariable(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "123", Type: "project", Data: map[string]any{"name": "Project"}}, + {ID: "456", Type: "project", Data: map[string]any{"name": "Another"}}, + } + + result, err := filter.Filter(context.Background(), resources, `id == "123"`) + + assertion.NoError(err) + assertion.Equal(1, len(result)) + assertion.Equal("123", result[0].ID) +} + +func TestCELFilter_Filter_StringOperations(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Linux Foundation"}}, + {ID: "2", Type: "project", Data: map[string]any{"name": "Apache Foundation"}}, + {ID: "3", Type: "project", Data: map[string]any{"name": "Linux Kernel"}}, + } + + result, err := filter.Filter(context.Background(), resources, `data.name.startsWith("Linux")`) + + assertion.NoError(err) + assertion.Equal(2, len(result)) + assertion.Equal("1", result[0].ID) + assertion.Equal("3", result[1].ID) +} + +func TestCELFilter_Filter_NumericComparisons(t *testing.T) { + tests := []struct { + name string + expression string + expectedID string + }{ + { + name: "greater than", + expression: `data.count > 50`, + expectedID: "1", + }, + { + name: "less than", + expression: `data.count < 30`, + expectedID: "2", + }, + { + name: "greater than or equal", + expression: `data.count >= 50`, + expectedID: "1", + }, + { + name: "less than or equal", + expression: `data.count <= 25`, + expectedID: "2", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"count": 100}}, + {ID: "2", Type: "project", Data: map[string]any{"count": 25}}, + } + + result, err := filter.Filter(context.Background(), resources, tc.expression) + + assertion.NoError(err) + assertion.Equal(1, len(result)) + assertion.Equal(tc.expectedID, result[0].ID) + }) + } +} + +func TestCELFilter_Filter_NestedFields(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{ + "settings": map[string]any{"notifications": map[string]any{"enabled": true}}, + }}, + {ID: "2", Type: "project", Data: map[string]any{ + "settings": map[string]any{"notifications": map[string]any{"enabled": false}}, + }}, + } + + result, err := filter.Filter(context.Background(), resources, `data.settings.notifications.enabled == true`) + + assertion.NoError(err) + assertion.Equal(1, len(result)) + assertion.Equal("1", result[0].ID) +} + +func TestCELFilter_Filter_InvalidExpression(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Project"}}, + } + + result, err := filter.Filter(context.Background(), resources, `invalid syntax (((`) + + assertion.Error(err) + assertion.Nil(result) + assertion.Contains(err.Error(), "invalid filter expression") +} + +func TestCELFilter_Filter_NonBooleanExpression(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Project"}}, + } + + // Expression returns string, not boolean + result, err := filter.Filter(context.Background(), resources, `data.name`) + + assertion.Error(err) + assertion.Nil(result) + assertion.Contains(err.Error(), "must return boolean") +} + +func TestCELFilter_Filter_ExpressionTooLong(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Project"}}, + } + + // Create expression longer than MaxExpressionLength + longExpression := "" + for i := 0; i < MaxExpressionLength+100; i++ { + longExpression += "x" + } + + result, err := filter.Filter(context.Background(), resources, longExpression) + + assertion.Error(err) + assertion.Nil(result) + assertion.Contains(err.Error(), "exceeds maximum length") +} + +func TestCELFilter_Filter_MissingField(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Project"}}, + {ID: "2", Type: "project", Data: map[string]any{"slug": "project-2"}}, + } + + // Lenient mode: skip resources that error during evaluation + result, err := filter.Filter(context.Background(), resources, `data.slug == "project-2"`) + + assertion.NoError(err) + assertion.Equal(1, len(result)) + assertion.Equal("2", result[0].ID) +} + +func TestCELFilter_Filter_ContextCancellation(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Project"}}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + result, err := filter.Filter(ctx, resources, `data.name == "Project"`) + + assertion.Error(err) + assertion.Nil(result) +} + +func TestCELFilter_ProgramCaching(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + resources := []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Project"}}, + } + + expression := `data.name == "Project"` + + // First call - should compile + result1, err1 := filter.Filter(context.Background(), resources, expression) + assertion.NoError(err1) + assertion.Equal(1, len(result1)) + + // Second call - should use cached program + result2, err2 := filter.Filter(context.Background(), resources, expression) + assertion.NoError(err2) + assertion.Equal(1, len(result2)) + + // Verify cache contains the expression + assertion.NotNil(filter.programCache.get(expression)) +} + +func TestCELFilter_CacheExpiration(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + expression := `data.name == "Project"` + + // Compile and cache + _, err := filter.getOrCompileProgram(expression) + assertion.NoError(err) + + // Verify it's in cache + filter.programCache.mu.RLock() + cacheSize := len(filter.programCache.cache) + filter.programCache.mu.RUnlock() + assertion.Equal(1, cacheSize, "expected 1 cached entry") + + // Manually expire the cache entry + filter.programCache.mu.Lock() + entry := filter.programCache.cache[expression] + entry.expiresAt = time.Now().Add(-1 * time.Hour) + filter.programCache.mu.Unlock() + + // Should return nil for expired entry + assertion.Nil(filter.programCache.get(expression)) + + // Verify expired entry was removed from cache + filter.programCache.mu.RLock() + cacheSize = len(filter.programCache.cache) + filter.programCache.mu.RUnlock() + assertion.Equal(0, cacheSize, "expected expired entry to be removed from cache") +} + +func TestCELFilter_ComplexExpressions(t *testing.T) { + tests := []struct { + name string + expression string + resources []model.Resource + expectedCount int + expectedIDs []string + }{ + { + name: "OR condition", + expression: `data.status == "active" || data.status == "pending"`, + resources: []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"status": "active"}}, + {ID: "2", Type: "project", Data: map[string]any{"status": "pending"}}, + {ID: "3", Type: "project", Data: map[string]any{"status": "inactive"}}, + }, + expectedCount: 2, + expectedIDs: []string{"1", "2"}, + }, + { + name: "IN operator", + expression: `data.status in ["active", "pending"]`, + resources: []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"status": "active"}}, + {ID: "2", Type: "project", Data: map[string]any{"status": "inactive"}}, + }, + expectedCount: 1, + expectedIDs: []string{"1"}, + }, + { + name: "Combined string and numeric", + expression: `data.name.contains("Linux") && data.members > 10`, + resources: []model.Resource{ + {ID: "1", Type: "project", Data: map[string]any{"name": "Linux Foundation", "members": 20}}, + {ID: "2", Type: "project", Data: map[string]any{"name": "Linux Kernel", "members": 5}}, + {ID: "3", Type: "project", Data: map[string]any{"name": "Apache", "members": 15}}, + }, + expectedCount: 1, + expectedIDs: []string{"1"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertion := assert.New(t) + filter, _ := NewCELFilter() + + result, err := filter.Filter(context.Background(), tc.resources, tc.expression) + + assertion.NoError(err) + assertion.Equal(tc.expectedCount, len(result)) + + for i, expectedID := range tc.expectedIDs { + assertion.Equal(expectedID, result[i].ID) + } + }) + } +} + +func TestProgramCache_CleanupExpired(t *testing.T) { + assertion := assert.New(t) + + cache := &programCache{ + cache: make(map[string]*cacheEntry), + maxSize: 10, + } + + // Add entries with different expiration times + filter, _ := NewCELFilter() + prg1, _ := filter.env.Program(nil) // Dummy program + + cache.cache["expired1"] = &cacheEntry{ + program: prg1, + expiresAt: time.Now().Add(-1 * time.Hour), + } + cache.cache["expired2"] = &cacheEntry{ + program: prg1, + expiresAt: time.Now().Add(-30 * time.Minute), + } + cache.cache["valid"] = &cacheEntry{ + program: prg1, + expiresAt: time.Now().Add(5 * time.Minute), + } + + cache.mu.Lock() + cache.cleanupExpiredLocked() + cache.mu.Unlock() + + assertion.Equal(1, len(cache.cache)) + assertion.NotNil(cache.cache["valid"]) +} diff --git a/internal/infrastructure/mock/resource_filter.go b/internal/infrastructure/mock/resource_filter.go new file mode 100644 index 0000000..9c9721b --- /dev/null +++ b/internal/infrastructure/mock/resource_filter.go @@ -0,0 +1,24 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package mock + +import ( + "context" + + "github.com/linuxfoundation/lfx-v2-query-service/internal/domain/model" +) + +// MockResourceFilter is a mock implementation of ResourceFilter for testing +type MockResourceFilter struct{} + +// NewMockResourceFilter creates a new MockResourceFilter +func NewMockResourceFilter() *MockResourceFilter { + return &MockResourceFilter{} +} + +// Filter returns all resources unfiltered (no-op filter for testing) +func (m *MockResourceFilter) Filter(ctx context.Context, resources []model.Resource, expression string) ([]model.Resource, error) { + // Mock filter just returns all resources unchanged + return resources, nil +} diff --git a/internal/infrastructure/opensearch/searcher.go b/internal/infrastructure/opensearch/searcher.go index da94c14..310fe07 100644 --- a/internal/infrastructure/opensearch/searcher.go +++ b/internal/infrastructure/opensearch/searcher.go @@ -250,8 +250,8 @@ func NewSearcher(ctx context.Context, config Config) (port.ResourceSearcher, err Addresses: []string{config.URL}, Transport: &http.Transport{ MaxIdleConnsPerHost: 10, - ResponseHeaderTimeout: time.Second, - DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, + ResponseHeaderTimeout: 30 * time.Second, + DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext, }, }, }) diff --git a/internal/service/resource_search.go b/internal/service/resource_search.go index e26d445..fcce312 100644 --- a/internal/service/resource_search.go +++ b/internal/service/resource_search.go @@ -34,6 +34,7 @@ type ResourceSearcher interface { type ResourceSearch struct { resourceSearcher port.ResourceSearcher accessChecker port.AccessControlChecker + resourceFilter port.ResourceFilter } // QueryResources performs resource search with business logic validation @@ -80,6 +81,29 @@ func (s *ResourceSearch) QueryResources(ctx context.Context, criteria model.Sear return nil, fmt.Errorf("search operation failed: %w", err) } + // Apply CEL filter if provided (before access control to reduce the number of resources to check). + // Note: applying this filter before access control and pagination can change the effective result set + // seen by the caller, which may cause pagination tokens (based on the unfiltered set) to skip or miss + // resources when the CEL expression significantly reduces the results. + if criteria.CelFilter != nil && *criteria.CelFilter != "" { + slog.DebugContext(ctx, "applying CEL filter", + "expression", *criteria.CelFilter, + "resource_count_before", len(result.Resources), + ) + filteredResources, errFilter := s.resourceFilter.Filter(ctx, result.Resources, *criteria.CelFilter) + if errFilter != nil { + slog.ErrorContext(ctx, "CEL filter failed", + "error", errFilter, + "expression", *criteria.CelFilter, + ) + return nil, fmt.Errorf("filter expression failed: %w", errFilter) + } + result.Resources = filteredResources + slog.DebugContext(ctx, "CEL filter applied", + "resource_count_after", len(result.Resources), + ) + } + slog.DebugContext(ctx, "checking access control for resources", "resource_count", len(result.Resources), ) @@ -350,9 +374,10 @@ func (s *ResourceSearch) IsReady(ctx context.Context) error { } // NewResourceSearch creates a new ResourceSearch instance -func NewResourceSearch(resourceSearcher port.ResourceSearcher, accessChecker port.AccessControlChecker) ResourceSearcher { +func NewResourceSearch(resourceSearcher port.ResourceSearcher, accessChecker port.AccessControlChecker, resourceFilter port.ResourceFilter) ResourceSearcher { return &ResourceSearch{ resourceSearcher: resourceSearcher, accessChecker: accessChecker, + resourceFilter: resourceFilter, } } diff --git a/internal/service/resource_search_test.go b/internal/service/resource_search_test.go index 7ddb8f6..810b384 100644 --- a/internal/service/resource_search_test.go +++ b/internal/service/resource_search_test.go @@ -151,7 +151,7 @@ func TestResourceSearchQueryResources(t *testing.T) { tc.setupMocks(mockSearcher, mockAccessChecker) // Create service - service, ok := NewResourceSearch(mockSearcher, mockAccessChecker).(*ResourceSearch) + service, ok := NewResourceSearch(mockSearcher, mockAccessChecker, mock.NewMockResourceFilter()).(*ResourceSearch) if !ok { t.Fatal("failed to create ResourceSearch service") } @@ -655,7 +655,7 @@ func TestNewResourceSearch(t *testing.T) { searcher, accessChecker := tc.setupMocks() // Execute - result := NewResourceSearch(searcher, accessChecker) + result := NewResourceSearch(searcher, accessChecker, mock.NewMockResourceFilter()) // Verify if tc.expectNonNil { @@ -681,7 +681,7 @@ func TestResourceSearchQueryResourcesEdgeCases(t *testing.T) { // Setup mockSearcher := mock.NewMockResourceSearcher() mockAccessChecker := mock.NewMockAccessControlChecker() - service, ok := NewResourceSearch(mockSearcher, mockAccessChecker).(*ResourceSearch) + service, ok := NewResourceSearch(mockSearcher, mockAccessChecker, mock.NewMockResourceFilter()).(*ResourceSearch) if !ok { t.Fatal("failed to create ResourceSearch service") } @@ -727,7 +727,7 @@ func TestResourceSearchQueryResourcesEdgeCases(t *testing.T) { // Setup mockSearcher := mock.NewMockResourceSearcher() mockAccessChecker := mock.NewMockAccessControlChecker() - service, ok := NewResourceSearch(mockSearcher, mockAccessChecker).(*ResourceSearch) + service, ok := NewResourceSearch(mockSearcher, mockAccessChecker, mock.NewMockResourceFilter()).(*ResourceSearch) if !ok { t.Fatal("failed to create ResourceSearch service") } @@ -894,7 +894,7 @@ func TestResourceCountQueryResourcesCount(t *testing.T) { tc.setupMocks(resourceSearcher, accessChecker) // Create service - service := NewResourceSearch(resourceSearcher, accessChecker) + service := NewResourceSearch(resourceSearcher, accessChecker, mock.NewMockResourceFilter()) // Create context with principal ctx := context.WithValue(context.Background(), constants.PrincipalContextID, tc.principal)