diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..72bbadf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# top-most EditorConfig file +root = true + +# Tab indentation +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +charset = utf-8 +insert_final_newline = true diff --git a/package-lock.json b/package-lock.json index 36296e4..01326c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,17 @@ "integrity": "sha512-xXD08vZsvpv4xptQXj1+ky22f7ZoKu5ZNI/4l+/BXG3X+XaeZsmaFbbTKuhSE3NjjvRuZFxFf9sQBMXIcZNFMQ==", "dev": true }, + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "ansi-colors": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", @@ -283,6 +294,16 @@ "strip-eof": "^1.0.0" } }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, "find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -467,6 +488,11 @@ "esprima": "^4.0.0" } }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, "lcid": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", @@ -774,6 +800,11 @@ "once": "^1.3.1" } }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -953,6 +984,14 @@ "integrity": "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==", "dev": true }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "requires": { + "punycode": "^2.1.0" + } + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/package.json b/package.json index 5133a7a..e76a0a8 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "cnabjs", - "version": "0.0.1", + "version": "0.0.2", "description": "A library for loading and working with CNAB (Cloud Native Application Bundle) manifests", - "main": "js/index.js", - "types": "js/index.d.ts", + "main": "js/ts/index.js", + "types": "js/ts/index.d.ts", "files": [ - "js/**/*" + "js/ts/**/*" ], "scripts": { "compile": "tsc -p ./", @@ -33,5 +33,8 @@ "ts-node": "^8.3.0", "tslint": "^5.9.1", "typescript": "^3.5.3" + }, + "dependencies": { + "ajv": "^6.10.2" } } diff --git a/test/test.ts b/test/test.ts deleted file mode 100644 index 3236f84..0000000 --- a/test/test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'mocha'; -import assert from 'assert'; - -describe('mathematics', () => { - it('should be consistent', () => { - assert.equal(1 + 1, 2, "call Bertrand Russell pronto"); - }); -}); diff --git a/test/validation.ts b/test/validation.ts new file mode 100644 index 0000000..2e14dc6 --- /dev/null +++ b/test/validation.ts @@ -0,0 +1,227 @@ +import 'mocha'; +import assert from 'assert'; + +import * as cnab from '../ts/index'; +import { Validator, Validity } from '../ts/index'; + +const TEST_BUNDLE: cnab.Bundle = { + name: 'test', + schemaVersion: 'v1', + version: '1.0.0', + invocationImages: [], + + definitions: { + simpleString: { type: 'string' }, + lengthyString: { type: 'string', minLength: 4, maxLength: 7 }, + simpleInt: { type: 'integer' }, + constrainedInt: { type: 'integer', minimum: 1, maximum: 100 }, + simpleFloat: { type: 'number' }, + simpleBool: { type: 'boolean' }, + }, + + parameters: { + simpleString: { definition: 'simpleString', destination: {} }, + lengthyString: { definition: 'lengthyString', destination: { } }, + simpleNum: { definition: 'simpleInt', destination: {} }, + constrainedNum: { definition: 'constrainedInt', destination: { } }, + simpleFloat: { definition: 'simpleFloat', destination: {} }, + simpleBool: { definition: 'simpleBool', destination: {} }, + noDef: { definition: 'doesnt have one', destination: { } }, + }, +}; + +const TEST_VALIDATOR = Validator.for(TEST_BUNDLE); + +function expectValid(validity: Validity) { + if (!validity.isValid) { + assert.fail(`should have been valid but '${validity.reason}'`); + } +} + +function expectInvalid(validity: Validity) { + if (validity.isValid) { + assert.fail(`should NOT have been valid`); + } +} + +describe('a string parameter', () => { + + it('should accept a string value', () => { + const validity = TEST_VALIDATOR.validate('simpleString', 'some text'); + expectValid(validity); + }); + + it('should not accept a numeric value', () => { + const validity = TEST_VALIDATOR.validate('simpleString', 123); + expectInvalid(validity); + }); + + it('should validate against constraints in the schema', () => { + // NOTE: purpose of this is not to exercise JSON Schema - we lean on ajv for that. + // It is solely to confirm that we are successfully passing the schema into ajv. + const validity6 = TEST_VALIDATOR.validate('lengthyString', '6chars'); + expectValid(validity6); + const validity3 = TEST_VALIDATOR.validate('lengthyString', '3ch'); + expectInvalid(validity3); + const validity12 = TEST_VALIDATOR.validate('lengthyString', '12characters'); + expectInvalid(validity12); + }); + + it('should accept a string value via the text API', () => { + const validity = TEST_VALIDATOR.validateText('simpleString', 'some text'); + expectValid(validity); + }); + +}); + +describe('an integer parameter', () => { + + it('should accept an integer value', () => { + const validity = TEST_VALIDATOR.validate('simpleNum', 123); + expectValid(validity); + }); + + it('should not accept a value with a fractional part', () => { + const validity = TEST_VALIDATOR.validate('simpleNum', 123.5); + expectInvalid(validity); + }); + + it('should not accept a string value', () => { + const validity = TEST_VALIDATOR.validate('simpleNum', '123'); + expectInvalid(validity); + }); + + it('should validate against constraints in the schema', () => { + // NOTE: purpose of this is not to exercise JSON Schema - we lean on ajv for that. + // It is solely to confirm that we are successfully passing the schema into ajv. + const validity70 = TEST_VALIDATOR.validate('constrainedNum', 70); + expectValid(validity70); + const validity0 = TEST_VALIDATOR.validate('constrainedNum', 0); + expectInvalid(validity0); + const validity150 = TEST_VALIDATOR.validate('constrainedNum', 150); + expectInvalid(validity150); + }); + + it('should accept a stringised number via the text API', () => { + const validity = TEST_VALIDATOR.validateText('simpleNum', '123'); + expectValid(validity); + }); + + it('should not accept a stringised non-number via the text API', () => { + const validity = TEST_VALIDATOR.validateText('simpleNum', 'xyz123'); + expectInvalid(validity); + }); + +}); + +describe('a number parameter', () => { + + it('should accept an integer value', () => { + const validity = TEST_VALIDATOR.validate('simpleFloat', 123); + expectValid(validity); + }); + + it('should accept a value with a fractional part', () => { + const validity = TEST_VALIDATOR.validate('simpleFloat', 123.5); + expectValid(validity); + }); + + it('should not accept a string value', () => { + const validity = TEST_VALIDATOR.validate('simpleFloat', '123'); + expectInvalid(validity); + }); + + it('should accept a stringised number via the text API', () => { + const validity = TEST_VALIDATOR.validateText('simpleFloat', '123'); + expectValid(validity); + }); + + it('should not accept a stringised non-number via the text API', () => { + const validity = TEST_VALIDATOR.validateText('simpleFloat', 'xyz123'); + expectInvalid(validity); + }); + +}); + +describe('a boolean parameter', () => { + + it('should accept true', () => { + const validity = TEST_VALIDATOR.validate('simpleBool', true); + expectValid(validity); + }); + + it('should accept false', () => { + const validity = TEST_VALIDATOR.validate('simpleBool', false); + expectValid(validity); + }); + + it('should not accept a string value', () => { + const validity = TEST_VALIDATOR.validate('simpleBool', 'true'); + expectInvalid(validity); + }); + + it('should accept a stringised boolean via the text API', () => { + const validityT = TEST_VALIDATOR.validateText('simpleBool', 'true'); + expectValid(validityT); + const validityF = TEST_VALIDATOR.validateText('simpleBool', 'false'); + expectValid(validityF); + }); + + it('should not accept a stringised non-boolean via the text API', () => { + const validity = TEST_VALIDATOR.validateText('simpleBool', 'FILE_NOT_FOUND'); + expectInvalid(validity); + }); + +}); + +describe('a parameter with no definition', () => { + + it('should fail validation', () => { + const validity = TEST_VALIDATOR.validate('noDef', 123); + expectInvalid(validity); + }); + +}); + +describe('an undefined parameter', () => { + + it('should fail validation', () => { + const validity = TEST_VALIDATOR.validate('there is no parameter with this name', 123); + expectInvalid(validity); + }); + +}); + +const PARAMETERLESS_BUNDLE: cnab.Bundle = { + name: 'test', + schemaVersion: 'v1', + version: '1.0.0', + invocationImages: [], + definitions: { + foo: { type: 'integer' } + } +}; + +const DEFINITIONLESS_BUNDLE: cnab.Bundle = { + name: 'test', + schemaVersion: 'v1', + version: '1.0.0', + invocationImages: [], + parameters: { + foo: { definition: 'foo', destination: {} } + } +}; + +describe('if the bundle has no...', () => { + + it('definitions, parameters should fail validation', () => { + const validity = Validator.for(DEFINITIONLESS_BUNDLE).validate('foo', 123); + expectInvalid(validity); + }); + + it('parameters, parameters should fail validation', () => { + const validity = Validator.for(PARAMETERLESS_BUNDLE).validate('foo', 123); + expectInvalid(validity); + }); + +}); diff --git a/ts/bundle-manifest.ts b/ts/bundle-manifest.ts index bb9a7b4..ac39198 100644 --- a/ts/bundle-manifest.ts +++ b/ts/bundle-manifest.ts @@ -128,6 +128,10 @@ export interface Definition { * The permitted values of the value. */ enum?: any[]; + /** + * Property bag to prevent object literal errors in TypeScript. + */ + [key: string]: any; } /** diff --git a/ts/index.ts b/ts/index.ts index 06de347..3f04584 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1 +1,2 @@ export * from './bundle-manifest'; +export * from './validation'; diff --git a/ts/utils/never.ts b/ts/utils/never.ts new file mode 100644 index 0000000..d6cae5f --- /dev/null +++ b/ts/utils/never.ts @@ -0,0 +1,3 @@ +export function cantHappen(n: never): never { + return n; +} diff --git a/ts/validation.ts b/ts/validation.ts new file mode 100644 index 0000000..53d02b1 --- /dev/null +++ b/ts/validation.ts @@ -0,0 +1,137 @@ +import { Bundle } from "./bundle-manifest"; +import * as ajv from 'ajv'; +import { cantHappen } from "./utils/never"; + +/** + * Indicates that a parameter value is valid according to its schema. + */ +export interface Valid { + /** + * Indicates that the parameter value is valid according to its schema. + */ + readonly isValid: true; +} + +/** + * Indicates that a parameter value is invalid according to its schema. + */ +export interface Invalid { + /** + * Indicates that a parameter value is invalid according to its schema. + */ + readonly isValid: false; + /** + * The reason the value is invalid. + */ + readonly reason: string; +} + +/** + * Indicates whether a parameter value is valid according to its schema. + */ +export type Validity = Valid | Invalid; + +/** + * Provides parameter value validation for a bundle. + */ +export interface BundleParameterValidator { + /** + * Determines whether a value is a valid value for a parameter. + * @param parameter The name of the parameter. + * @param value The value to validate. + * @returns Whether the value is valid for the parameter. + */ + validate(parameter: string, value: string | number | boolean): Validity; + /** + * Determines whether a text string represents a valid value for a parameter. + * This is useful when the value comes from a user interface such as a HTML + * input box. + * @param parameter The name of the parameter. + * @param valueText The string to validate. + * @returns Whether the string represents a valid value for the parameter. + */ + validateText(parameter: string, valueText: string): Validity; +} + +/** + * Provides parameter value validation for a bundle. + */ +export class Validator { + /** + * Creates a parameter validator for a CNAB bundle. + * @param bundle The bundle containing parameter declarations and definitions. + * @returns An object which can be used to perform parameter validation + * according to the schema defined in the bundle. + */ + static for(bundle: Bundle): BundleParameterValidator { + return new ValidatorImpl(bundle); + } +} + +class ValidatorImpl implements BundleParameterValidator { + + constructor(private readonly bundle: Bundle) { } + + validate(parameter: string, value: string | number | boolean): Validity { + const schema = this.parameterSchema(parameter); + if (!schema) { + return { isValid: false, reason: 'Bundle does not specify valid parameter values' }; // TODO: more precise error message + } + + const validator = ajv.default(); + const validate = validator.compile(schema); + const isValid = !!(validate(value)); // We are never async so we can just breeze past the PromiseLike return + if (isValid) { + return { isValid: true }; + } + + const reason = validate.errors!.map((err) => err.message).join(', '); + return { isValid: false, reason: reason }; + } + + validateText(parameter: string, valueText: string): Validity { + const schema = this.parameterSchema(parameter); + if (!schema) { + return { isValid: false, reason: 'Bundle does not specify valid parameter values' }; // TODO: more precise error message + } + + const targetType = schema.type || 'string'; + + switch (targetType) { + case 'string': + return this.validate(parameter, valueText); + case 'integer': + const intValue = Number.parseInt(valueText, 10); + if (isNaN(intValue)) { + return { isValid: false, reason: 'The value must be a whole number' }; + } + return this.validate(parameter, intValue); + case 'number': + const floatValue = Number.parseFloat(valueText); + if (isNaN(floatValue)) { + return { isValid: false, reason: 'The value must be a number' }; + } + return this.validate(parameter, floatValue); + case 'boolean': + const boolValue = valueText.toLowerCase() === 'true' ? true : (valueText.toLowerCase() === 'false' ? false : undefined); + if (boolValue === undefined) { + return { isValid: false, reason: 'The value must be either "true" or "false"' }; + } + return this.validate(parameter, boolValue); + } + + return cantHappen(targetType); + } + + private parameterSchema(parameter: string) { + if (!this.bundle.parameters || !this.bundle.definitions) { + return undefined; + } + const parameterInfo = this.bundle.parameters[parameter]; + if (!parameterInfo) { + return undefined; + } + const definition = this.bundle.definitions[parameterInfo.definition]; + return definition; + } +} diff --git a/tsconfig.json b/tsconfig.json index dab022f..39da4c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,10 @@ "esModuleInterop": true, "sourceMap": true, "outDir": "./js", - "rootDir": "./ts", + "rootDirs": [ + "./ts", + "./test" + ], "declaration": true, "strict": true, "alwaysStrict": true,