diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index e7713623b9..489093f65d 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -32,7 +32,8 @@ export function isQueryEntitiesInitialRequest( */ export function cursorContainsQuery(cursor: string): boolean { try { - const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')); + // Use browser-compatible base64 decoding + const decoded = JSON.parse(atob(cursor)); return !!decoded.query; } catch { return false; diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index aad176e390..04bfb93d4f 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -16,7 +16,8 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityFilter, EntityPredicate } from '@backstage/plugin-catalog-node'; +import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * A pagination rule for entities. @@ -53,7 +54,7 @@ export type EntitiesRequest = { }; export type EntityPredicateRequest = { - query?: EntityPredicate; + query?: FilterPredicate; order?: EntityOrder[]; pagination?: EntityPagination; credentials: BackstageCredentials; @@ -232,7 +233,7 @@ export interface QueryEntitiesInitialRequest { * Predicate-based query for filtering entities. * Mutually exclusive with filter. */ - query?: EntityPredicate; + query?: FilterPredicate; orderFields?: EntityOrder[]; fullTextFilter?: { term: string; @@ -298,7 +299,7 @@ export type Cursor = { * A predicate-based query to be applied to the full list of entities. * Mutually exclusive with filter. */ - query?: EntityPredicate; + query?: FilterPredicate; /** * true if the cursor is a previous cursor. */ diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6af2fc96b0..c3608c19a6 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -30,10 +30,8 @@ import { } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, serializeError } from '@backstage/errors'; -import { - EntityPredicate, - LocationAnalyzer, -} from '@backstage/plugin-catalog-node'; +import { parseFilterPredicate } from '@backstage/filter-predicates'; +import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; import express from 'express'; import yn from 'yn'; import { z } from 'zod'; @@ -272,7 +270,10 @@ export async function createRouter( }); try { - const query = req.body.query as EntityPredicate | undefined; + // Validate the query using the Zod schema from @backstage/filter-predicates + const query = req.body.query + ? parseFilterPredicate(req.body.query) + : undefined; const order = parseEntityOrderFieldParams(req.query); const pagination = parseEntityPaginationParams(req.query); const credentials = await httpAuth.credentials(req); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 59e08e7d9e..eff7c12f7e 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -15,32 +15,33 @@ */ import { - EntityPredicate, - EntityPredicatePrimitive, - EntityPredicateValue, -} from '@backstage/plugin-catalog-node'; + FilterPredicate, + FilterPredicatePrimitive, + FilterPredicateValue, +} from '@backstage/filter-predicates'; +import { InputError } from '@backstage/errors'; import { Knex } from 'knex'; import { DbSearchRow } from '../../database/tables'; function isAllPredicate( - filter: EntityPredicate, -): filter is { $all: EntityPredicate[] } { + filter: FilterPredicate, +): filter is { $all: FilterPredicate[] } { return typeof filter === 'object' && filter !== null && '$all' in filter; } function isAnyPredicate( - filter: EntityPredicate, -): filter is { $any: EntityPredicate[] } { + filter: FilterPredicate, +): filter is { $any: FilterPredicate[] } { return typeof filter === 'object' && filter !== null && '$any' in filter; } function isNotPredicate( - filter: EntityPredicate, -): filter is { $not: EntityPredicate } { + filter: FilterPredicate, +): filter is { $not: FilterPredicate } { return typeof filter === 'object' && filter !== null && '$not' in filter; } -function isPrimitive(value: unknown): value is EntityPredicatePrimitive { +function isPrimitive(value: unknown): value is FilterPredicatePrimitive { return ( typeof value === 'string' || typeof value === 'number' || @@ -49,18 +50,18 @@ function isPrimitive(value: unknown): value is EntityPredicatePrimitive { } function isExistsValue( - value: EntityPredicateValue, + value: FilterPredicateValue, ): value is { $exists: boolean } { return typeof value === 'object' && value !== null && '$exists' in value; } function isInValue( - value: EntityPredicateValue, -): value is { $in: EntityPredicatePrimitive[] } { + value: FilterPredicateValue, +): value is { $in: FilterPredicatePrimitive[] } { return typeof value === 'object' && value !== null && '$in' in value; } -function isFieldExpression(filter: EntityPredicate): boolean { +function isFieldExpression(filter: FilterPredicate): boolean { if (typeof filter !== 'object' || filter === null) { return false; } @@ -109,7 +110,7 @@ function isFieldExpression(filter: EntityPredicate): boolean { */ function applyPredicateInStrategy( - filter: EntityPredicate, + filter: FilterPredicate, targetQuery: Knex.QueryBuilder, onEntityIdField: string, knex: Knex, @@ -211,6 +212,13 @@ function applyPredicateInStrategy( value: String(value).toLowerCase(), }); this.andWhere(onEntityIdField, 'in', matchQuery); + } else { + // Reject unsupported/invalid predicate values + throw new InputError( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, or $in operator, but got ${JSON.stringify( + value, + )}`, + ); } } }, @@ -221,7 +229,7 @@ function applyPredicateInStrategy( } export function applyPredicateEntityFilterToQuery(options: { - filter: EntityPredicate; + filter: FilterPredicate; targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index dbdbd374cf..708eec2de3 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -15,6 +15,7 @@ */ import { InputError, NotAllowedError } from '@backstage/errors'; +import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates'; import { Request } from 'express'; import lodash from 'lodash'; import { z } from 'zod'; @@ -109,6 +110,8 @@ const entityFilterParser: z.ZodSchema = z.lazy(() => .or(z.object({ allOf: z.array(entityFilterParser) })), ); +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + export const cursorParser: z.ZodSchema = z.object({ orderFields: z.array( z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), @@ -122,7 +125,7 @@ export const cursorParser: z.ZodSchema = z.object({ orderFieldValues: z.array(z.string().or(z.null())), filter: entityFilterParser.optional(), isPrevious: z.boolean(), - query: z.any().optional(), + query: filterPredicateSchema.optional(), firstSortFieldValues: z.array(z.string().or(z.null())).optional(), totalItems: z.number().optional(), }); diff --git a/plugins/catalog-node/src/api/common.ts b/plugins/catalog-node/src/api/common.ts index f88857a312..7f9bd3eabf 100644 --- a/plugins/catalog-node/src/api/common.ts +++ b/plugins/catalog-node/src/api/common.ts @@ -85,69 +85,3 @@ export type EntitiesSearchFilter = { */ values?: string[]; }; - -/** - * Defines a predicate used to filter entities using logical and comparison operators. - * - * This type supports: - * - Primitive equality matching - * - Field existence checks - * - Inclusion checks - * - Logical composition using $all, $any, and $not - * - * @public - */ -export type EntityPredicate = - | EntityPredicateExpression - | EntityPredicatePrimitive - | { $all: EntityPredicate[] } - | { $any: EntityPredicate[] } - | { $not: EntityPredicate }; - -/** - * Represents a field-based predicate expression. - * - * Each key is treated as a JSON path into the entity object. - * Keys starting with `$` are reserved for operators and are not allowed. - * - * Example: - * ```ts - * { - * "spec.type": "service", - * "metadata.name": { $in: ["payments", "orders"] } - * } - * ``` - * - * @public - */ -export type EntityPredicateExpression = { - /** JSON path of the entity field */ - [KPath in string]: EntityPredicateValue; -} & { - /** Operator keys are not allowed as field paths */ - [KPath in `$${string}`]: never; -}; - -/** - * Represents a value condition for a predicate field. - * - * Supported forms: - * - Primitive equality match - * - Existence check using `$exists` - * - Inclusion check using `$in` - * - * @public - */ -export type EntityPredicateValue = - | EntityPredicatePrimitive - | { $exists: boolean } - | { $in: EntityPredicatePrimitive[] }; - -/** - * Represents a primitive predicate value. - * - * Used for direct equality comparison. - * - * @public - */ -export type EntityPredicatePrimitive = string | number | boolean; diff --git a/plugins/catalog-node/src/api/index.ts b/plugins/catalog-node/src/api/index.ts index 5b7abbb6b9..292a6cd872 100644 --- a/plugins/catalog-node/src/api/index.ts +++ b/plugins/catalog-node/src/api/index.ts @@ -20,10 +20,6 @@ export type { LocationSpec, EntitiesSearchFilter, EntityFilter, - EntityPredicate, - EntityPredicateExpression, - EntityPredicateValue, - EntityPredicatePrimitive, } from './common'; export type { CatalogProcessor,