mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[SOR] Adds support for validation schema with models (#158527)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
6ea3f39b61
commit
fd068da3a4
6 changed files with 302 additions and 44 deletions
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
|
||||
import { SavedObjectsType } from '@kbn/core-saved-objects-server';
|
||||
import { type SavedObjectSanitizedDoc } from '@kbn/core-saved-objects-server';
|
||||
import { ValidationHelper } from './validation';
|
||||
import { typedef, typedef1, typedef2 } from './validation_fixtures';
|
||||
import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal';
|
||||
|
||||
const defaultVersion = '8.10.0';
|
||||
const modelVirtualVersion = '10.1.0';
|
||||
const typeA = 'my-typeA';
|
||||
const typeB = 'my-typeB';
|
||||
const typeC = 'my-typeC';
|
||||
|
||||
describe('Saved Objects type validation helper', () => {
|
||||
let helper: ValidationHelper;
|
||||
let logger: MockedLogger;
|
||||
let typeRegistry: SavedObjectTypeRegistry;
|
||||
|
||||
const createMockObject = (
|
||||
type: string,
|
||||
attr: Partial<SavedObjectSanitizedDoc>
|
||||
): SavedObjectSanitizedDoc => ({
|
||||
type,
|
||||
id: 'test-id',
|
||||
references: [],
|
||||
attributes: {},
|
||||
...attr,
|
||||
});
|
||||
const registerType = (name: string, parts: Partial<SavedObjectsType>) => {
|
||||
typeRegistry.registerType({
|
||||
name,
|
||||
hidden: false,
|
||||
namespaceType: 'single',
|
||||
mappings: { properties: {} },
|
||||
...parts,
|
||||
});
|
||||
};
|
||||
beforeEach(() => {
|
||||
logger = loggerMock.create();
|
||||
typeRegistry = new SavedObjectTypeRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('validation helper', () => {
|
||||
beforeEach(() => {
|
||||
registerType(typeA, typedef);
|
||||
registerType(typeB, typedef1);
|
||||
registerType(typeC, typedef2);
|
||||
});
|
||||
|
||||
it('should validate objects against stack versions', () => {
|
||||
helper = new ValidationHelper({
|
||||
logger,
|
||||
registry: typeRegistry,
|
||||
kibanaVersion: defaultVersion,
|
||||
});
|
||||
const data = createMockObject(typeA, { attributes: { foo: 'hi', count: 1 } });
|
||||
expect(() => helper.validateObjectForCreate(typeA, data)).not.toThrowError();
|
||||
});
|
||||
|
||||
it('should validate objects against model versions', () => {
|
||||
helper = new ValidationHelper({
|
||||
logger,
|
||||
registry: typeRegistry,
|
||||
kibanaVersion: modelVirtualVersion,
|
||||
});
|
||||
const data = createMockObject(typeB, { attributes: { foo: 'hi', count: 1 } });
|
||||
expect(() => helper.validateObjectForCreate(typeB, data)).not.toThrowError();
|
||||
});
|
||||
|
||||
it('should fail validation against invalid objects when version requested does not support a field', () => {
|
||||
helper = new ValidationHelper({
|
||||
logger,
|
||||
registry: typeRegistry,
|
||||
kibanaVersion: defaultVersion,
|
||||
});
|
||||
const validationError = new Error(
|
||||
'[attributes.count]: definition for this key is missing: Bad Request'
|
||||
);
|
||||
const data = createMockObject(typeC, { attributes: { foo: 'hi', count: 1 } });
|
||||
expect(() => helper.validateObjectForCreate(typeC, data)).toThrowError(validationError);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -9,7 +9,10 @@
|
|||
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server';
|
||||
import { SavedObjectsTypeValidator } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import {
|
||||
SavedObjectsTypeValidator,
|
||||
modelVersionToVirtualVersion,
|
||||
} from '@kbn/core-saved-objects-base-server-internal';
|
||||
import {
|
||||
SavedObjectsErrorHelpers,
|
||||
type SavedObjectSanitizedDoc,
|
||||
|
@ -91,7 +94,7 @@ export class ValidationHelper {
|
|||
}
|
||||
const validator = this.getTypeValidator(type);
|
||||
try {
|
||||
validator.validate(doc, this.kibanaVersion);
|
||||
validator.validate(doc);
|
||||
} catch (error) {
|
||||
throw SavedObjectsErrorHelpers.createBadRequestError(error.message);
|
||||
}
|
||||
|
@ -100,10 +103,30 @@ export class ValidationHelper {
|
|||
private getTypeValidator(type: string): SavedObjectsTypeValidator {
|
||||
if (!this.typeValidatorMap[type]) {
|
||||
const savedObjectType = this.registry.getType(type);
|
||||
|
||||
const stackVersionSchemas =
|
||||
typeof savedObjectType?.schemas === 'function'
|
||||
? savedObjectType.schemas()
|
||||
: savedObjectType?.schemas ?? {};
|
||||
|
||||
const modelVersionCreateSchemas =
|
||||
typeof savedObjectType?.modelVersions === 'function'
|
||||
? savedObjectType.modelVersions()
|
||||
: savedObjectType?.modelVersions ?? {};
|
||||
|
||||
const combinedSchemas = { ...stackVersionSchemas };
|
||||
Object.entries(modelVersionCreateSchemas).reduce((map, [key, modelVersion]) => {
|
||||
if (modelVersion.schemas?.create) {
|
||||
const virtualVersion = modelVersionToVirtualVersion(key);
|
||||
combinedSchemas[virtualVersion] = modelVersion.schemas!.create!;
|
||||
}
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
this.typeValidatorMap[type] = new SavedObjectsTypeValidator({
|
||||
logger: this.logger.get('type-validator'),
|
||||
type,
|
||||
validationMap: savedObjectType!.schemas ?? {},
|
||||
validationMap: combinedSchemas,
|
||||
defaultVersion: this.kibanaVersion,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { SavedObjectsType } from '@kbn/core-saved-objects-server';
|
||||
|
||||
export const typedef: Partial<SavedObjectsType> = {
|
||||
mappings: {
|
||||
properties: {
|
||||
foo: {
|
||||
type: 'keyword',
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
},
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
'8.9.0': schema.object({
|
||||
foo: schema.string(),
|
||||
}),
|
||||
'8.10.0': schema.object({
|
||||
foo: schema.string(),
|
||||
count: schema.number(),
|
||||
}),
|
||||
},
|
||||
switchToModelVersionAt: '8.10.0',
|
||||
};
|
||||
|
||||
export const typedef1: Partial<SavedObjectsType> = {
|
||||
mappings: {
|
||||
properties: {
|
||||
foo: {
|
||||
type: 'keyword',
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
},
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
'8.9.0': schema.object({
|
||||
foo: schema.string(),
|
||||
}),
|
||||
'8.10.0': schema.object({
|
||||
foo: schema.string(),
|
||||
count: schema.number(),
|
||||
}),
|
||||
},
|
||||
switchToModelVersionAt: '8.10.0',
|
||||
modelVersions: {
|
||||
'1': {
|
||||
changes: [
|
||||
{
|
||||
type: 'mappings_addition',
|
||||
addedMappings: {
|
||||
count: {
|
||||
properties: {
|
||||
count: {
|
||||
type: 'integer',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
schemas: {
|
||||
create: schema.object({
|
||||
foo: schema.string(),
|
||||
count: schema.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const typedef2: Partial<SavedObjectsType> = {
|
||||
mappings: {
|
||||
properties: {
|
||||
foo: {
|
||||
type: 'keyword',
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
},
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
'8.9.0': schema.object({
|
||||
foo: schema.string(),
|
||||
}),
|
||||
},
|
||||
switchToModelVersionAt: '8.10.0',
|
||||
modelVersions: {
|
||||
'1': {
|
||||
changes: [
|
||||
{
|
||||
type: 'mappings_addition',
|
||||
addedMappings: {
|
||||
count: {
|
||||
properties: {
|
||||
count: {
|
||||
type: 'integer',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
schemas: {
|
||||
create: schema.object({
|
||||
foo: schema.string(),
|
||||
count: schema.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -51,18 +51,18 @@ describe('Saved Objects type validator', () => {
|
|||
|
||||
it('should log when a validation fails', () => {
|
||||
const data = createMockObject({ attributes: { foo: false } });
|
||||
expect(() => validator.validate(data, '1.0.0')).toThrowError();
|
||||
expect(() => validator.validate(data)).toThrowError();
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should work when given valid values', () => {
|
||||
const data = createMockObject({ attributes: { foo: 'hi' } });
|
||||
expect(() => validator.validate(data, '1.0.0')).not.toThrowError();
|
||||
expect(() => validator.validate(data)).not.toThrowError();
|
||||
});
|
||||
|
||||
it('should throw an error when given invalid values', () => {
|
||||
const data = createMockObject({ attributes: { foo: false } });
|
||||
expect(() => validator.validate(data, '1.0.0')).toThrowErrorMatchingInlineSnapshot(
|
||||
expect(() => validator.validate(data)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[attributes.foo]: expected value of type [string] but got [boolean]"`
|
||||
);
|
||||
});
|
||||
|
@ -71,7 +71,7 @@ describe('Saved Objects type validator', () => {
|
|||
const data = createMockObject({ attributes: { foo: 'hi' } });
|
||||
// @ts-expect-error Intentionally malformed object
|
||||
data.updated_at = false;
|
||||
expect(() => validator.validate(data, '1.0.0')).toThrowErrorMatchingInlineSnapshot(
|
||||
expect(() => validator.validate(data)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[updated_at]: expected value of type [string] but got [boolean]"`
|
||||
);
|
||||
});
|
||||
|
@ -86,7 +86,7 @@ describe('Saved Objects type validator', () => {
|
|||
});
|
||||
|
||||
const data = createMockObject({ attributes: { foo: 'hi' } });
|
||||
expect(() => validator.validate(data, '1.0.0')).not.toThrowError();
|
||||
expect(() => validator.validate(data)).not.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -97,8 +97,8 @@ describe('Saved Objects type validator', () => {
|
|||
'2.7.0': createStubSpec(),
|
||||
'3.0.0': createStubSpec(),
|
||||
'3.5.0': createStubSpec(),
|
||||
'4.0.0': createStubSpec(),
|
||||
'4.3.0': createStubSpec(),
|
||||
// we're intentionally leaving out 10.1.0 to test model version selection
|
||||
'10.2.0': createStubSpec(),
|
||||
};
|
||||
validator = new SavedObjectsTypeValidator({ logger, type, validationMap, defaultVersion });
|
||||
});
|
||||
|
@ -118,51 +118,58 @@ describe('Saved Objects type validator', () => {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
it('should use the correct schema when specifying the version', () => {
|
||||
let data = createMockObject({ typeMigrationVersion: '2.2.0' });
|
||||
validator.validate(data, '3.2.0');
|
||||
it('should use the correct schema for documents with typeMigrationVersion', () => {
|
||||
const data = createMockObject({ typeMigrationVersion: '3.0.0' });
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('3.0.0');
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
data = createMockObject({ typeMigrationVersion: '3.5.0' });
|
||||
validator.validate(data, '4.5.0');
|
||||
expect(getCalledVersion()).toEqual('4.3.0');
|
||||
});
|
||||
|
||||
it('should use the correct schema for documents with typeMigrationVersion', () => {
|
||||
let data = createMockObject({ typeMigrationVersion: '3.2.0' });
|
||||
it('should use the correct schema for documents with typeMigrationVersion greater than default version', () => {
|
||||
const data = createMockObject({ typeMigrationVersion: '3.5.0' });
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('3.0.0');
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
data = createMockObject({ typeMigrationVersion: '3.5.0' });
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('3.5.0');
|
||||
});
|
||||
|
||||
it('should use the correct schema for documents with migrationVersion', () => {
|
||||
let data = createMockObject({
|
||||
const data = createMockObject({
|
||||
migrationVersion: {
|
||||
[type]: '3.0.0',
|
||||
},
|
||||
});
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('3.0.0');
|
||||
});
|
||||
|
||||
it('should use the correct schema for documents with migrationVersion higher than default', () => {
|
||||
const data = createMockObject({
|
||||
migrationVersion: {
|
||||
[type]: '4.6.0',
|
||||
},
|
||||
});
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('4.3.0');
|
||||
// 4.6.0 > 3.3.0 (default), is not a valid virtual model and there aren't migrations for the type in the default version
|
||||
expect(getCalledVersion()).toEqual('3.0.0');
|
||||
});
|
||||
|
||||
it("should use the correct schema for documents with virtualModelVersion that isn't registered", () => {
|
||||
let data = createMockObject({ typeMigrationVersion: '10.1.0' });
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('3.5.0');
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
data = createMockObject({
|
||||
migrationVersion: {
|
||||
[type]: '4.0.0',
|
||||
},
|
||||
});
|
||||
data = createMockObject({ typeMigrationVersion: '10.3.0' });
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('4.0.0');
|
||||
expect(getCalledVersion()).toEqual('10.2.0');
|
||||
});
|
||||
|
||||
it('should use the correct schema for documents without a version specified', () => {
|
||||
it('should use the correct schema for documents with virtualModelVersion that is registered', () => {
|
||||
const data = createMockObject({ typeMigrationVersion: '10.2.0' });
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('10.2.0');
|
||||
});
|
||||
|
||||
it('should use the correct schema for documents without a version', () => {
|
||||
const data = createMockObject({});
|
||||
validator.validate(data);
|
||||
expect(getCalledVersion()).toEqual('3.0.0');
|
||||
|
|
|
@ -13,6 +13,7 @@ import type {
|
|||
SavedObjectSanitizedDoc,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { createSavedObjectSanitizedDocSchema } from './schema';
|
||||
import { isVirtualModelVersion } from '../model_version';
|
||||
|
||||
/**
|
||||
* Helper class that takes a {@link SavedObjectsValidationMap} and runs validations for a
|
||||
|
@ -45,13 +46,16 @@ export class SavedObjectsTypeValidator {
|
|||
this.orderedVersions = Object.keys(this.validationMap).sort(Semver.compare);
|
||||
}
|
||||
|
||||
public validate(document: SavedObjectSanitizedDoc, version?: string): void {
|
||||
const docVersion =
|
||||
version ??
|
||||
document.typeMigrationVersion ??
|
||||
document.migrationVersion?.[document.type] ??
|
||||
this.defaultVersion;
|
||||
const schemaVersion = previousVersionWithSchema(this.orderedVersions, docVersion);
|
||||
public validate(document: SavedObjectSanitizedDoc): void {
|
||||
let usedVersion: string;
|
||||
const docVersion = document.typeMigrationVersion ?? document.migrationVersion?.[document.type];
|
||||
if (docVersion) {
|
||||
usedVersion = isVirtualModelVersion(docVersion) ? docVersion : this.defaultVersion;
|
||||
} else {
|
||||
usedVersion = this.defaultVersion;
|
||||
}
|
||||
const schemaVersion = previousVersionWithSchema(this.orderedVersions, usedVersion);
|
||||
|
||||
if (!schemaVersion || !this.validationMap[schemaVersion]) {
|
||||
return;
|
||||
}
|
||||
|
@ -62,7 +66,7 @@ export class SavedObjectsTypeValidator {
|
|||
validationSchema.validate(document);
|
||||
} catch (e) {
|
||||
this.log.warn(
|
||||
`Error validating object of type [${this.type}] against version [${docVersion}]`
|
||||
`Error validating object of type [${this.type}] against version [${usedVersion}]`
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import type { ObjectType } from '@kbn/config-schema';
|
||||
import type { SavedObjectsValidationSpec } from '../validation';
|
||||
|
||||
/**
|
||||
* The validation and conversion schemas associated with this model version.
|
||||
|
@ -29,6 +30,11 @@ export interface SavedObjectsModelVersionSchemaDefinitions {
|
|||
* See {@link SavedObjectModelVersionForwardCompatibilitySchema} for more info.
|
||||
*/
|
||||
forwardCompatibility?: SavedObjectModelVersionForwardCompatibilitySchema;
|
||||
/**
|
||||
* The schema applied when creating a document of the current version
|
||||
* Allows for validating properties using @kbn/config-schema validations
|
||||
*/
|
||||
create?: SavedObjectsValidationSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue