diff --git a/Meadowlark-js/packages/meadowlark-core/src/metaed/MetaEdValidation.ts b/Meadowlark-js/packages/meadowlark-core/src/metaed/MetaEdValidation.ts deleted file mode 100644 index 2aeb61a7..00000000 --- a/Meadowlark-js/packages/meadowlark-core/src/metaed/MetaEdValidation.ts +++ /dev/null @@ -1,186 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Licensed to the Ed-Fi Alliance under one or more agreements. -// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. -// See the LICENSE and NOTICES files in the project root for more information. - -import Ajv from 'ajv/dist/2020'; -import addFormatsTo from 'ajv-formats'; -import type { ErrorObject, ValidateFunction } from 'ajv'; -import { betterAjvErrors, ValidationError } from '@apideck/better-ajv-errors'; -import didYouMean from 'didyoumean2'; -import { MetaEdEnvironment, TopLevelEntity, NoTopLevelEntity } from '@edfi/metaed-core'; -import { getBooleanFromEnvironment } from '@edfi/meadowlark-utilities'; -import type { ResourceMatchResult } from '../model/ResourceMatchResult'; -import { getMetaEdModelForResourceName, getResourceNamesForProject } from './ResourceNameMapping'; -import type { FrontendQueryParameters } from '../handler/FrontendRequest'; - -/** - * Compiled ajv JSON Schema validator functions for the API resource - */ -export type ResourceSchemaValidators = { - insertValidator: ValidateFunction; - updateValidator: ValidateFunction; - queryValidator: ValidateFunction; -}; - -function initializeAjv(): Ajv { - const removeAdditional = getBooleanFromEnvironment('ALLOW_OVERPOSTING', false); - const coerceTypes = getBooleanFromEnvironment('ALLOW_TYPE_COERCION', false); - - const ajv = new Ajv({ allErrors: true, coerceTypes, removeAdditional }); - addFormatsTo(ajv); - - return ajv; -} - -let ajv; - -// simple cache implementation, see: https://rewind.io/blog/simple-caching-in-aws-lambda-functions/ -/** This is a cache mapping MetaEd model objects to compiled ajv JSON Schema validators for the API resource */ -const validatorCache: Map = new Map(); - -/** - * Returns the API resource JSON Schema validator functions for the given MetaEd model. Caches results. - */ -function getSchemaValidatorsFor(metaEdModel: TopLevelEntity): ResourceSchemaValidators { - const cachedValidators: ResourceSchemaValidators | undefined = validatorCache.get(metaEdModel); - if (cachedValidators != null) return cachedValidators; - ajv = initializeAjv(); - const resourceValidators: ResourceSchemaValidators = { - insertValidator: ajv.compile(metaEdModel.data.edfiApiSchema.jsonSchemaForInsert), - updateValidator: ajv.compile(metaEdModel.data.edfiApiSchema.jsonSchemaForUpdate), - queryValidator: ajv.compile({ - ...metaEdModel.data.edfiApiSchema.jsonSchemaForInsert, - // Need to relax the validation such that no fields are "required" - required: [], - }), - }; - validatorCache.set(metaEdModel, resourceValidators); - return resourceValidators; -} - -/** - * Function to delete all item from validatorCache for testing purposes. - */ -export function clearAllValidatorCache(): void { - validatorCache.clear(); -} -/** - * Creates a new empty ResourceMatchResult object - */ -export function newResourceMatchResult(): ResourceMatchResult { - return { - resourceName: '', - isDescriptor: false, - exact: false, - suggestion: false, - matchingMetaEdModel: NoTopLevelEntity, - }; -} - -/** - * Finds the MetaEd entity name that matches the endpoint of the API request, or provides a suggestion - * if no match is found. - */ -export function matchResourceNameToMetaEd( - resourceName: string, - metaEd: MetaEdEnvironment, - namespace: string, -): ResourceMatchResult { - const matchingMetaEdModel: TopLevelEntity | undefined = getMetaEdModelForResourceName(resourceName, metaEd, namespace); - if (matchingMetaEdModel != null) { - return { - ...newResourceMatchResult(), - resourceName, - isDescriptor: matchingMetaEdModel.type === 'descriptor', - exact: true, - matchingMetaEdModel, - }; - } - - const suggestion = didYouMean(resourceName, getResourceNamesForProject(metaEd, namespace)); - if (suggestion == null) return newResourceMatchResult(); - - const suggestedName = Array.isArray(suggestion) ? suggestion[0] : suggestion; - return { ...newResourceMatchResult(), resourceName: suggestedName, suggestion: true }; -} - -/** - * Validate the JSON body of the request against the Joi schema for the MetaEd entity corresponding - * to the API endpoint. - */ -export function validateEntityBodyAgainstSchema( - metaEdModel: TopLevelEntity, - body: object, - isUpdate: boolean = false, -): ValidationError[] | null { - const resourceValidators: ResourceSchemaValidators = getSchemaValidatorsFor(metaEdModel); - const validator: ValidateFunction = isUpdate ? resourceValidators.updateValidator : resourceValidators.insertValidator; - - const isValid: boolean = validator(body); - - if (isValid) return null; - - return betterAjvErrors({ - data: body, - schema: isUpdate - ? metaEdModel.data.edfiApiSchema.jsonSchemaForUpdate - : metaEdModel.data.edfiApiSchema.jsonSchemaForInsert, - errors: validator.errors, - basePath: '{requestBody}', - }); -} - -/** - * Validates that those queryParameters which are present actually belong in the MetaEd entity. - */ -export function validateQueryParametersAgainstSchema( - metaEdModel: TopLevelEntity, - queryParameters: FrontendQueryParameters, -): string[] { - const { queryValidator } = getSchemaValidatorsFor(metaEdModel); - - let errors: string[] = []; - - /** - * Number and boolean are come through as strings, which causes ajv to fail validation of parameters - * This loops through all query parameters and checks the type against the schema, if the type is numeric or boolean - * it will attempt convert the value. If the conversion is good, we set the value back and ajv will validate the schema - * If the value can't be converted (i.e. a word is provided for a numeric value, we leave it and let ajv invalidate - * the schema). - */ - Object.keys(queryParameters).forEach((keyValue) => { - const property = metaEdModel.data.edfiApiSchema.jsonSchemaForInsert.properties[keyValue]; - - if (property != null && property.type != null) { - if ((property && property?.type === 'integer') || property.type === 'number') { - const value = Number(queryParameters[keyValue]); - if (!Number.isNaN(value)) { - queryParameters[keyValue] = value; - } - } - if (property.type === 'boolean' && (queryParameters[keyValue] === 'true' || queryParameters[keyValue] === 'false')) { - queryParameters[keyValue] = Boolean(queryParameters[keyValue]); - } - } - }); - - const isValid: boolean = queryValidator(queryParameters); - - if (isValid) return []; - - if (queryValidator.errors) { - const ajvErrors = queryValidator.errors.map((error: ErrorObject) => { - // When a parameter name is invalid instancePath is null thus only - // shows the error message which looks like the following: ' must NOT have additional properties' - if (error.instancePath === '' && error.keyword === 'additionalProperties') { - return `${metaEdModel.metaEdName} does not include property '${error.params.additionalProperty}'`; - } - return `${error.instancePath} ${error.message}` ?? ''; - }); - - errors = [...errors, ...ajvErrors]; - } - - return errors; -} diff --git a/Meadowlark-js/packages/meadowlark-core/test/integration/metaed/MetaEdValidation.test.ts b/Meadowlark-js/packages/meadowlark-core/test/integration/metaed/MetaEdValidation.test.ts.ignore similarity index 100% rename from Meadowlark-js/packages/meadowlark-core/test/integration/metaed/MetaEdValidation.test.ts rename to Meadowlark-js/packages/meadowlark-core/test/integration/metaed/MetaEdValidation.test.ts.ignore diff --git a/Meadowlark-js/packages/meadowlark-core/test/metaed/MetaEdValidation.test.ts b/Meadowlark-js/packages/meadowlark-core/test/validation/DocumentValidator.test.ts.ignore similarity index 74% rename from Meadowlark-js/packages/meadowlark-core/test/metaed/MetaEdValidation.test.ts rename to Meadowlark-js/packages/meadowlark-core/test/validation/DocumentValidator.test.ts.ignore index 3bc7a824..f2d4198d 100644 --- a/Meadowlark-js/packages/meadowlark-core/test/metaed/MetaEdValidation.test.ts +++ b/Meadowlark-js/packages/meadowlark-core/test/validation/DocumentValidator.test.ts.ignore @@ -5,102 +5,70 @@ import { ValidationError } from '@apideck/better-ajv-errors'; import { Config, Environment, getBooleanFromEnvironment } from '@edfi/meadowlark-utilities'; -import { newTopLevelEntity, newEntityProperty, TopLevelEntity } from '@edfi/metaed-core'; -import { validateEntityBodyAgainstSchema, validateQueryParametersAgainstSchema } from '../../src/metaed/MetaEdValidation'; +import { ResourceSchema, newResourceSchema } from '../../src/model/api-schema/ResourceSchema'; const originalGetBooleanFromEnvironment = getBooleanFromEnvironment; -const createModel = (): TopLevelEntity => ({ - ...newTopLevelEntity(), - metaEdName: 'Student', - properties: [ - { ...newEntityProperty(), metaEdName: 'uniqueId', isPartOfIdentity: true }, - { ...newEntityProperty(), metaEdName: 'someBooleanParameter', isPartOfIdentity: false }, - { ...newEntityProperty(), metaEdName: 'someIntegerParameter', isPartOfIdentity: false }, - { ...newEntityProperty(), metaEdName: 'someDecimalParameter', isPartOfIdentity: false }, - ], - data: { - edfiApiSchema: { - jsonSchemaForInsert: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - additionalProperties: false, +const resourceSchema: ResourceSchema = { + ...newResourceSchema(), + jsonSchemaForInsert: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + description: 'doc', + properties: { + uniqueId: { description: 'doc', - properties: { - uniqueId: { - description: 'doc', - maxLength: 30, - type: 'string', - }, - someBooleanParameter: { - description: 'doc', - type: 'boolean', - }, - someIntegerParameter: { - description: 'doc', - type: 'integer', - }, - someDecimalParameter: { - description: 'doc', - type: 'number', - }, - }, - required: ['uniqueId'], - title: 'EdFi.Student', - type: 'object', + maxLength: 30, + type: 'string', }, - jsonSchemaForUpdate: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - additionalProperties: false, + someBooleanParameter: { description: 'doc', - properties: { - id: { - description: 'The item id', - type: 'string', - }, - uniqueId: { - description: 'doc', - maxLength: 30, - type: 'string', - }, - someBooleanParameter: { - description: 'doc', - type: 'boolean', - }, - someIntegerParameter: { - description: 'doc', - type: 'integer', - }, - someDecimalParameter: { - description: 'doc', - type: 'number', - }, - }, - required: ['id', 'uniqueId'], - title: 'EdFi.Student', - type: 'object', + type: 'boolean', + }, + someIntegerParameter: { + description: 'doc', + type: 'integer', + }, + someDecimalParameter: { + description: 'doc', + type: 'number', }, }, + required: ['uniqueId'], + title: 'EdFi.Student', + type: 'object', }, -}); - -describe('given query parameters have no properties', () => { - it('should not return an error', () => { - const queryParameters = {}; - - const validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); - - expect(validationResult).toHaveLength(0); - }); -}); - -describe('given query parameters have a valid property', () => { - it('should not return an error', () => { - const queryParameters = { uniqueId: 'a' }; - - const validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); - - expect(validationResult).toHaveLength(0); - }); -}); + jsonSchemaForUpdate: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + description: 'doc', + properties: { + id: { + description: 'The item id', + type: 'string', + }, + uniqueId: { + description: 'doc', + maxLength: 30, + type: 'string', + }, + someBooleanParameter: { + description: 'doc', + type: 'boolean', + }, + someIntegerParameter: { + description: 'doc', + type: 'integer', + }, + someDecimalParameter: { + description: 'doc', + type: 'number', + }, + }, + required: ['id', 'uniqueId'], + title: 'EdFi.Student', + type: 'object', + }, +}; describe('given query parameters with allow overposting is false have two invalid properties and a valid one', () => { let validationResult: string[]; @@ -114,7 +82,7 @@ describe('given query parameters with allow overposting is false have two invali } return originalGetBooleanFromEnvironment(key, false); }); - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); afterAll(() => { @@ -146,7 +114,7 @@ describe('given query parameters with allow overposting is true have two extrane } return originalGetBooleanFromEnvironment(key, false); }); - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); afterAll(() => { jest.restoreAllMocks(); @@ -163,7 +131,7 @@ describe('given a boolean query parameter value of true', () => { beforeAll(() => { const queryParameters = { someBooleanParameter: 'true' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have no errors', () => { @@ -177,7 +145,7 @@ describe('given a boolean query parameter value of false', () => { beforeAll(() => { const queryParameters = { someBooleanParameter: 'false' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have no errors', () => { @@ -191,7 +159,7 @@ describe('given a non boolean query parameter value for a boolean query paramete beforeAll(() => { const queryParameters = { someBooleanParameter: 'yes' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have one error', () => { @@ -213,7 +181,7 @@ describe('given a valid integer query parameter value', () => { beforeAll(() => { const queryParameters = { someIntegerParameter: '100' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have no errors', () => { @@ -227,7 +195,7 @@ describe('given a decimal value for an integer query parameter', () => { beforeAll(() => { const queryParameters = { someIntegerParameter: '100.10' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have one error', () => { @@ -249,7 +217,7 @@ describe('given a bad integer query parameter value', () => { beforeAll(() => { const queryParameters = { someIntegerParameter: 'adsf' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have one error', () => { @@ -271,7 +239,7 @@ describe('given a valid decimal query parameter value', () => { beforeAll(() => { const queryParameters = { someDecimalParameter: '100.10' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have no errors', () => { @@ -285,7 +253,7 @@ describe('given an integer value for a decimal query parameter value', () => { beforeAll(() => { const queryParameters = { someDecimalParameter: '100' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have no errors', () => { @@ -299,7 +267,7 @@ describe('given a bad decimal query parameter value', () => { beforeAll(() => { const queryParameters = { someDecimalParameter: 'adsf' }; - validationResult = validateQueryParametersAgainstSchema(createModel(), queryParameters); + validationResult = validateQueryParametersAgainstSchema(createResourceSchema(), queryParameters); }); it('should have one error', () => { @@ -327,7 +295,7 @@ describe('given body insert with allow overposting is false have two invalid pro } return originalGetBooleanFromEnvironment(key, false); }); - validationResult = validateEntityBodyAgainstSchema(createModel(), bodyParameters); + validationResult = validateEntityBodyAgainstSchema(createResourceSchema(), bodyParameters); }); afterAll(() => { @@ -372,7 +340,7 @@ describe('given body update with allow overposting is false have two invalid pro } return originalGetBooleanFromEnvironment(key, false); }); - validationResult = validateEntityBodyAgainstSchema(createModel(), bodyParameters); + validationResult = validateEntityBodyAgainstSchema(createResourceSchema(), bodyParameters); }); afterAll(() => { @@ -417,7 +385,7 @@ describe('given body insert with allow overposting is true have two invalid prop } return originalGetBooleanFromEnvironment(key, false); }); - validationResult = validateEntityBodyAgainstSchema(createModel(), bodyParameters); + validationResult = validateEntityBodyAgainstSchema(createResourceSchema(), bodyParameters); }); afterAll(() => { @@ -441,7 +409,7 @@ describe('given body update with allow overposting is true have two invalid prop } return originalGetBooleanFromEnvironment(key, false); }); - validationResult = validateEntityBodyAgainstSchema(createModel(), bodyParameters); + validationResult = validateEntityBodyAgainstSchema(createResourceSchema(), bodyParameters); }); afterAll(() => {