diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b7749b5419e26..33727c6da1d87 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -540,6 +540,7 @@ src/platform/packages/shared/kbn-cleanup-before-exit @elastic/observability-ui src/platform/packages/shared/kbn-coloring @elastic/kibana-visualizations src/platform/packages/shared/kbn-config @elastic/kibana-core src/platform/packages/shared/kbn-config-schema @elastic/kibana-core +src/platform/packages/shared/kbn-config-schema-helpers @elastic/kibana-core src/platform/packages/shared/kbn-connector-cli @elastic/workchat-eng src/platform/packages/shared/kbn-connector-schemas @elastic/response-ops src/platform/packages/shared/kbn-connector-specs @elastic/response-ops diff --git a/package.json b/package.json index 2682e7b73c462..4cd5d9a54fd65 100644 --- a/package.json +++ b/package.json @@ -311,6 +311,7 @@ "@kbn/config": "link:src/platform/packages/shared/kbn-config", "@kbn/config-mocks": "link:src/platform/packages/private/kbn-config-mocks", "@kbn/config-schema": "link:src/platform/packages/shared/kbn-config-schema", + "@kbn/config-schema-helpers": "link:src/platform/packages/shared/kbn-config-schema-helpers", "@kbn/connector-schemas": "link:src/platform/packages/shared/kbn-connector-schemas", "@kbn/connector-specs": "link:src/platform/packages/shared/kbn-connector-specs", "@kbn/console-plugin": "link:src/platform/plugins/shared/console", diff --git a/packages/kbn-check-saved-objects-cli/moon.yml b/packages/kbn-check-saved-objects-cli/moon.yml index c3d49c89fb398..083595c399d50 100644 --- a/packages/kbn-check-saved-objects-cli/moon.yml +++ b/packages/kbn-check-saved-objects-cli/moon.yml @@ -28,6 +28,7 @@ dependsOn: - '@kbn/core-saved-objects-api-server' - '@kbn/migrator-test-kit' - '@kbn/config-schema' + - '@kbn/zod' - '@kbn/repo-info' - '@kbn/encrypted-saved-objects-plugin' - '@kbn/object-utils' diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/fixtures/create_fixture_template.test.ts b/packages/kbn-check-saved-objects-cli/src/migrations/fixtures/create_fixture_template.test.ts index a219d2efab446..bd1ca8ce12af6 100644 --- a/packages/kbn-check-saved-objects-cli/src/migrations/fixtures/create_fixture_template.test.ts +++ b/packages/kbn-check-saved-objects-cli/src/migrations/fixtures/create_fixture_template.test.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { z } from '@kbn/zod'; import { createFixtureTemplate } from './create_fixture_template'; function mkModelVersion(props: Array<{ path: string[]; type: any }>) { @@ -20,6 +21,21 @@ function mkModelVersion(props: Array<{ path: string[]; type: any }>) { } describe('createFixtureTemplate', () => { + it('builds from a zod create schema via getZodSchemaStructure', () => { + const modelVersion = { + schemas: { + create: z.object({ + title: z.string(), + meta: z.object({ author: z.string().optional() }), + }), + }, + }; + expect(createFixtureTemplate(modelVersion as any)).toEqual({ + title: 'string', + meta: { author: 'string?' }, + }); + }); + it('creates fixture for a single root property', () => { const props = [{ path: ['title'], type: 'text' }]; const modelVersion = mkModelVersion(props); diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/fixtures/create_fixture_template.ts b/packages/kbn-check-saved-objects-cli/src/migrations/fixtures/create_fixture_template.ts index 2818c36d3bdf1..d7903ee78c31d 100644 --- a/packages/kbn-check-saved-objects-cli/src/migrations/fixtures/create_fixture_template.ts +++ b/packages/kbn-check-saved-objects-cli/src/migrations/fixtures/create_fixture_template.ts @@ -7,11 +7,28 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { getZodSchemaStructure, isZod } from '@kbn/zod'; import type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server'; import type { FixtureTemplate, ModelVersionSchemaProperty } from './types'; +function hasGetSchemaStructure( + value: unknown +): value is { getSchemaStructure: () => ModelVersionSchemaProperty[] } { + if (typeof value !== 'object' || value === null) { + return false; + } + return typeof Reflect.get(value, 'getSchemaStructure') === 'function'; +} + export function createFixtureTemplate(modelVersion: SavedObjectsModelVersion): FixtureTemplate { - const props = modelVersion.schemas!.create!.getSchemaStructure() as ModelVersionSchemaProperty[]; + let props: ModelVersionSchemaProperty[] = []; + const createSchema = modelVersion.schemas!.create; + + if (hasGetSchemaStructure(createSchema)) { + props = createSchema.getSchemaStructure(); + } else if (isZod(createSchema)) { + props = getZodSchemaStructure(createSchema); + } // Sort props by path length to ensure parent paths are created before nested ones props.sort((a, b) => a.path.length - b.path.length); diff --git a/packages/kbn-check-saved-objects-cli/src/snapshots/validate_changes/common_utils.test.ts b/packages/kbn-check-saved-objects-cli/src/snapshots/validate_changes/common_utils.test.ts index 9bf8367f5cf65..00dedb8df676a 100644 --- a/packages/kbn-check-saved-objects-cli/src/snapshots/validate_changes/common_utils.test.ts +++ b/packages/kbn-check-saved-objects-cli/src/snapshots/validate_changes/common_utils.test.ts @@ -7,85 +7,265 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { schema } from '@kbn/config-schema'; +import { z } from '@kbn/zod'; +import type { ObjectType } from '@kbn/config-schema'; +import type { SavedObjectsType } from '@kbn/core-saved-objects-server'; import type { MigrationInfoRecord, ModelVersionSummary } from '../../types'; import { isSavedObjectsCheckError } from '../../findings'; import { + extractMappingCompatibleSchemaFields, + extractMappingCompatibleZodSchemaFields, getMappingFieldPaths, + validateAllMappingsInModelVersion, validateNoIndexOrEnabledFalse, validateNoIndexOrEnabledFalseInAllMappings, } from './common_utils'; -describe('validateNoIndexOrEnabledFalse', () => { - const modelVersionWithFlattenedNewMappings: ModelVersionSummary = { - version: '2', - modelVersionHash: 'hash', - changeTypes: ['mappings_addition'], - hasTransformation: false, - newMappings: ['category.type', 'category.index', 'category.doc_values'], - schemas: { create: false, forwardCompatibility: false }, - }; - - const toWithIndexFalse: MigrationInfoRecord = { - name: 'my-type', - hash: 'hash', - migrationVersions: [], - schemaVersions: [], - modelVersions: [modelVersionWithFlattenedNewMappings], - mappings: { - 'properties.category.type': 'keyword', - 'properties.category.index': false, - 'properties.category.doc_values': false, - }, - }; - - it('reports each violating field once when newMappings lists multiple flattened paths', () => { - try { - validateNoIndexOrEnabledFalse('my-type', toWithIndexFalse, [ - modelVersionWithFlattenedNewMappings, +function normalizePaths(paths: string[]): string[] { + return [...new Set(paths)].sort(); +} + +/** Compare Joi internals from a config-schema `ObjectType` to an equivalent Zod object schema. */ +function expectMappingPathsMatchConfigSchemaAndZod( + configObjectSchema: ObjectType, + zodObjectSchema: z.ZodType +): void { + const joiPaths = normalizePaths( + extractMappingCompatibleSchemaFields(configObjectSchema.getSchema()) + ); + const zodPaths = normalizePaths(extractMappingCompatibleZodSchemaFields(zodObjectSchema)); + expect(zodPaths).toEqual(joiPaths); +} + +describe('common_utils', () => { + describe('extractMappingCompatibleSchemaFields (Joi) vs extractMappingCompatibleZodSchemaFields (Zod)', () => { + it('flat object keys', () => { + expectMappingPathsMatchConfigSchemaAndZod( + schema.object({ + title: schema.string(), + count: schema.number(), + }), + z.object({ + title: z.string(), + count: z.number(), + }) + ); + }); + + it('nested objects', () => { + expectMappingPathsMatchConfigSchemaAndZod( + schema.object({ + meta: schema.object({ + author: schema.string(), + id: schema.number(), + }), + }), + z.object({ + meta: z.object({ + author: z.string(), + id: z.number(), + }), + }) + ); + }); + + it('optional leaf fields', () => { + expectMappingPathsMatchConfigSchemaAndZod( + schema.object({ + subtitle: schema.maybe(schema.string()), + }), + z.object({ + subtitle: z.string().optional(), + }) + ); + }); + + it('arrays of objects (paths from element object)', () => { + expectMappingPathsMatchConfigSchemaAndZod( + schema.object({ + rows: schema.arrayOf( + schema.object({ + id: schema.string(), + }) + ), + }), + z.object({ + rows: z.array( + z.object({ + id: z.string(), + }) + ), + }) + ); + }); + + it('union alternatives at a field (string | number)', () => { + expectMappingPathsMatchConfigSchemaAndZod( + schema.object({ + value: schema.oneOf([schema.string(), schema.number()]), + }), + z.object({ + value: z.union([z.string(), z.number()]), + }) + ); + }); + }); + + describe('extractMappingCompatibleSchemaFields (Joi)', () => { + it('returns dotted paths for nested properties', () => { + const joiSchema = schema + .object({ + kibanaSavedObjectMeta: schema.object({ + searchSourceJSON: schema.maybe(schema.string()), + }), + }) + .getSchema(); + expect(normalizePaths(extractMappingCompatibleSchemaFields(joiSchema))).toEqual([ + 'kibanaSavedObjectMeta.searchSourceJSON', + ]); + }); + }); + + describe('extractMappingCompatibleZodSchemaFields (Zod)', () => { + it('returns dotted paths for nested properties', () => { + const zodSchema = z.object({ + kibanaSavedObjectMeta: z.object({ + searchSourceJSON: z.string().optional(), + }), + }); + expect(normalizePaths(extractMappingCompatibleZodSchemaFields(zodSchema))).toEqual([ + 'kibanaSavedObjectMeta.searchSourceJSON', ]); - fail('expected SavedObjectsCheckError'); - } catch (err) { - expect(isSavedObjectsCheckError(err)).toBe(true); - if (!isSavedObjectsCheckError(err)) { - return; + }); + + it('lazy object resolves to field paths', () => { + interface Row { + id: string; } - expect(err.findings).toHaveLength(1); - expect(err.findings[0].message).toBe( - "The SO type 'my-type' has new mapping fields with 'index: false': category." + const rowSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + }) ); - } + expect( + normalizePaths(extractMappingCompatibleZodSchemaFields(z.object({ row: rowSchema }))) + ).toEqual(['row.id']); + }); }); -}); -describe('empty-object field handling (preserved `properties: {}` leaves)', () => { - // Snapshots now preserve empty object fields as an explicit `properties..properties: {}` - // flattened leaf. These tests lock in that the flattened-mapping consumers tolerate that key. - const mappingsWithEmptyObjectField: Record = { - dynamic: false, - 'properties.title.type': 'text', - 'properties.pending_upgrade_review.dynamic': false, - 'properties.pending_upgrade_review.properties': {}, - }; - - it('does not derive a spurious field path from an empty-object `properties` leaf', () => { - // The empty-object leaf collapses to the same parent path as its sibling `dynamic` key, - // so the field set is exactly { title, pending_upgrade_review } with no `…properties` entry. - expect(getMappingFieldPaths(mappingsWithEmptyObjectField)).toEqual([ - 'pending_upgrade_review', - 'title', - ]); + describe('validateAllMappingsInModelVersion', () => { + it('throws when latest model version `schemas.create` is not Zod and has no Joi `getSchema()`', () => { + const typeName = 'unsupported-create-schema'; + const to: MigrationInfoRecord = { + name: typeName, + hash: 'hash', + migrationVersions: [], + schemaVersions: [], + modelVersions: [ + { + version: '1', + modelVersionHash: 'h', + changeTypes: [], + hasTransformation: false, + newMappings: [], + schemas: { create: 'h', forwardCompatibility: 'h' }, + }, + ], + mappings: {}, + }; + + const registeredType = { + name: typeName, + modelVersions: { + 1: { + changes: [], + schemas: { + create: { plain: 'object-without-getSchema' }, + forwardCompatibility: schema.object({}, { unknowns: 'ignore' }), + }, + }, + }, + } as unknown as SavedObjectsType; + + expect(() => validateAllMappingsInModelVersion(typeName, to, registeredType)).toThrow( + `❌ The SO type '${typeName}' has a 'create' schema that is neither a Zod schema nor a Joi schema. Unable to extract fields for validation.` + ); + }); }); - it('does not flag an empty-object field as an index/enabled violation', () => { - const to: MigrationInfoRecord = { + describe('validateNoIndexOrEnabledFalse', () => { + const modelVersionWithFlattenedNewMappings: ModelVersionSummary = { + version: '2', + modelVersionHash: 'hash', + changeTypes: ['mappings_addition'], + hasTransformation: false, + newMappings: ['category.type', 'category.index', 'category.doc_values'], + schemas: { create: false, forwardCompatibility: false }, + }; + + const toWithIndexFalse: MigrationInfoRecord = { name: 'my-type', hash: 'hash', migrationVersions: [], schemaVersions: [], - modelVersions: [], - mappings: mappingsWithEmptyObjectField, + modelVersions: [modelVersionWithFlattenedNewMappings], + mappings: { + 'properties.category.type': 'keyword', + 'properties.category.index': false, + 'properties.category.doc_values': false, + }, }; - expect(() => validateNoIndexOrEnabledFalseInAllMappings('my-type', to)).not.toThrow(); + it('reports each violating field once when newMappings lists multiple flattened paths', () => { + try { + validateNoIndexOrEnabledFalse('my-type', toWithIndexFalse, [ + modelVersionWithFlattenedNewMappings, + ]); + fail('expected SavedObjectsCheckError'); + } catch (err) { + expect(isSavedObjectsCheckError(err)).toBe(true); + if (!isSavedObjectsCheckError(err)) { + return; + } + expect(err.findings).toHaveLength(1); + expect(err.findings[0].message).toBe( + "The SO type 'my-type' has new mapping fields with 'index: false': category." + ); + } + }); + }); + + describe('empty-object field handling (preserved `properties: {}` leaves)', () => { + // Snapshots now preserve empty object fields as an explicit `properties..properties: {}` + // flattened leaf. These tests lock in that the flattened-mapping consumers tolerate that key. + const mappingsWithEmptyObjectField: Record = { + dynamic: false, + 'properties.title.type': 'text', + 'properties.pending_upgrade_review.dynamic': false, + 'properties.pending_upgrade_review.properties': {}, + }; + + it('does not derive a spurious field path from an empty-object `properties` leaf', () => { + // The empty-object leaf collapses to the same parent path as its sibling `dynamic` key, + // so the field set is exactly { title, pending_upgrade_review } with no `…properties` entry. + expect(getMappingFieldPaths(mappingsWithEmptyObjectField)).toEqual([ + 'pending_upgrade_review', + 'title', + ]); + }); + + it('does not flag an empty-object field as an index/enabled violation', () => { + const to: MigrationInfoRecord = { + name: 'my-type', + hash: 'hash', + migrationVersions: [], + schemaVersions: [], + modelVersions: [], + mappings: mappingsWithEmptyObjectField, + }; + + expect(() => validateNoIndexOrEnabledFalseInAllMappings('my-type', to)).not.toThrow(); + }); }); }); diff --git a/packages/kbn-check-saved-objects-cli/src/snapshots/validate_changes/common_utils.ts b/packages/kbn-check-saved-objects-cli/src/snapshots/validate_changes/common_utils.ts index ce3c61699e3a0..c4fc4d71c2c69 100644 --- a/packages/kbn-check-saved-objects-cli/src/snapshots/validate_changes/common_utils.ts +++ b/packages/kbn-check-saved-objects-cli/src/snapshots/validate_changes/common_utils.ts @@ -8,6 +8,7 @@ */ import type { SavedObjectsType, ModelVersionIdentifier } from '@kbn/core-saved-objects-server'; +import { type z, isZod } from '@kbn/zod'; import type { MigrationInfoRecord, ModelVersionSummary } from '../../types'; import { RULE_IDS, SavedObjectsCheckError } from '../../findings'; import { getVersions } from '../../migrations'; @@ -143,8 +144,13 @@ export function toSchemaPathFormat(mappingPath: string): string { * * Accesses Joi schema internals following the same pattern as `getSchemaStructure()` in * kbn-config-schema. If Joi internals change, both places will need updating. + * + * @internal Exported for unit tests */ -function extractMappingCompatibleSchemaFields(joiSchema: any, path: string[] = []): string[] { +export function extractMappingCompatibleSchemaFields( + joiSchema: any, + path: string[] = [] +): string[] { const matches: Array<{ schema: any }> = joiSchema.$_terms?.matches; if (matches?.length > 0) { return matches.flatMap(({ schema: alt }) => extractMappingCompatibleSchemaFields(alt, path)); @@ -166,6 +172,140 @@ function extractMappingCompatibleSchemaFields(joiSchema: any, path: string[] = [ return path.length > 0 ? [path.join('.')] : []; } +/** + * Zod equivalent of {@link extractMappingCompatibleSchemaFields}. Walks Zod v4 `def` / `shape` (and + * common wrappers) so mapping paths align with the Joi-based extractor for the same logical schema. + * + * @internal Exported for unit tests + */ +export function extractMappingCompatibleZodSchemaFields( + zodSchema: z.ZodType, + path: string[] = [] +): string[] { + const inner = unwrapZodSchemaForMapping(zodSchema); + const kind = zodDefType(inner); + + if (kind === 'union') { + const options = Reflect.get(zodDefOf(inner), 'options'); + if (Array.isArray(options) && options.length > 0) { + return options.flatMap((opt: unknown) => + isZod(opt) ? extractMappingCompatibleZodSchemaFields(opt, path) : [] + ); + } + } + + if (kind === 'intersection') { + const def = zodDefOf(inner); + const left = Reflect.get(def, 'left'); + const right = Reflect.get(def, 'right'); + const results: string[] = []; + if (isZod(left)) { + results.push(...extractMappingCompatibleZodSchemaFields(left, path)); + } + if (isZod(right)) { + results.push(...extractMappingCompatibleZodSchemaFields(right, path)); + } + return results.length > 0 ? results : path.length > 0 ? [path.join('.')] : []; + } + + if (kind === 'lazy') { + const getter = Reflect.get(zodDefOf(inner), 'getter'); + if (typeof getter === 'function') { + const resolved = getter(); + if (isZod(resolved)) { + return extractMappingCompatibleZodSchemaFields(resolved, path); + } + } + } + + if (kind === 'object') { + const shape = getZodObjectShape(inner); + if (shape && Object.keys(shape).length > 0) { + return Object.entries(shape).flatMap(([key, child]) => + extractMappingCompatibleZodSchemaFields(child, [...path, key]) + ); + } + } + + if (kind === 'array') { + const element = Reflect.get(zodDefOf(inner), 'element'); + if (isZod(element)) { + const itemPaths = extractMappingCompatibleZodSchemaFields(element, path); + return itemPaths.length > 0 ? itemPaths : path.length > 0 ? [path.join('.')] : []; + } + } + + return path.length > 0 ? [path.join('.')] : []; +} + +function zodDefOf(schema: z.ZodType): object { + const def = Reflect.get(schema, 'def'); + if (typeof def === 'object' && def !== null) { + return def; + } + return {}; +} + +function zodDefType(schema: z.ZodType): string { + const t = Reflect.get(zodDefOf(schema), 'type'); + return typeof t === 'string' ? t : 'unknown'; +} + +function unwrapZodSchemaForMapping(schema: z.ZodType): z.ZodType { + let s = schema; + for (;;) { + const kind = zodDefType(s); + const def = zodDefOf(s); + if ( + kind === 'optional' || + kind === 'nullable' || + kind === 'default' || + kind === 'catch' || + kind === 'readonly' + ) { + const next = Reflect.get(def, 'innerType'); + if (isZod(next)) { + s = next; + continue; + } + break; + } + if (kind === 'pipe') { + const next = Reflect.get(def, 'in'); + if (isZod(next)) { + s = next; + continue; + } + break; + } + break; + } + return s; +} + +function getZodObjectShape(schema: z.ZodType): Record | undefined { + const shape = Reflect.get(schema, 'shape'); + if (typeof shape !== 'object' || shape === null || Array.isArray(shape)) { + return undefined; + } + const out: Record = {}; + for (const key of Object.keys(shape)) { + const v = Reflect.get(shape, key); + if (isZod(v)) { + out[key] = v; + } + } + return out; +} + +function hasGetSchemaForMapping(value: unknown): value is { getSchema: () => unknown } { + return ( + typeof value === 'object' && + value !== null && + typeof Reflect.get(value, 'getSchema') === 'function' + ); +} + export function validateAllMappingsInModelVersion( name: string, to: MigrationInfoRecord, @@ -196,7 +336,16 @@ export function validateAllMappingsInModelVersion( } const mappingFieldPaths = getMappingFieldPaths(to.mappings); - const schemaFields = extractMappingCompatibleSchemaFields(createSchema.getSchema()); + let schemaFields: string[]; + if (isZod(createSchema)) { + schemaFields = extractMappingCompatibleZodSchemaFields(createSchema); + } else if (hasGetSchemaForMapping(createSchema)) { + schemaFields = extractMappingCompatibleSchemaFields(createSchema.getSchema()); + } else { + throw new Error( + `❌ The SO type '${name}' has a 'create' schema that is neither a Zod schema nor a Joi schema. Unable to extract fields for validation.` + ); + } const normalizedMappingPaths = mappingFieldPaths.map((p) => toSchemaPathFormat(p)); const undeclaredFields = normalizedMappingPaths.filter((field) => { diff --git a/packages/kbn-check-saved-objects-cli/tsconfig.json b/packages/kbn-check-saved-objects-cli/tsconfig.json index b0d5f51404ae0..e90e12a5e7cd3 100644 --- a/packages/kbn-check-saved-objects-cli/tsconfig.json +++ b/packages/kbn-check-saved-objects-cli/tsconfig.json @@ -25,6 +25,7 @@ "@kbn/core-saved-objects-api-server", "@kbn/migrator-test-kit", "@kbn/config-schema", + "@kbn/zod", "@kbn/repo-info", "@kbn/encrypted-saved-objects-plugin", "@kbn/object-utils", diff --git a/src/core/packages/saved-objects/base-server-internal/moon.yml b/src/core/packages/saved-objects/base-server-internal/moon.yml index c0dd0822ebc7b..90cc7ce4c92bc 100644 --- a/src/core/packages/saved-objects/base-server-internal/moon.yml +++ b/src/core/packages/saved-objects/base-server-internal/moon.yml @@ -18,6 +18,7 @@ project: sourceRoot: src/core/packages/saved-objects/base-server-internal dependsOn: - '@kbn/logging' + - '@kbn/zod' - '@kbn/config-schema' - '@kbn/core-base-server-internal' - '@kbn/core-saved-objects-server' diff --git a/src/core/packages/saved-objects/base-server-internal/src/model_version/backward_conversion_schema.test.ts b/src/core/packages/saved-objects/base-server-internal/src/model_version/backward_conversion_schema.test.ts index 7fb78ef0b2a4f..3b6792fac974d 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/model_version/backward_conversion_schema.test.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/model_version/backward_conversion_schema.test.ts @@ -8,6 +8,7 @@ */ import { schema } from '@kbn/config-schema'; +import { z } from '@kbn/zod'; import { convertModelVersionBackwardConversionSchema } from './backward_conversion_schema'; import type { SavedObjectUnsanitizedDoc, @@ -24,6 +25,16 @@ describe('convertModelVersionBackwardConversionSchema', () => { ...parts, }); + it('should throw if the schema is unknown', () => { + const mySchema = {} as any; + + expect(() => + convertModelVersionBackwardConversionSchema(mySchema) + ).toThrowErrorMatchingInlineSnapshot( + `"Unknown forward compatibility schema. Must be defined with \`@kbn/zod\` or \`@kbn/config-schema\`."` + ); + }); + describe('using functions', () => { it('converts the schema', () => { const conversionSchema: jest.MockedFunction = @@ -152,4 +163,71 @@ describe('convertModelVersionBackwardConversionSchema', () => { }); }); }); + + describe('using zod', () => { + it('converts the schema', () => { + const conversionSchema = z.object({ + foo: z.string().optional(), + }); + const parseSpy = jest.spyOn(conversionSchema, 'safeParse'); + + const doc = createDoc({ attributes: { foo: 'bar' } }); + const converted = convertModelVersionBackwardConversionSchema(conversionSchema); + + const output = converted(doc); + + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(parseSpy).toHaveBeenCalledWith({ foo: 'bar' }); + expect(output).toEqual(doc); + }); + + it('returns the document with the updated properties', () => { + const conversionSchema = z.object({ + foo: z.string().optional(), + }); + + const doc = createDoc({ attributes: { foo: 'bar', hello: 'dolly' } }); + const converted = convertModelVersionBackwardConversionSchema(conversionSchema); + + const output = converted(doc); + + expect(output).toEqual({ + ...doc, + attributes: { + foo: 'bar', + }, + }); + }); + + it('throws if the validation throws', () => { + const conversionSchema = z.object({ + foo: z.string(), + }); + + const doc = createDoc({ attributes: { foo: 1 } }); + const converted = convertModelVersionBackwardConversionSchema(conversionSchema); + + expect(() => converted(doc)).toThrow(/Invalid input: expected string, received number/); + }); + + it('returns the known subset of keys with their original values', () => { + const conversionSchema = z.object({ + durations: z.array(z.string()), + byteSize: z.string(), + }); + + const doc = createDoc({ + attributes: { durations: ['1m', '4d'], byteSize: '1gb', excluded: true }, + }); + const converted = convertModelVersionBackwardConversionSchema(conversionSchema); + + expect(converted(doc)).toEqual({ + ...doc, + attributes: { + durations: ['1m', '4d'], + byteSize: '1gb', + }, + }); + }); + }); }); diff --git a/src/core/packages/saved-objects/base-server-internal/src/model_version/backward_conversion_schema.ts b/src/core/packages/saved-objects/base-server-internal/src/model_version/backward_conversion_schema.ts index d7ffa09b8a69b..bad0c2c409bc9 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/model_version/backward_conversion_schema.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/model_version/backward_conversion_schema.ts @@ -7,29 +7,47 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { isConfigSchema, type ObjectType } from '@kbn/config-schema'; +import { isZod, z } from '@kbn/zod'; +import { isConfigSchema } from '@kbn/config-schema'; import type { SavedObjectUnsanitizedDoc, SavedObjectModelVersionForwardCompatibilitySchema, } from '@kbn/core-saved-objects-server'; import { pickValuesBasedOnStructure } from '../utils'; -function isObjectType( - schema: SavedObjectModelVersionForwardCompatibilitySchema -): schema is ObjectType { - return isConfigSchema(schema); -} - export type ConvertedSchema = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; +function toAttributeObject(attributes: unknown): object { + if (attributes !== null && typeof attributes === 'object') { + return attributes; + } + return {}; +} + export const convertModelVersionBackwardConversionSchema = ( - schema: SavedObjectModelVersionForwardCompatibilitySchema + forwardSchema: SavedObjectModelVersionForwardCompatibilitySchema ): ConvertedSchema => { - if (isObjectType(schema)) { + if (isZod(forwardSchema)) { + const zodSchema = forwardSchema; + return (doc) => { + const originalAttrs = toAttributeObject(doc.attributes); + const result = zodSchema.safeParse(doc.attributes); + if (!result.success) { + throw new Error(z.prettifyError(result.error)); + } + const convertedAttrs = pickValuesBasedOnStructure(result.data, originalAttrs); + return { + ...doc, + attributes: convertedAttrs, + }; + }; + } + + if (isConfigSchema(forwardSchema)) { return (doc) => { - const originalAttrs = doc.attributes as object; + const originalAttrs = toAttributeObject(doc.attributes); // Get the validated object, with possible stripping of unknown keys - const validatedAttrs = schema.validate(doc.attributes); + const validatedAttrs = forwardSchema.validate(doc.attributes); // Use the validated attrs object to pick values from the original attrs. // // If we reversed this, validation conversion would be returned in the @@ -41,13 +59,19 @@ export const convertModelVersionBackwardConversionSchema = ( attributes: convertedAttrs, }; }; - } else { + } + + if (typeof forwardSchema === 'function') { return (doc) => { - const attrs = schema(doc.attributes); + const attrs = forwardSchema(doc.attributes); return { ...doc, attributes: attrs, }; }; } + + throw new Error( + 'Unknown forward compatibility schema. Must be defined with `@kbn/zod` or `@kbn/config-schema`.' + ); }; diff --git a/src/core/packages/saved-objects/base-server-internal/src/validation/base_schema.ts b/src/core/packages/saved-objects/base-server-internal/src/validation/base_schema.ts new file mode 100644 index 0000000000000..60680f8e9e794 --- /dev/null +++ b/src/core/packages/saved-objects/base-server-internal/src/validation/base_schema.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod'; +import { schema, type Type } from '@kbn/config-schema'; +import type { SavedObjectSanitizedDoc } from '@kbn/core-saved-objects-server'; + +// We convert `SavedObjectSanitizedDoc` to its validation schema representation +// to ensure that we don't forget to keep the schema up-to-date. TS will complain +// if we update `SavedObjectSanitizedDoc` without making changes below. +type SavedObjectSanitizedDocSchema = { + [K in keyof Required]: Type; +}; + +/** + * Base config-schema schema for a saved object. + * + * @internal + */ +export const baseConfigSchema = schema.object({ + id: schema.string({ minLength: 1 }), + type: schema.string(), + references: schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }), + { + defaultValue: [], + maxSize: 10_000, // needed to allow importing dashboards with duplicate references, + } + ), + namespace: schema.maybe(schema.string()), + namespaces: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 100 })), + migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), + coreMigrationVersion: schema.maybe(schema.string()), + typeMigrationVersion: schema.maybe(schema.string()), + updated_at: schema.maybe(schema.string()), + updated_by: schema.maybe(schema.string()), + created_at: schema.maybe(schema.string()), + created_by: schema.maybe(schema.string()), + version: schema.maybe(schema.string()), + originId: schema.maybe(schema.string()), + managed: schema.maybe(schema.boolean()), + accessControl: schema.maybe( + schema.object({ + owner: schema.string(), + accessMode: schema.oneOf([schema.literal('write_restricted'), schema.literal('default')]), + }) + ), + attributes: schema.recordOf(schema.string(), schema.maybe(schema.any())), +} satisfies SavedObjectSanitizedDocSchema); + +/** + * Base Zod schema for a saved object. + * + * @internal + */ +export const baseZodSchema = z.object({ + id: z.string().min(1), + type: z.string(), + references: z + .array( + z.object({ + name: z.string(), + type: z.string(), + id: z.string(), + }) + ) + .max(10_000) // needed to allow importing dashboards with duplicate references, + .default([]), + namespace: z.string().optional(), + namespaces: z.array(z.string()).max(100).optional(), + migrationVersion: z.record(z.string(), z.string()).optional(), + coreMigrationVersion: z.string().optional(), + typeMigrationVersion: z.string().optional(), + updated_at: z.string().optional(), + updated_by: z.string().optional(), + created_at: z.string().optional(), + created_by: z.string().optional(), + version: z.string().optional(), + originId: z.string().optional(), + managed: z.boolean().optional(), + accessControl: z + .object({ + owner: z.string(), + accessMode: z.enum(['write_restricted', 'default']), + }) + .optional(), + attributes: z.record(z.string(), z.any().optional()), +}) satisfies z.ZodType; diff --git a/src/core/packages/saved-objects/base-server-internal/src/validation/schema.test.ts b/src/core/packages/saved-objects/base-server-internal/src/validation/schema.test.ts index 6905589351938..6b630bacf009e 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/validation/schema.test.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/validation/schema.test.ts @@ -8,11 +8,12 @@ */ import { schema } from '@kbn/config-schema'; +import { z } from '@kbn/zod'; import type { SavedObjectsValidationMap, SavedObjectSanitizedDoc, } from '@kbn/core-saved-objects-server'; -import { createSavedObjectSanitizedDocSchema } from './schema'; +import { createSavedObjectSanitizedDocValidator } from './schema'; describe('Saved Objects type validation schema', () => { const type = 'my-type'; @@ -29,35 +30,44 @@ describe('Saved Objects type validation schema', () => { type, }); + it('should throw if schema is unknown', () => { + const mySchema = {} as any; + expect(() => { + createSavedObjectSanitizedDocValidator(mySchema); + }).toThrowErrorMatchingInlineSnapshot( + `"Unknown attributes schema. Must be defined with \`@kbn/zod\` or \`@kbn/config-schema\`."` + ); + }); + it('should validate attributes based on provided spec', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']); + const validate = createSavedObjectSanitizedDocValidator(validationMap['1.0.0']); const data = createMockObject({ foo: 'heya' }); - expect(() => objectSchema.validate(data)).not.toThrowError(); + expect(() => validate(data)).not.toThrowError(); }); it('should fail if invalid attributes are provided', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']); + const validate = createSavedObjectSanitizedDocValidator(validationMap['1.0.0']); const data = createMockObject({ foo: false }); - expect(() => objectSchema.validate(data)).toThrowErrorMatchingInlineSnapshot( + expect(() => validate(data)).toThrowErrorMatchingInlineSnapshot( `"[attributes.foo]: expected value of type [string] but got [boolean]"` ); }); it('should fail if invalid id is provided', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']); + const validate = createSavedObjectSanitizedDocValidator(validationMap['1.0.0']); const data = createMockObject({ foo: 'bar' }); data.id = ''; - expect(() => objectSchema.validate(data)).toThrowErrorMatchingInlineSnapshot( + expect(() => validate(data)).toThrowErrorMatchingInlineSnapshot( `"[id]: value has length [0] but it must have a minimum length of [1]."` ); }); it('should validate top-level properties', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']); + const validate = createSavedObjectSanitizedDocValidator(validationMap['1.0.0']); const data = createMockObject({ foo: 'heya' }); expect(() => - objectSchema.validate({ + validate({ ...data, id: 'abc-123', type: 'dashboard', @@ -86,48 +96,55 @@ describe('Saved Objects type validation schema', () => { }); it('should fail if top-level properties are invalid', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']); + const validate = createSavedObjectSanitizedDocValidator(validationMap['1.0.0']); const data = createMockObject({ foo: 'heya' }); - expect(() => objectSchema.validate({ ...data, id: false })).toThrowErrorMatchingInlineSnapshot( + expect(() => + validate({ + ...data, + // @ts-expect-error - invalid id + id: false, + }) + ).toThrowErrorMatchingInlineSnapshot( `"[id]: expected value of type [string] but got [boolean]"` ); }); describe('default schema', () => { it('validates a record of attributes', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(undefined); + const validate = createSavedObjectSanitizedDocValidator(undefined); const data = createMockObject({ foo: 'heya' }); - expect(() => objectSchema.validate(data)).not.toThrowError(); + expect(() => validate(data)).not.toThrowError(); }); it('fails validation on undefined attributes', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(undefined); + const validate = createSavedObjectSanitizedDocValidator(undefined); const data = createMockObject(undefined); - expect(() => objectSchema.validate(data)).toThrowErrorMatchingInlineSnapshot( + expect(() => validate(data)).toThrowErrorMatchingInlineSnapshot( `"[attributes]: expected value of type [object] but got [undefined]"` ); }); it('fails validation on primitive attributes', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(undefined); + const validate = createSavedObjectSanitizedDocValidator(undefined); const data = createMockObject(42); - expect(() => objectSchema.validate(data)).toThrowErrorMatchingInlineSnapshot( + expect(() => validate(data)).toThrowErrorMatchingInlineSnapshot( `"[attributes]: expected value of type [object] but got [number]"` ); }); it('fails validation on incorrect access control', () => { - const objectSchema = createSavedObjectSanitizedDocSchema(undefined); + const validate = createSavedObjectSanitizedDocValidator(undefined); const data = createMockObject({ foo: 'heya' }); expect(() => - objectSchema.validate({ + validate({ ...data, accessControl: { owner: 'user1', + // @ts-expect-error - invalid accessMode accessMode: 'invalid_mode', }, }) @@ -138,4 +155,41 @@ describe('Saved Objects type validation schema', () => { ); }); }); + + describe('using zod for attributes', () => { + const zodAttributesMap: SavedObjectsValidationMap = { + '1.0.0': z.object({ + foo: z.string(), + }), + }; + + it('should validate attributes based on zod spec', () => { + const validate = createSavedObjectSanitizedDocValidator(zodAttributesMap['1.0.0']); + const data = createMockObject({ foo: 'heya' }); + expect(() => validate(data)).not.toThrowError(); + }); + + it('should fail if invalid attributes are provided', () => { + const validate = createSavedObjectSanitizedDocValidator(zodAttributesMap['1.0.0']); + const data = createMockObject({ foo: false }); + expect(() => validate(data)).toThrowErrorMatchingInlineSnapshot(` + "✖ Invalid input: expected string, received boolean + → at attributes.foo" + `); + }); + + it('should fail if invalid id is provided', () => { + const validate = createSavedObjectSanitizedDocValidator(zodAttributesMap['1.0.0']); + const data = createMockObject({ foo: 'bar' }); + expect(() => + validate({ + ...data, + id: '', + }) + ).toThrowErrorMatchingInlineSnapshot(` + "✖ Too small: expected string to have >=1 characters + → at id" + `); + }); + }); }); diff --git a/src/core/packages/saved-objects/base-server-internal/src/validation/schema.ts b/src/core/packages/saved-objects/base-server-internal/src/validation/schema.ts index e969f1fedf84a..8ad1f133202bd 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/validation/schema.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/validation/schema.ts @@ -7,68 +7,58 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { schema, type Type } from '@kbn/config-schema'; +import { z, isZod } from '@kbn/zod'; +import { isConfigSchema } from '@kbn/config-schema'; import type { SavedObjectsValidationSpec, SavedObjectSanitizedDoc, } from '@kbn/core-saved-objects-server'; +import { baseConfigSchema, baseZodSchema } from './base_schema'; -// We convert `SavedObjectSanitizedDoc` to its validation schema representation -// to ensure that we don't forget to keep the schema up-to-date. TS will complain -// if we update `SavedObjectSanitizedDoc` without making changes below. -type SavedObjectSanitizedDocSchema = { - [K in keyof Required]: Type; -}; - -const baseSchema = schema.object({ - id: schema.string({ minLength: 1 }), - type: schema.string(), - references: schema.arrayOf( - schema.object({ - name: schema.string(), - type: schema.string(), - id: schema.string(), - }), - { - defaultValue: [], - maxSize: 10_000, // needed to allow importing dashboards with duplicate references - } - ), - namespace: schema.maybe(schema.string()), - namespaces: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 100 })), - migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), - coreMigrationVersion: schema.maybe(schema.string()), - typeMigrationVersion: schema.maybe(schema.string()), - updated_at: schema.maybe(schema.string()), - updated_by: schema.maybe(schema.string()), - created_at: schema.maybe(schema.string()), - created_by: schema.maybe(schema.string()), - version: schema.maybe(schema.string()), - originId: schema.maybe(schema.string()), - managed: schema.maybe(schema.boolean()), - accessControl: schema.maybe( - schema.object({ - owner: schema.string(), - accessMode: schema.oneOf([schema.literal('write_restricted'), schema.literal('default')]), - }) - ), - attributes: schema.recordOf(schema.string(), schema.maybe(schema.any())), -}); +/** + * Generic validator function for a given {@link SavedObjectSanitizedDoc} + * + * @internal + */ +export type SavedObjectSanitizedDocValidator = ( + document: SavedObjectSanitizedDoc +) => SavedObjectSanitizedDoc; /** - * Takes a {@link SavedObjectsValidationSpec} and returns a full schema representing - * a {@link SavedObjectSanitizedDoc}, with the spec applied to the object's `attributes`. + * Takes a {@link SavedObjectsValidationSpec} and returns a validator function for a full + * {@link SavedObjectSanitizedDoc}, with the spec applied to the object's `attributes`. * * @internal */ -export const createSavedObjectSanitizedDocSchema = ( - attributesSchema: SavedObjectsValidationSpec | undefined -) => { - if (attributesSchema) { - return baseSchema.extends({ +export const createSavedObjectSanitizedDocValidator = ( + attributesSchema?: SavedObjectsValidationSpec +): SavedObjectSanitizedDocValidator => { + if (!attributesSchema) { + return (document) => baseConfigSchema.validate(document); + } + + if (isZod(attributesSchema)) { + const fullSchema = baseZodSchema.extend({ attributes: attributesSchema, }); - } else { - return baseSchema; + + return (document) => { + const result = fullSchema.safeParse(document); + if (!result.success) { + throw new Error(z.prettifyError(result.error)); + } + return result.data; + }; } + + if (isConfigSchema(attributesSchema)) { + const fullSchema = baseConfigSchema.extends({ + attributes: attributesSchema, + }); + return (document) => fullSchema.validate(document); + } + + throw new Error( + 'Unknown attributes schema. Must be defined with `@kbn/zod` or `@kbn/config-schema`.' + ); }; diff --git a/src/core/packages/saved-objects/base-server-internal/src/validation/validator.test.ts b/src/core/packages/saved-objects/base-server-internal/src/validation/validator.test.ts index f92e195e41ee5..3da1cd921783d 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/validation/validator.test.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/validation/validator.test.ts @@ -8,6 +8,7 @@ */ import { schema } from '@kbn/config-schema'; +import { z } from '@kbn/zod'; import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; import type { SavedObjectSanitizedDoc, @@ -204,4 +205,33 @@ describe('Saved Objects type validator', () => { expect(getCalledVersion()).toEqual('3.0.0'); }); }); + + describe('zod validation specs', () => { + const zodValidationMap: SavedObjectsValidationMap = { + '3.0.0': z.object({ + foo: z.string(), + }), + }; + + beforeEach(() => { + validator = new SavedObjectsTypeValidator({ + logger, + type, + validationMap: zodValidationMap, + defaultVersion, + }); + }); + + it('validates with zod when attributes match', () => { + const data = createMockObject({ attributes: { foo: 'hi' } }); + expect(() => validator.validate(data)).not.toThrowError(); + }); + + it('throws when zod rejects attributes', () => { + const data = createMockObject({ attributes: { foo: 1 } }); + expect(() => validator.validate(data)).toThrow( + /Invalid input: expected string, received number/ + ); + }); + }); }); diff --git a/src/core/packages/saved-objects/base-server-internal/src/validation/validator.ts b/src/core/packages/saved-objects/base-server-internal/src/validation/validator.ts index 8f07fea0af93e..3651380556db2 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/validation/validator.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/validation/validator.ts @@ -14,7 +14,7 @@ import type { SavedObjectsValidationMap, SavedObjectSanitizedDoc, } from '@kbn/core-saved-objects-server'; -import { createSavedObjectSanitizedDocSchema } from './schema'; +import { createSavedObjectSanitizedDocValidator } from './schema'; import { isVirtualModelVersion } from '../model_version'; /** @@ -64,8 +64,8 @@ export class SavedObjectsTypeValidator { } try { - const validationSchema = createSavedObjectSanitizedDocSchema(validationRule); - validationSchema.validate(document); + const validate = createSavedObjectSanitizedDocValidator(validationRule); + validate(document); } catch (e) { this.log.warn( `Error validating object of type [${this.type}] against version [${usedVersion}]` diff --git a/src/core/packages/saved-objects/base-server-internal/tsconfig.json b/src/core/packages/saved-objects/base-server-internal/tsconfig.json index 12e9e79999ae3..f00b69d3c2363 100644 --- a/src/core/packages/saved-objects/base-server-internal/tsconfig.json +++ b/src/core/packages/saved-objects/base-server-internal/tsconfig.json @@ -12,6 +12,7 @@ ], "kbn_references": [ "@kbn/logging", + "@kbn/zod", "@kbn/config-schema", "@kbn/core-base-server-internal", "@kbn/core-saved-objects-server", diff --git a/src/core/packages/saved-objects/server/moon.yml b/src/core/packages/saved-objects/server/moon.yml index d2dec1c8e15be..2d96b99b2212d 100644 --- a/src/core/packages/saved-objects/server/moon.yml +++ b/src/core/packages/saved-objects/server/moon.yml @@ -18,6 +18,7 @@ project: sourceRoot: src/core/packages/saved-objects/server dependsOn: - '@kbn/utility-types' + - '@kbn/zod' - '@kbn/config-schema' - '@kbn/logging' - '@kbn/core-http-server' diff --git a/src/core/packages/saved-objects/server/src/model_version/schemas.ts b/src/core/packages/saved-objects/server/src/model_version/schemas.ts index 18fc3b6727ff7..f1a4497eb0b82 100644 --- a/src/core/packages/saved-objects/server/src/model_version/schemas.ts +++ b/src/core/packages/saved-objects/server/src/model_version/schemas.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { z } from '@kbn/zod'; import type { ObjectType } from '@kbn/config-schema'; import type { SavedObjectsValidationSpec } from '../validation'; @@ -34,7 +35,7 @@ export interface SavedObjectsModelVersionSchemaDefinitions { forwardCompatibility?: SavedObjectModelVersionForwardCompatibilitySchema; /** * The schema applied when creating a document of the current version - * Allows for validating properties using @kbn/config-schema validations + * Allows for validating properties using `@kbn/config-schema` or `@kbn/zod`. */ create?: SavedObjectsValidationSpec; } @@ -57,7 +58,7 @@ export interface SavedObjectsFullModelVersionSchemaDefinitions { forwardCompatibility: SavedObjectModelVersionForwardCompatibilitySchema; /** * The schema applied when creating a document of the current version - * Allows for validating properties using @kbn/config-schema validations + * Allows for validating properties using `@kbn/config-schema` or `@kbn/zod`. */ create: SavedObjectsValidationSpec; } @@ -66,7 +67,8 @@ export interface SavedObjectsFullModelVersionSchemaDefinitions { * Schema used when retrieving a document of a higher version to convert them to the older version. * * These schemas can be defined in multiple ways: - * - A `@kbn/config-schema`'s Object schema, that will receive the document's attributes + * - A `@kbn/config-schema` schema, that will receive the document's attributes + * - A `@kbn/zod` schema, that will receive the document's attributes * - An arbitrary function that will receive the document's attributes as parameter and should return the converted attributes * * @remark These conversion mechanism shouldn't assert the data itself, and only strip unknown fields to convert @@ -101,7 +103,11 @@ export type SavedObjectModelVersionForwardCompatibilitySchema< | SavedObjectModelVersionForwardCompatibilityFn; /** - * Object-schema (from `@kbn/config-schema`) alternative for {@link SavedObjectModelVersionForwardCompatibilitySchema} + * Schema alternative for {@link SavedObjectModelVersionForwardCompatibilitySchema} + * + * Supports: + * - `@kbn/zod` + * - `@kbn/config-schema` * * @example * ```ts @@ -115,7 +121,9 @@ export type SavedObjectModelVersionForwardCompatibilitySchema< * ``` * @public */ -export type SavedObjectModelVersionForwardCompatibilityObjectSchema = ObjectType; +export type SavedObjectModelVersionForwardCompatibilityObjectSchema = + | ObjectType + | z.ZodType>; /** * Plain javascript function alternative for {@link SavedObjectModelVersionForwardCompatibilitySchema} diff --git a/src/core/packages/saved-objects/server/src/validation.ts b/src/core/packages/saved-objects/server/src/validation.ts index 515c9d59f0c5c..7066bdc24559e 100644 --- a/src/core/packages/saved-objects/server/src/validation.ts +++ b/src/core/packages/saved-objects/server/src/validation.ts @@ -7,14 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { z } from '@kbn/zod'; import type { ObjectType } from '@kbn/config-schema'; /** - * Allows for validating properties using @kbn/config-schema validations. + * Allows for validating properties using `@kbn/config-schema` or `@kbn/zod`. * * @public */ -export type SavedObjectsValidationSpec = ObjectType; +export type SavedObjectsValidationSpec = ObjectType | z.ZodType>; /** * A map of {@link SavedObjectsValidationSpec | validation specs} to be used for a given type. diff --git a/src/core/packages/saved-objects/server/tsconfig.json b/src/core/packages/saved-objects/server/tsconfig.json index 645a8356e6bed..7a04c96422f62 100644 --- a/src/core/packages/saved-objects/server/tsconfig.json +++ b/src/core/packages/saved-objects/server/tsconfig.json @@ -12,6 +12,7 @@ ], "kbn_references": [ "@kbn/utility-types", + "@kbn/zod", "@kbn/config-schema", "@kbn/logging", "@kbn/core-http-server", diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/README.md b/src/platform/packages/shared/kbn-config-schema-helpers/README.md new file mode 100644 index 0000000000000..2068fafd6c179 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/README.md @@ -0,0 +1,11 @@ +# Helpers and utilities for @kbn/config-schema + +Helpers defined in this package: + +- Can be used in other packages and plugins to make it easier to work with `@kbn/config-schema`, such as in tests. +- Are already used in saved object model version tests. + +When you add some helper code to this package, please make sure that: + +- The code is generic and domain-agnostic (doesn't "know" about any domains such as Security or Observability). +- The code is reusable and there are already a few use cases for it. Try to not generalize prematurely. diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/index.ts b/src/platform/packages/shared/kbn-config-schema-helpers/index.ts new file mode 100644 index 0000000000000..7b63cd164b2c5 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { expectConfigSchema } from './src/expect_config_schema'; diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/jest.config.js b/src/platform/packages/shared/kbn-config-schema-helpers/jest.config.js new file mode 100644 index 0000000000000..6f499b15532d6 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/platform/packages/shared/kbn-config-schema-helpers'], +}; diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/kibana.jsonc b/src/platform/packages/shared/kbn-config-schema-helpers/kibana.jsonc new file mode 100644 index 0000000000000..89f3da9c2b3b4 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/kibana.jsonc @@ -0,0 +1,10 @@ +{ + "type": "shared-common", + "id": "@kbn/config-schema-helpers", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", + "devOnly": false +} diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/moon.yml b/src/platform/packages/shared/kbn-config-schema-helpers/moon.yml new file mode 100644 index 0000000000000..6106cbdd872c6 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/moon.yml @@ -0,0 +1,34 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/config-schema-helpers' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/config-schema-helpers' +layer: unknown +owners: + defaultOwner: '@elastic/kibana-core' +toolchains: + default: node +language: typescript +project: + title: '@kbn/config-schema-helpers' + description: Moon project for @kbn/config-schema-helpers + channel: '' + owner: '@elastic/kibana-core' + sourceRoot: src/platform/packages/shared/kbn-config-schema-helpers +dependsOn: + - '@kbn/config-schema' +tags: + - shared-common + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' + jest-config: + - jest.config.js +tasks: {} diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/package.json b/src/platform/packages/shared/kbn-config-schema-helpers/package.json new file mode 100644 index 0000000000000..fe9cfa4025398 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/package.json @@ -0,0 +1,8 @@ +{ + "description": "Config Schema helpers for Kibana", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", + "name": "@kbn/config-schema-helpers", + "private": true, + "version": "1.0.0", + "sideEffects": false +} diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/src/expect_config_schema.test.ts b/src/platform/packages/shared/kbn-config-schema-helpers/src/expect_config_schema.test.ts new file mode 100644 index 0000000000000..b3114f6ae83c7 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/src/expect_config_schema.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { schema } from '@kbn/config-schema'; +import { expectConfigSchema } from './expect_config_schema'; + +describe('expectConfigSchema', () => { + it('does not throw for a config schema', () => { + expect(() => expectConfigSchema(schema.string())).not.toThrow(); + }); + + it('throws for a non-config-schema value', () => { + expect(() => expectConfigSchema('not a schema')).toThrow(); + }); +}); diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/src/expect_config_schema.ts b/src/platform/packages/shared/kbn-config-schema-helpers/src/expect_config_schema.ts new file mode 100644 index 0000000000000..f74404c4492f9 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/src/expect_config_schema.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { isConfigSchema, type Type } from '@kbn/config-schema'; + +export function expectConfigSchema(schema: unknown): asserts schema is Type { + expect(isConfigSchema(schema)).toBe(true); +} diff --git a/src/platform/packages/shared/kbn-config-schema-helpers/tsconfig.json b/src/platform/packages/shared/kbn-config-schema-helpers/tsconfig.json new file mode 100644 index 0000000000000..c14eb0103af60 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema-helpers/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "outDir": "target/types", + "types": ["jest", "node"] + }, + "exclude": ["target/**/*"], + "extends": "@kbn/tsconfig-base/tsconfig.json", + "include": ["**/*.ts"], + "kbn_references": ["@kbn/config-schema"] +} diff --git a/src/platform/packages/shared/kbn-zod/v4/get_schema_structure.test.ts b/src/platform/packages/shared/kbn-zod/v4/get_schema_structure.test.ts new file mode 100644 index 0000000000000..9fa060475bc20 --- /dev/null +++ b/src/platform/packages/shared/kbn-zod/v4/get_schema_structure.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from 'zod/v4'; +import { getZodSchemaStructure } from './get_schema_structure'; + +describe('getZodSchemaStructure', () => { + it('lists leaf paths for a flat object', () => { + const schema = z.object({ + title: z.string(), + count: z.number().optional(), + }); + const structure = getZodSchemaStructure(schema); + expect(structure).toEqual( + expect.arrayContaining([ + { path: ['title'], type: 'string' }, + { path: ['count'], type: 'number?' }, + ]) + ); + expect(structure).toHaveLength(2); + }); + + it('nests object properties in path', () => { + const schema = z.object({ + meta: z.object({ + author: z.string(), + }), + }); + expect(getZodSchemaStructure(schema)).toEqual([{ path: ['meta', 'author'], type: 'string' }]); + }); + + it('describes arrays of primitives', () => { + const schema = z.object({ + tags: z.array(z.string()), + }); + expect(getZodSchemaStructure(schema)).toEqual([{ path: ['tags'], type: 'string[]' }]); + }); + + it('uses array label for array of object', () => { + const schema = z.object({ + rows: z.array(z.object({ id: z.string() })), + }); + expect(getZodSchemaStructure(schema)).toEqual([{ path: ['rows'], type: 'array' }]); + }); +}); diff --git a/src/platform/packages/shared/kbn-zod/v4/get_schema_structure.ts b/src/platform/packages/shared/kbn-zod/v4/get_schema_structure.ts new file mode 100644 index 0000000000000..647ac82e2e5cd --- /dev/null +++ b/src/platform/packages/shared/kbn-zod/v4/get_schema_structure.ts @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Produces a structure list similar to {@link Type.getSchemaStructure} from `@kbn/config-schema`, + * for Zod v4 schemas (`z` from `@kbn/zod`). Used for saved-object fixture templates and tooling. + * + * @remarks + * This walks Zod's public `def` / `shape` API (Zod v4). Rare wrappers (effects, custom refinements) + * may fall back to a coarse label from `def.type`. + */ + +import type { z } from 'zod/v4'; +import { isZod } from './util'; + +export interface ModelVersionSchemaProperty { + path: string[]; + type: string; +} + +function defOf(schema: z.ZodType): object { + const def = Reflect.get(schema, 'def'); + if (typeof def === 'object' && def !== null) { + return def; + } + return {}; +} + +function defType(schema: z.ZodType): string { + const def = defOf(schema); + const t = Reflect.get(def, 'type'); + return typeof t === 'string' ? t : 'unknown'; +} + +function asZodType(value: unknown): z.ZodType | undefined { + return isZod(value) ? value : undefined; +} + +interface UnwrappedZod { + inner: z.ZodType; + isOptional: boolean; + isNullable: boolean; +} + +function unwrapZodWrappers(schema: z.ZodType): UnwrappedZod { + let inner: z.ZodType = schema; + let isOptional = false; + let isNullable = false; + + for (;;) { + const kind = defType(inner); + const def = defOf(inner); + if (kind === 'optional') { + isOptional = true; + const next = asZodType(Reflect.get(def, 'innerType')); + if (!next) { + break; + } + inner = next; + } else if (kind === 'nullable') { + isNullable = true; + const next = asZodType(Reflect.get(def, 'innerType')); + if (!next) { + break; + } + inner = next; + } else if (kind === 'default' || kind === 'catch') { + const next = asZodType(Reflect.get(def, 'innerType')); + if (!next) { + break; + } + inner = next; + } else if (kind === 'readonly') { + const next = asZodType(Reflect.get(def, 'innerType')); + if (!next) { + break; + } + inner = next; + } else if (kind === 'pipe') { + const next = asZodType(Reflect.get(def, 'in')); + if (!next) { + break; + } + inner = next; + } else { + break; + } + } + + return { inner, isOptional, isNullable }; +} + +function modifierSuffix(isOptional: boolean, isNullable: boolean): string { + let s = ''; + if (isOptional) { + s += '?'; + } + if (isNullable) { + s += '|null'; + } + return s; +} + +function formatLeafTypeLabel(schema: z.ZodType): string { + const kind = defType(schema); + const def = defOf(schema); + + switch (kind) { + case 'string': + case 'number': + case 'boolean': + case 'bigint': + case 'date': + case 'symbol': + case 'undefined': + case 'null': + case 'void': + case 'never': + case 'any': + case 'unknown': + return kind; + case 'literal': { + const values = Reflect.get(def, 'values'); + if (Array.isArray(values) && values.length > 0) { + return values.map((v: unknown) => JSON.stringify(v)).join('|'); + } + return 'literal'; + } + case 'enum': { + const entries = Reflect.get(def, 'entries'); + if (entries && typeof entries === 'object') { + return [...new Set(Object.values(entries))].join('|'); + } + return 'enum'; + } + case 'array': { + const element = asZodType(Reflect.get(def, 'element')); + if (!element) { + return 'array'; + } + const uw = unwrapZodWrappers(element); + if (defType(uw.inner) === 'object') { + return 'array'; + } + return `${formatTypeDescriptor(uw.inner, uw.isOptional, uw.isNullable)}[]`; + } + case 'record': + return 'record'; + case 'map': + return 'map'; + case 'set': + return 'set'; + case 'tuple': + return 'tuple'; + case 'union': { + const options = Reflect.get(def, 'options'); + if (!Array.isArray(options) || options.length === 0) { + return 'union'; + } + const parts = options + .map((opt: unknown) => (isZod(opt) ? formatTypeDescriptorFromSchema(opt) : 'unknown')) + .filter(Boolean); + return [...new Set(parts)].join('|'); + } + case 'intersection': + return 'intersection'; + case 'lazy': { + const getter = Reflect.get(def, 'getter'); + if (typeof getter === 'function') { + const inner = asZodType(getter()); + if (inner) { + return formatLeafTypeLabel(inner); + } + } + return 'lazy'; + } + default: + return kind; + } +} + +function formatTypeDescriptor(inner: z.ZodType, isOptional: boolean, isNullable: boolean): string { + return `${formatLeafTypeLabel(inner)}${modifierSuffix(isOptional, isNullable)}`; +} + +function formatTypeDescriptorFromSchema(schema: z.ZodType): string { + const { inner, isOptional, isNullable } = unwrapZodWrappers(schema); + return formatTypeDescriptor(inner, isOptional, isNullable); +} + +/** + * Returns leaf paths and type labels for a Zod schema, aligned with + * `ObjectType#getSchemaStructure()` output shape used by fixture tooling. + */ +export function getZodSchemaStructure(schema: z.ZodType): ModelVersionSchemaProperty[] { + return collectProperties(schema, []); +} + +function getObjectShape(schema: z.ZodType): Record | undefined { + const shape = Reflect.get(schema, 'shape'); + if (typeof shape !== 'object' || shape === null || Array.isArray(shape)) { + return undefined; + } + const out: Record = {}; + for (const key of Object.keys(shape)) { + const v = Reflect.get(shape, key); + if (isZod(v)) { + out[key] = v; + } + } + return out; +} + +function collectProperties(schema: z.ZodType, path: string[]): ModelVersionSchemaProperty[] { + const { inner, isOptional, isNullable } = unwrapZodWrappers(schema); + const kind = defType(inner); + + if (kind === 'object') { + const shape = getObjectShape(inner); + if (!shape) { + return [{ path, type: formatTypeDescriptor(inner, isOptional, isNullable) }]; + } + const results: ModelVersionSchemaProperty[] = []; + for (const key of Object.keys(shape)) { + results.push(...collectProperties(shape[key], [...path, key])); + } + return results; + } + + if (kind === 'intersection') { + const intersectionDef = defOf(inner); + const left = asZodType(Reflect.get(intersectionDef, 'left')); + const right = asZodType(Reflect.get(intersectionDef, 'right')); + const results: ModelVersionSchemaProperty[] = []; + if (left) { + results.push(...collectProperties(left, path)); + } + if (right) { + results.push(...collectProperties(right, path)); + } + return results.length > 0 + ? results + : [{ path, type: formatTypeDescriptor(inner, isOptional, isNullable) }]; + } + + if (kind === 'lazy') { + const getter = Reflect.get(defOf(inner), 'getter'); + if (typeof getter === 'function') { + const resolved = asZodType(getter()); + if (resolved) { + return collectProperties(resolved, path); + } + } + return [{ path, type: formatTypeDescriptor(inner, isOptional, isNullable) }]; + } + + return [ + { + path, + type: formatTypeDescriptor(inner, isOptional, isNullable), + }, + ]; +} diff --git a/src/platform/packages/shared/kbn-zod/v4/index.ts b/src/platform/packages/shared/kbn-zod/v4/index.ts index 15bc5b72bc9a1..bbe9a23f6e069 100644 --- a/src/platform/packages/shared/kbn-zod/v4/index.ts +++ b/src/platform/packages/shared/kbn-zod/v4/index.ts @@ -9,4 +9,5 @@ export * from 'zod/v4'; export { isZod } from './util'; +export { getZodSchemaStructure } from './get_schema_structure'; export { lazySchema, setLazySchemaDisabled } from './lazy_schema'; diff --git a/tsconfig.base.json b/tsconfig.base.json index b11269e4dbfed..03d64574672e7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -321,6 +321,8 @@ "@kbn/config-mocks/*": ["./src/platform/packages/private/kbn-config-mocks/*"], "@kbn/config-schema": ["./src/platform/packages/shared/kbn-config-schema"], "@kbn/config-schema/*": ["./src/platform/packages/shared/kbn-config-schema/*"], + "@kbn/config-schema-helpers": ["./src/platform/packages/shared/kbn-config-schema-helpers"], + "@kbn/config-schema-helpers/*": ["./src/platform/packages/shared/kbn-config-schema-helpers/*"], "@kbn/connector-cli": ["./src/platform/packages/shared/kbn-connector-cli"], "@kbn/connector-cli/*": ["./src/platform/packages/shared/kbn-connector-cli/*"], "@kbn/connector-schemas": ["./src/platform/packages/shared/kbn-connector-schemas"], diff --git a/x-pack/platform/plugins/shared/cases/moon.yml b/x-pack/platform/plugins/shared/cases/moon.yml index 5ddc9a9668c3c..a27d89625a5e9 100644 --- a/x-pack/platform/plugins/shared/cases/moon.yml +++ b/x-pack/platform/plugins/shared/cases/moon.yml @@ -48,6 +48,7 @@ dependsOn: - '@kbn/std' - '@kbn/test-jest-helpers' - '@kbn/config-schema' + - '@kbn/config-schema-helpers' - '@kbn/task-manager-plugin' - '@kbn/usage-collection-plugin' - '@kbn/core-saved-objects-server' diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/versioning.test.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/versioning.test.ts index 9ea39ce48cc7d..763bfedc08880 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/versioning.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/versioning.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { expectConfigSchema } from '@kbn/config-schema-helpers'; import { coreMock } from '@kbn/core/server/mocks'; import { createCaseSavedObjectType } from './cases'; import { @@ -194,7 +195,8 @@ describe('caseSavedObjectType model version transformations', () => { }, }).attributes; - expect(() => createSchema!.validate(attributes)).not.toThrow(); + expectConfigSchema(createSchema); + expect(() => createSchema.validate(attributes)).not.toThrow(); }); }); }); diff --git a/x-pack/platform/plugins/shared/cases/tsconfig.json b/x-pack/platform/plugins/shared/cases/tsconfig.json index 127c7a10d263b..f13f72345210e 100644 --- a/x-pack/platform/plugins/shared/cases/tsconfig.json +++ b/x-pack/platform/plugins/shared/cases/tsconfig.json @@ -44,6 +44,7 @@ "@kbn/std", "@kbn/test-jest-helpers", "@kbn/config-schema", + "@kbn/config-schema-helpers", "@kbn/task-manager-plugin", "@kbn/usage-collection-plugin", "@kbn/core-saved-objects-server", diff --git a/x-pack/platform/plugins/shared/task_manager/moon.yml b/x-pack/platform/plugins/shared/task_manager/moon.yml index bc5b510104512..fc99d3bdc6ee9 100644 --- a/x-pack/platform/plugins/shared/task_manager/moon.yml +++ b/x-pack/platform/plugins/shared/task_manager/moon.yml @@ -22,6 +22,7 @@ dependsOn: - '@kbn/uiam-api-keys-provisioning-status' - '@kbn/usage-collection-plugin' - '@kbn/config-schema' + - '@kbn/config-schema-helpers' - '@kbn/config' - '@kbn/utility-types' - '@kbn/safer-lodash-set' diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.test.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.test.ts index d2592d0056590..a857d616e58ad 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.test.ts @@ -11,6 +11,7 @@ import type { SavedObjectsFullModelVersion, SavedObjectsModelVersion, } from '@kbn/core-saved-objects-server'; +import { expectConfigSchema } from '@kbn/config-schema-helpers'; test('allows valid duration', () => { expect(validateDuration('1s')).toBeUndefined(); @@ -31,6 +32,7 @@ test('returns error message for invalid duration', () => { test('allows any cost value up to 100 characters', () => { const taskSchema = getLatestModelVersion()?.schemas?.create; expect(taskSchema).toBeDefined(); + expectConfigSchema(taskSchema); const costs = [undefined, 'tiny', 'normal', 'large', 'extralarge', 'waaaaytoobig']; costs.forEach((cost) => { @@ -42,6 +44,7 @@ test('allows any cost value up to 100 characters', () => { test('throws error message for cost > 100 characters', () => { const taskSchema = getLatestModelVersion()?.schemas?.create; expect(taskSchema).toBeDefined(); + expectConfigSchema(taskSchema); const longCost = 'a'.repeat(101); const task = getTask({ cost: longCost }); diff --git a/x-pack/platform/plugins/shared/task_manager/tsconfig.json b/x-pack/platform/plugins/shared/task_manager/tsconfig.json index 9144f511f5902..e22dee1c4273b 100644 --- a/x-pack/platform/plugins/shared/task_manager/tsconfig.json +++ b/x-pack/platform/plugins/shared/task_manager/tsconfig.json @@ -17,6 +17,7 @@ "@kbn/uiam-api-keys-provisioning-status", "@kbn/usage-collection-plugin", "@kbn/config-schema", + "@kbn/config-schema-helpers", "@kbn/config", "@kbn/utility-types", "@kbn/safer-lodash-set", diff --git a/yarn.lock b/yarn.lock index f4265e0dc619e..f2cebfd7e3b79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5100,6 +5100,10 @@ version "0.0.0" uid "" +"@kbn/config-schema-helpers@link:src/platform/packages/shared/kbn-config-schema-helpers": + version "0.0.0" + uid "" + "@kbn/config-schema@link:src/platform/packages/shared/kbn-config-schema": version "0.0.0" uid ""