diff --git a/lib/collector.js b/lib/collector.js index 6e295e9..5c8c234 100644 --- a/lib/collector.js +++ b/lib/collector.js @@ -7,15 +7,17 @@ class Collector { count = 0; exact = true; timeout = 0; + schema = null; #fulfill = null; #reject = null; #timer = null; #cause = null; - constructor(keys, { exact = true, timeout = 0 } = {}) { + constructor(keys, { exact = true, timeout = 0, schema = null } = {}) { this.keys = keys; if (exact === false) this.exact = false; if (typeof timeout === 'number') this.#timeout(timeout); + if (schema) this.schema = schema; } #timeout(msec) { @@ -29,6 +31,12 @@ class Collector { this.#timer = setTimeout(handler, msec); } + #validate(data) { + if (!this.schema) return { valid: true, errors: [] }; + + return this.schema.check(data); + } + set(key, value) { if (this.done) return; const has = this.data[key] !== undefined; @@ -40,6 +48,12 @@ class Collector { if (!has && expected) this.count++; this.data[key] = value; if (this.count === this.keys.length) { + const { valid, errors } = this.#validate(this.data); + if (!valid) { + const problems = errors.join('; '); + this.fail(new Error(`Invalid keys type: ${problems}`)); + return; + } this.done = true; this.#timeout(0); if (this.#fulfill) this.#fulfill(this.data); diff --git a/metautil.d.ts b/metautil.d.ts index 3b21e01..952bbf6 100644 --- a/metautil.d.ts +++ b/metautil.d.ts @@ -1,5 +1,6 @@ import { IncomingMessage } from 'node:http'; import { ScryptOptions, X509Certificate } from 'node:crypto'; +import { Schema } from 'metaschema'; type Strings = Array; type Dictionary = Record; @@ -235,6 +236,7 @@ export function sizeToBytes(size: string): number; export interface CollectorOptions { exact?: boolean; timeout?: number; + schema?: Schema; } type AsyncFunction = (...args: Array) => Promise; @@ -245,6 +247,7 @@ export class Collector { keys: Array; count: number; exact: boolean; + schema: Schema | null; timeout: number; constructor(keys: Array, options?: CollectorOptions); set(key: string, value: unknown): void; diff --git a/package-lock.json b/package-lock.json index b56b316..d93e315 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.0", "eslint-plugin-prettier": "^5.0.0", + "metaschema": "^2.1.5", "metatests": "^0.8.2", "prettier": "^3.1.1", "typescript": "^5.3.3" @@ -2018,6 +2019,23 @@ "node": ">= 8" } }, + "node_modules/metaschema": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/metaschema/-/metaschema-2.1.5.tgz", + "integrity": "sha512-cnGegYOFCOKrdH2EbmeN/m65M0/N/M2qTWQKHFZQ/UC3S/TXoml3FN9aU4nmuJH3rV2KUID1LOZAgOTYPz98vg==", + "dev": true, + "dependencies": { + "metautil": "^3.10.0", + "metavm": "^1.2.5" + }, + "engines": { + "node": "16 || 18 || 19 || 20" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/tshemsedinov" + } + }, "node_modules/metatests": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/metatests/-/metatests-0.8.2.tgz", @@ -2037,6 +2055,32 @@ "node": ">=12.0.0" } }, + "node_modules/metautil": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/metautil/-/metautil-3.15.0.tgz", + "integrity": "sha512-kGG920X8R10X6He2VKPRQFtG45DcDCSYgZehUFoqES52Tv88VALJm6EplZGwDRK7IiBDWsWDeS8d2OcW6bVXeg==", + "dev": true, + "engines": { + "node": "18 || 20" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/tshemsedinov" + } + }, + "node_modules/metavm": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/metavm/-/metavm-1.4.1.tgz", + "integrity": "sha512-LmJv6j8gjAQkEL5elIPa3Hlv1a4jY1IPOOyidDzTwmO1ZL+mCSJjHvmzMtbDyzZPi5CW929kmBRJttvBFI4B+w==", + "dev": true, + "engines": { + "node": "18 || 20 || 21" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/tshemsedinov" + } + }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", diff --git a/package.json b/package.json index 1443b47..e00c115 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.0", "eslint-plugin-prettier": "^5.0.0", + "metaschema": "^2.1.5", "metatests": "^0.8.2", "prettier": "^3.1.1", "typescript": "^5.3.3" diff --git a/test/collector.js b/test/collector.js index 7121eed..dc65a5f 100644 --- a/test/collector.js +++ b/test/collector.js @@ -2,6 +2,7 @@ const { collect } = require('..'); const metatests = require('metatests'); +const { Schema } = require('metaschema'); metatests.test('Collector: keys', async (test) => { const expectedResult = { key1: 1, key2: 2 }; @@ -209,3 +210,105 @@ metatests.test('Collector: error in then chain', (test) => { }, ); }); + +metatests.test('Collector: collect with schema validation', async (test) => { + const schema = Schema.from({ + firstname: 'string', + lastname: 'string', + }); + const ac1 = collect(['firstname', 'lastname'], { schema }); + + ac1.set('firstname', undefined); + ac1.set('lastname', 1); + + const expectedResult1 = new Error( + 'Invalid keys type: Field "firstname" not of expected type: string; Field "lastname" not of expected type: string', + ); + + try { + await ac1; + } catch (error) { + test.strictSame(error.message, expectedResult1.message); + } + + const ac2 = collect(['firstname', 'lastname'], { + schema, + exact: false, + }); + + ac2.set('fullName', 'Michael Jordan'); + ac2.set('firstname', 'Michael'); + ac2.set('lastname', 'Jordan'); + + const expectedResult2 = new Error( + 'Invalid keys type: Field "fullName" is not expected', + ); + try { + await ac2; + } catch (error) { + test.strictSame(error.message, expectedResult2.message); + } + + test.end(); +}); + +metatests.test( + 'Collector: compose collect with schema validation', + async (test) => { + const schema = Schema.from({ + id: 'number', + profile: { + firstname: 'string', + lastname: 'string', + fullName: { type: 'string', required: false }, + age: 'number', + }, + }); + + const ac1 = collect(['id', 'profile'], { schema }); + const profile1 = collect(['firstname', 'lastname', 'fullName', 'age']); + + ac1.collect({ profile: profile1 }); + ac1.set('id', 1); + profile1.set('firstname', 'Michael'); + profile1.set('lastname', 'Jordan'); + profile1.set('fullName', undefined); + profile1.set('age', 60); + + const expectedResult1 = { + id: 1, + profile: { + firstname: 'Michael', + lastname: 'Jordan', + fullName: undefined, + age: 60, + }, + }; + + const result1 = await ac1; + + test.strictSame(result1, expectedResult1); + + const ac2 = collect(['id', 'profile'], { schema }); + const profile2 = collect(['firstname', 'lastname', 'fullName', 'age']); + + ac2.collect({ profile: profile2 }); + ac2.set('id', 'string'); + profile2.set('firstname', undefined); + profile2.set('lastname', 'Jordan'); + profile2.set('fullName', undefined); + profile2.set('age', 'string'); + + const expectedResult2 = new Error( + 'Invalid keys type: Field "id" not of expected type: number; Field "profile.firstname" not of expected type: string; Field "profile.age" not of expected type: number', + ); + + try { + await ac2; + } catch (error) { + test.strictSame(error.message, expectedResult2.message); + } + + test.end(); + }, +);