diff --git a/.changeset/green-pears-jog.md b/.changeset/green-pears-jog.md new file mode 100644 index 0000000000..112bff3947 --- /dev/null +++ b/.changeset/green-pears-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +$contains may have string value in an entity filter. Typescript type and Zod parser were not the same. diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 3f2ed575b4..22ac105eeb 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -549,7 +549,7 @@ export type EntityPredicateValue = $in: EntityPredicatePrimitive[]; } | { - $contains: EntityPredicateExpression; + $contains: EntityPredicate; }; // @alpha (undocumented) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 3c9a020c93..af04673ad1 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -60,20 +60,75 @@ describe('EntityCardBlueprint', () => { { "anyOf": [ { - "type": [ - "string", - "number", - "boolean", - ], + "additionalProperties": { + "anyOf": [ + { + "type": [ + "string", + "number", + "boolean", + ], + }, + { + "additionalProperties": false, + "properties": { + "$exists": { + "type": "boolean", + }, + }, + "required": [ + "$exists", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$in": { + "items": { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0", + }, + "type": "array", + }, + }, + "required": [ + "$in", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$contains": { + "$ref": "#/properties/filter/anyOf/1", + }, + }, + "required": [ + "$contains", + ], + "type": "object", + }, + ], + }, + "propertyNames": { + "pattern": "^(?!\\$).*$", + }, + "type": "object", }, { - "items": { - "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", + "additionalProperties": { + "not": {}, }, - "type": "array", + "propertyNames": { + "pattern": "^\\$", + }, + "type": "object", }, ], }, + { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0", + }, { "additionalProperties": false, "properties": { @@ -116,58 +171,6 @@ describe('EntityCardBlueprint', () => { ], "type": "object", }, - { - "additionalProperties": { - "anyOf": [ - { - "$ref": "#/properties/filter/anyOf/1/anyOf/0", - }, - { - "additionalProperties": false, - "properties": { - "$exists": { - "type": "boolean", - }, - }, - "required": [ - "$exists", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$in": { - "items": { - "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", - }, - "type": "array", - }, - }, - "required": [ - "$in", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$contains": { - "$ref": "#/properties/filter/anyOf/1", - }, - }, - "required": [ - "$contains", - ], - "type": "object", - }, - ], - }, - "propertyNames": { - "pattern": "^(?!\\$).*$", - }, - "type": "object", - }, ], }, ], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index 729f15079f..e80834be92 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -62,20 +62,75 @@ describe('EntityContentBlueprint', () => { { "anyOf": [ { - "type": [ - "string", - "number", - "boolean", - ], + "additionalProperties": { + "anyOf": [ + { + "type": [ + "string", + "number", + "boolean", + ], + }, + { + "additionalProperties": false, + "properties": { + "$exists": { + "type": "boolean", + }, + }, + "required": [ + "$exists", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$in": { + "items": { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0", + }, + "type": "array", + }, + }, + "required": [ + "$in", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$contains": { + "$ref": "#/properties/filter/anyOf/1", + }, + }, + "required": [ + "$contains", + ], + "type": "object", + }, + ], + }, + "propertyNames": { + "pattern": "^(?!\\$).*$", + }, + "type": "object", }, { - "items": { - "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", + "additionalProperties": { + "not": {}, }, - "type": "array", + "propertyNames": { + "pattern": "^\\$", + }, + "type": "object", }, ], }, + { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0", + }, { "additionalProperties": false, "properties": { @@ -118,58 +173,6 @@ describe('EntityContentBlueprint', () => { ], "type": "object", }, - { - "additionalProperties": { - "anyOf": [ - { - "$ref": "#/properties/filter/anyOf/1/anyOf/0", - }, - { - "additionalProperties": false, - "properties": { - "$exists": { - "type": "boolean", - }, - }, - "required": [ - "$exists", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$in": { - "items": { - "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", - }, - "type": "array", - }, - }, - "required": [ - "$in", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$contains": { - "$ref": "#/properties/filter/anyOf/1", - }, - }, - "required": [ - "$contains", - ], - "type": "object", - }, - ], - }, - "propertyNames": { - "pattern": "^(?!\\$).*$", - }, - "type": "object", - }, ], }, ], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 55598f5de1..d92fcc1863 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -73,20 +73,75 @@ describe('EntityContextMenuItemBlueprint', () => { { "anyOf": [ { - "type": [ - "string", - "number", - "boolean", - ], + "additionalProperties": { + "anyOf": [ + { + "type": [ + "string", + "number", + "boolean", + ], + }, + { + "additionalProperties": false, + "properties": { + "$exists": { + "type": "boolean", + }, + }, + "required": [ + "$exists", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$in": { + "items": { + "$ref": "#/properties/filter/anyOf/0/anyOf/0/additionalProperties/anyOf/0", + }, + "type": "array", + }, + }, + "required": [ + "$in", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$contains": { + "$ref": "#/properties/filter", + }, + }, + "required": [ + "$contains", + ], + "type": "object", + }, + ], + }, + "propertyNames": { + "pattern": "^(?!\\$).*$", + }, + "type": "object", }, { - "items": { - "$ref": "#/properties/filter/anyOf/0/anyOf/0", + "additionalProperties": { + "not": {}, }, - "type": "array", + "propertyNames": { + "pattern": "^\\$", + }, + "type": "object", }, ], }, + { + "$ref": "#/properties/filter/anyOf/0/anyOf/0/additionalProperties/anyOf/0", + }, { "additionalProperties": false, "properties": { @@ -129,58 +184,6 @@ describe('EntityContextMenuItemBlueprint', () => { ], "type": "object", }, - { - "additionalProperties": { - "anyOf": [ - { - "$ref": "#/properties/filter/anyOf/0", - }, - { - "additionalProperties": false, - "properties": { - "$exists": { - "type": "boolean", - }, - }, - "required": [ - "$exists", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$in": { - "items": { - "$ref": "#/properties/filter/anyOf/0/anyOf/0", - }, - "type": "array", - }, - }, - "required": [ - "$in", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$contains": { - "$ref": "#/properties/filter", - }, - }, - "required": [ - "$contains", - ], - "type": "object", - }, - ], - }, - "propertyNames": { - "pattern": "^(?!\\$).*$", - }, - "type": "object", - }, ], }, }, diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts index 4fdf921c1a..3553fa932b 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts @@ -16,84 +16,100 @@ import { z } from 'zod'; import { createEntityPredicateSchema } from './createEntityPredicateSchema'; +import { EntityPredicate } from './types'; describe('createEntityPredicateSchema', () => { const schema = createEntityPredicateSchema(z); - it.each([ - 'string', - '', - [], - 1, - { kind: 'component', 'spec.type': 'service' }, - { 'metadata.tags': { $in: ['java'] } }, - { - $all: [ - { 'metadata.tags': { $contains: 'java' } }, - { 'metadata.tags': { $contains: 'spring' } }, - ], - }, - { 'metadata.tags': ['java', 'spring'] }, - { 'metadata.tags': { $in: ['go'] } }, - { 'metadata.tags.0': 'java' }, - { $not: { 'metadata.tags': { $in: ['java'] } } }, - { - $any: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], - }, - { - relations: { - $contains: { type: 'ownedBy', targetRef: 'group:default/g' }, + describe('valid predicates', () => { + const predicates: EntityPredicate[] = [ + 'string', + '', + 1, + { kind: 'component', 'spec.type': 'service' }, + { 'metadata.tags': { $in: ['java'] } }, + { 'metadata.tags': { $contains: 'java' } }, + { + $all: [ + { 'metadata.tags': { $contains: 'java' } }, + { 'metadata.tags': { $contains: 'spring' } }, + ], }, - }, - { - metadata: { $contains: { name: 'a' } }, - }, - { kind: 'component', 'spec.type': { $in: ['service', 'website'] } }, - { - $any: [ - { - $all: [ - { - kind: 'component', - 'spec.type': { $in: ['service', 'website'] }, - }, - ], + { 'metadata.tags': { $in: ['go'] } }, + { 'metadata.tags.0': 'java' }, + { $not: { 'metadata.tags': { $in: ['java'] } } }, + { + $any: [ + { kind: 'component', 'spec.type': 'service' }, + { kind: 'group' }, + ], + }, + { + relations: { + $contains: { type: 'ownedBy', targetRef: 'group:default/g' }, }, - { $all: [{ kind: 'api', 'spec.type': 'grpc' }] }, - ], - }, - { kind: 'component', 'spec.type': { $in: ['service'] } }, - { 'spec.owner': { $exists: true } }, - { 'spec.owner': { $exists: false } }, - { 'spec.type': 'service' }, - { $not: { 'spec.type': 'service' } }, - { - kind: 'component', - 'metadata.annotations.github.com/repo': { $exists: true }, - }, - { $all: [{ x: { $exists: true } }] }, - { $any: [{ x: { $exists: true } }] }, - { $not: { x: { $exists: true } } }, - { $not: { $all: [{ x: { $exists: true } }] } }, - ])('should accept valid predicate %j', predicate => { - expect(schema.parse(predicate)).toEqual(predicate); + }, + { + metadata: { $contains: { name: 'a' } }, + }, + { kind: 'component', 'spec.type': { $in: ['service', 'website'] } }, + { + $any: [ + { + $all: [ + { + kind: 'component', + 'spec.type': { $in: ['service', 'website'] }, + }, + ], + }, + { $all: [{ kind: 'api', 'spec.type': 'grpc' }] }, + ], + }, + { kind: 'component', 'spec.type': { $in: ['service'] } }, + { 'spec.owner': { $exists: true } }, + { 'spec.owner': { $exists: false } }, + { 'spec.type': 'service' }, + { $not: { 'spec.type': 'service' } }, + { + kind: 'component', + 'metadata.annotations.github.com/repo': { $exists: true }, + }, + { $all: [{ x: { $exists: true } }] }, + { $any: [{ x: { $exists: true } }] }, + { $not: { x: { $exists: true } } }, + { $not: { $all: [{ x: { $exists: true } }] } }, + ]; + + it.each(predicates)('should accept valid predicate %j', predicate => { + expect(schema.parse(predicate)).toEqual(predicate); + }); }); - it.each([ - { kind: { 1: 'foo' } }, - { kind: { foo: 'bar' } }, - { kind: { $unknown: 'foo' } }, - { kind: { $in: 'foo' } }, - { kind: { $in: [{ x: 'foo' }] } }, - { kind: { $in: [{ x: 'foo' }] } }, - { 'spec.type': null }, - { $all: [{ x: { $unknown: true } }] }, - { $any: [{ x: { $unknown: true } }] }, - { $not: { x: { $unknown: true } } }, - { $not: { $all: [{ x: { $unknown: true } }] } }, - { $unknown: 'foo' }, - ])('should reject invalid predicate %j', predicate => { - const result = schema.safeParse(predicate); - expect(result.success).toBe(false); + describe('invalid predicates', () => { + const predicates: Array< + Exclude + > = [ + [], + ['foo', 'bar'], + { kind: { 1: 'foo' } }, + { kind: { foo: 'bar' } }, + { kind: { $unknown: 'foo' } }, + { kind: { $in: 'foo' } }, + { kind: { $in: [{ x: 'foo' }] } }, + { kind: { $in: [{ x: 'foo' }] } }, + { 'spec.type': null }, + { $all: [{ x: { $unknown: true } }] }, + { $any: [{ x: { $unknown: true } }] }, + { $not: { x: { $unknown: true } } }, + { $not: { $all: [{ x: { $unknown: true } }] } }, + { $unknown: 'foo' }, + { 'metadata.tags': ['foo', 'bar'] }, + ]; + + it.each(predicates)('should reject invalid predicate %j', predicate => { + const result = schema.safeParse(predicate); + expect(result.success).toBe(false); + }); }); }); diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts index cfd2e451df..3e31ec4d35 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts @@ -14,33 +14,44 @@ * limitations under the License. */ -import { EntityPredicate, EntityPredicateValue } from './types'; +import { + EntityPredicate, + EntityPredicateExpression, + EntityPredicatePrimitive, + EntityPredicateValue, +} from './types'; import type { z as zImpl, ZodType } from 'zod'; /** @internal */ export function createEntityPredicateSchema(z: typeof zImpl) { - const primitiveSchema = z.union([z.string(), z.number(), z.boolean()]); - - const comparableValueSchema = z.union([ - primitiveSchema, - z.array(primitiveSchema), - ]); + const primitiveSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + ]) as ZodType; // eslint-disable-next-line prefer-const let valuePredicateSchema: ZodType; + const expressionSchema = z.lazy(() => + z.union([ + z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema), + z.record(z.string().regex(/^\$/), z.never()), + ]), + ) as ZodType; + const predicateSchema = z.lazy(() => z.union([ - comparableValueSchema, + expressionSchema, + primitiveSchema, z.object({ $all: z.array(predicateSchema) }), z.object({ $any: z.array(predicateSchema) }), z.object({ $not: predicateSchema }), - z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema), ]), ) as ZodType; valuePredicateSchema = z.union([ - comparableValueSchema, + primitiveSchema, z.object({ $exists: z.boolean() }), z.object({ $in: z.array(primitiveSchema) }), z.object({ $contains: predicateSchema }), diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/plugins/catalog-react/src/alpha/predicates/types.ts index a4a2e9199f..f19026c01b 100644 --- a/plugins/catalog-react/src/alpha/predicates/types.ts +++ b/plugins/catalog-react/src/alpha/predicates/types.ts @@ -34,7 +34,7 @@ export type EntityPredicateValue = | EntityPredicatePrimitive | { $exists: boolean } | { $in: EntityPredicatePrimitive[] } - | { $contains: EntityPredicateExpression }; + | { $contains: EntityPredicate }; /** @alpha */ export type EntityPredicatePrimitive = string | number | boolean;