[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:
Christiane (Tina) Heiligers 2023-06-05 06:11:38 -07:00 committed by GitHub
parent 6ea3f39b61
commit fd068da3a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 302 additions and 44 deletions

View file

@ -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);
});
});
});

View file

@ -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,
});
}

View file

@ -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(),
}),
},
},
},
};

View file

@ -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');

View file

@ -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;
}

View file

@ -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;
}
/**