diff --git a/.changeset/facets-predicate-backend.md b/.changeset/facets-predicate-backend.md new file mode 100644 index 0000000000..9ecdef11b6 --- /dev/null +++ b/.changeset/facets-predicate-backend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added support for predicate-based filtering on the `/entity-facets` endpoint via a new `POST` method. Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/.changeset/facets-predicate-client.md b/.changeset/facets-predicate-client.md new file mode 100644 index 0000000000..5493d7c82a --- /dev/null +++ b/.changeset/facets-predicate-client.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': minor +--- + +Added support for the `query` field in `getEntityFacets` requests, enabling predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 0d71e6bc59..8bfebe6ab1 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -280,6 +280,7 @@ export interface GetEntityAncestorsResponse { export interface GetEntityFacetsRequest { facets: string[]; filter?: EntityFilterQuery; + query?: FilterPredicate; } // @public diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index d1196bd8ba..0d173022a3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -479,7 +479,13 @@ export class CatalogClient implements CatalogApi { request: GetEntityFacetsRequest, options?: CatalogRequestOptions, ): Promise { - const { filter = [], facets } = request; + const { filter = [], query, facets } = request; + + // Route to POST endpoint if query predicate is provided + if (query) { + return this.getEntityFacetsByPredicate(request, options); + } + return await this.requestOptional( await this.apiClient.getEntityFacets( { @@ -490,6 +496,45 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Get entity facets using predicate-based filters (POST endpoint). + * @internal + */ + private async getEntityFacetsByPredicate( + request: GetEntityFacetsRequest, + options?: CatalogRequestOptions, + ): Promise { + const { filter, query, facets } = request; + + let filterPredicate: FilterPredicate | undefined; + if (query !== undefined) { + if (typeof query !== 'object' || query === null || Array.isArray(query)) { + throw new InputError('Query must be an object'); + } + filterPredicate = query; + } + if (filter !== undefined) { + const converted = convertFilterToPredicate(filter); + filterPredicate = filterPredicate + ? { $all: [filterPredicate, converted] } + : converted; + } + + return await this.requestOptional( + await this.apiClient.queryEntityFacetsByPredicate( + { + body: { + facets, + ...(filterPredicate && { + query: filterPredicate as unknown as { [key: string]: any }, + }), + }, + }, + options, + ), + ); + } + /** * {@inheritdoc CatalogApi.addLocation} */ diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index 086f3695d4..899e310e94 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -30,6 +30,7 @@ import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; +import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model'; @@ -146,6 +147,12 @@ export type GetEntityFacets = { export type QueryEntitiesByPredicate = { body: QueryEntitiesByPredicateRequest; }; +/** + * @public + */ +export type QueryEntityFacetsByPredicate = { + body: QueryEntityFacetsByPredicateRequest; +}; /** * @public */ @@ -481,6 +488,31 @@ export class DefaultApiClient { }); } + /** + * Get entity facets using predicate-based filters. + * @param queryEntityFacetsByPredicateRequest - + */ + public async queryEntityFacetsByPredicate( + // @ts-ignore + request: QueryEntityFacetsByPredicate, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/entity-facets`; + + const uri = parser.parse(uriTemplate).expand({}); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify(request.body), + }); + } + /** * Refresh the entity related to entityRef. * @param refreshEntityRequest - diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts new file mode 100644 index 0000000000..14fd3a8bc0 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntityFacetsByPredicateRequest { + facets: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/index.ts b/packages/catalog-client/src/schema/openapi/generated/models/index.ts index 9461ac8d28..ebecb8dbb7 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -48,6 +48,7 @@ export * from '../models/NullableEntity.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; +export * from '../models/QueryEntityFacetsByPredicateRequest.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index afd26076c9..13a7822df9 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -297,6 +297,16 @@ export interface GetEntityFacetsRequest { * of that key, no matter what its value is. */ filter?: EntityFilterQuery; + /** + * If given, return only entities that match the given predicate query. + * + * @remarks + * + * Supports operators like `$all`, `$any`, `$not`, `$exists`, `$in`, + * `$contains`, and `$hasPrefix`. When both `filter` and `query` are + * provided, they are combined with `$all`. + */ + query?: FilterPredicate; /** * Dot separated paths for the facets to extract from each entity. * diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 5d733ad14c..d5c4b2b675 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -120,6 +120,10 @@ export interface EntityFacetsRequest { * A filter to apply on the full list of entities before computing the facets. */ filter?: EntityFilter; + /** + * Predicate-based query for filtering entities. + */ + query?: FilterPredicate; /** * The facets to compute. * diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 0531a9a1d3..58d7fed4ac 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1194,6 +1194,40 @@ paths: value: - spec.type - $ref: '#/components/parameters/filter' + post: + operationId: QueryEntityFacetsByPredicate + tags: + - Entity + description: Get entity facets using predicate-based filters. + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/EntityFacetsResponse' + '400': + $ref: '#/components/responses/ErrorResponse' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - facets + properties: + facets: + type: array + items: + type: string + query: + $ref: '#/components/schemas/JsonObject' /locations: post: operationId: CreateLocation diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index dfb111359c..3362f903d0 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -27,6 +27,7 @@ import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; +import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; @@ -128,6 +129,13 @@ export type QueryEntitiesByPredicate = { body: QueryEntitiesByPredicateRequest; response: EntitiesQueryResponse | Error | Error; }; +/** + * @public + */ +export type QueryEntityFacetsByPredicate = { + body: QueryEntityFacetsByPredicateRequest; + response: EntityFacetsResponse | Error | Error; +}; /** * @public */ @@ -230,6 +238,8 @@ export type EndpointMap = { '#post|/entities/by-query': QueryEntitiesByPredicate; + '#post|/entity-facets': QueryEntityFacetsByPredicate; + '#post|/refresh': RefreshEntity; '#post|/validate-entity': ValidateEntity; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts new file mode 100644 index 0000000000..14fd3a8bc0 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntityFacetsByPredicateRequest { + facets: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts index 9461ac8d28..ebecb8dbb7 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -48,6 +48,7 @@ export * from '../models/NullableEntity.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; +export * from '../models/QueryEntityFacetsByPredicateRequest.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index 6c739eb2ee..b423905955 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1373,6 +1373,57 @@ export const spec = { }, ], }, + post: { + operationId: 'QueryEntityFacetsByPredicate', + tags: ['Entity'], + description: 'Get entity facets using predicate-based filters.', + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/EntityFacetsResponse', + }, + }, + }, + }, + '400': { + $ref: '#/components/responses/ErrorResponse', + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['facets'], + properties: { + facets: { + type: 'array', + items: { + type: 'string', + }, + }, + query: { + $ref: '#/components/schemas/JsonObject', + }, + }, + }, + }, + }, + }, + }, }, '/locations': { post: { diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index dab605d176..a9d706582a 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -686,9 +686,10 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }) .groupBy(['search.key', 'search.original_value']); - if (request.filter) { + if (request.filter || request.query) { applyEntityFilterToQuery({ filter: request.filter, + query: request.query, targetQuery: query, onEntityIdField: 'search.entity_id', knex: this.database, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 842a08c2d3..ccdd034cae 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -1190,6 +1190,89 @@ describe('createRouter readonly disabled', () => { ); }); }); + + describe('GET /entity-facets', () => { + it('returns facets', async () => { + entitiesCatalog.facets.mockResolvedValue({ + facets: { kind: [{ value: 'Component', count: 5 }] }, + }); + + const response = await request(app).get('/entity-facets?facet=kind'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + facets: { kind: [{ value: 'Component', count: 5 }] }, + }); + }); + + it('returns facets with filter parameter', async () => { + entitiesCatalog.facets.mockResolvedValue({ + facets: { 'spec.type': [{ value: 'service', count: 3 }] }, + }); + + const response = await request(app).get( + '/entity-facets?facet=spec.type&filter=kind=Component', + ); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + facets: { 'spec.type': [{ value: 'service', count: 3 }] }, + }); + expect(entitiesCatalog.facets).toHaveBeenCalledWith( + expect.objectContaining({ + facets: ['spec.type'], + filter: { key: 'kind', values: ['Component'] }, + }), + ); + }); + }); + + describe('POST /entity-facets', () => { + it('returns facets with predicate query', async () => { + entitiesCatalog.facets.mockResolvedValue({ + facets: { 'spec.type': [{ value: 'service', count: 3 }] }, + }); + + const response = await request(app) + .post('/entity-facets') + .send({ + facets: ['spec.type'], + query: { kind: 'Component' }, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + facets: { 'spec.type': [{ value: 'service', count: 3 }] }, + }); + expect(entitiesCatalog.facets).toHaveBeenCalledWith( + expect.objectContaining({ + facets: ['spec.type'], + query: { kind: 'Component' }, + }), + ); + }); + + it('returns facets without query predicate', async () => { + entitiesCatalog.facets.mockResolvedValue({ + facets: { kind: [{ value: 'Component', count: 5 }] }, + }); + + const response = await request(app) + .post('/entity-facets') + .send({ facets: ['kind'] }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + facets: { kind: [{ value: 'Component', count: 5 }] }, + }); + }); + + it('returns 400 for missing facets', async () => { + const response = await request(app) + .post('/entity-facets') + .send({ query: { kind: 'Component' } }); + + expect(response.status).toBe(400); + }); + }); }); describe('createRouter readonly and raw json enabled', () => { diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 48bf7961b8..90c76cc4d4 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -47,6 +47,7 @@ import { parseQueryEntitiesParams, } from './request'; import { parseEntityFacetParams } from './request/parseEntityFacetParams'; +import { parseEntityFacetsQuery } from './request/parseEntityFacetsQuery'; import { parseEntityOrderParams } from './request/parseEntityOrderParams'; import { parseEntityPaginationParams } from './request/parseEntityPaginationParams'; import { @@ -564,6 +565,31 @@ export async function createRouter( await auditorEvent?.success(); + res.status(200).json(response); + } catch (err) { + await auditorEvent?.fail({ + error: err, + }); + throw err; + } + }) + .post('/entity-facets', async (req, res) => { + const auditorEvent = await auditor.createEvent({ + eventId: 'entity-facets', + request: req, + }); + + try { + const { facets, query } = parseEntityFacetsQuery(req.body ?? {}); + + const response = await entitiesCatalog.facets({ + query, + facets, + credentials: await httpAuth.credentials(req), + }); + + await auditorEvent?.success(); + res.status(200).json(response); } catch (err) { await auditorEvent?.fail({ diff --git a/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts new file mode 100644 index 0000000000..473466f950 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityFacetsQuery } from './parseEntityFacetsQuery'; + +describe('parseEntityFacetsQuery', () => { + it('parses facets with no query', () => { + expect(parseEntityFacetsQuery({ facets: ['kind'] })).toEqual({ + facets: ['kind'], + query: undefined, + }); + }); + + it('parses facets with a simple query', () => { + expect( + parseEntityFacetsQuery({ + facets: ['spec.type'], + query: { kind: 'Component' }, + }), + ).toEqual({ + facets: ['spec.type'], + query: { kind: 'Component' }, + }); + }); + + it('parses facets with complex predicate query', () => { + const query = { + $all: [{ kind: 'Component' }, { 'spec.lifecycle': 'production' }], + }; + expect(parseEntityFacetsQuery({ facets: ['spec.type'], query })).toEqual({ + facets: ['spec.type'], + query, + }); + }); + + it('throws on missing facets', () => { + expect(() => parseEntityFacetsQuery({} as any)).toThrow( + 'Missing or empty facets parameter', + ); + }); + + it('throws on empty facets array', () => { + expect(() => parseEntityFacetsQuery({ facets: [] })).toThrow( + 'Missing or empty facets parameter', + ); + }); + + it('throws on invalid query (null)', () => { + expect(() => + parseEntityFacetsQuery({ facets: ['kind'], query: null as any }), + ).toThrow(); + }); + + it('throws on invalid query (array)', () => { + expect(() => + parseEntityFacetsQuery({ facets: ['kind'], query: [] as any }), + ).toThrow(); + }); + + it('throws on invalid query (invalid operator)', () => { + expect(() => + parseEntityFacetsQuery({ + facets: ['kind'], + query: { $invalid: 'bad' } as any, + }), + ).toThrow(); + }); +}); diff --git a/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts new file mode 100644 index 0000000000..04e5e2ab82 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { + createZodV3FilterPredicateSchema, + FilterPredicate, +} from '@backstage/filter-predicates'; +import { z } from 'zod/v3'; +import { fromZodError } from 'zod-validation-error/v3'; +import { QueryEntityFacetsByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model'; + +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + +export interface ParsedEntityFacetsQuery { + facets: string[]; + query?: FilterPredicate; +} + +export function parseEntityFacetsQuery( + request: Readonly, +): ParsedEntityFacetsQuery { + // Parse facets + if (!request.facets || request.facets.length === 0) { + throw new InputError('Missing or empty facets parameter'); + } + const facets = request.facets.filter(f => f.length > 0); + if (facets.length === 0) { + throw new InputError('Missing or empty facets parameter'); + } + + // Parse query predicate + let query: FilterPredicate | undefined; + if (request.query !== undefined) { + const result = filterPredicateSchema.safeParse(request.query); + if (!result.success) { + throw new InputError(`Invalid query: ${fromZodError(result.error)}`); + } + query = result.data; + } + + return { facets, query }; +}