├── .editorconfig
├── .eslintrc.js
├── .gitattributes
├── .gitignore
├── .verb.md
├── README.md
├── examples
├── resolve.ts
└── validate.ts
├── index.ts
├── package.json
├── prettier.config.js
├── src
├── index.ts
├── merge.ts
├── resolve.ts
├── schema-props.ts
├── types.ts
└── utils.ts
├── test
├── merge.test.ts
├── resolve-get-value.test.ts
└── resolve.test.ts
├── tsconfig.json
└── tsup.config.ts
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | end_of_line = lf
6 | charset = utf-8
7 | indent_size = 2
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@types/eslint').Linter.BaseConfig}
3 | */
4 | module.exports = {
5 | root: true,
6 |
7 | extends: [
8 | 'eslint:recommended'
9 | ],
10 |
11 | env: {
12 | commonjs: true,
13 | es2023: true,
14 | mocha: true,
15 | node: true
16 | },
17 |
18 | plugins: ['@typescript-eslint'],
19 | parser: '@typescript-eslint/parser',
20 | parserOptions: {
21 | ecmaVersion: 'latest',
22 | sourceType: 'module',
23 | requireConfigFile: false
24 | },
25 |
26 | rules: {
27 | 'accessor-pairs': 2,
28 | 'array-bracket-newline': [1, 'consistent'],
29 | 'array-bracket-spacing': [1, 'never'],
30 | 'array-callback-return': 1,
31 | 'array-element-newline': [1, 'consistent'],
32 | 'arrow-body-style': 0,
33 | 'arrow-parens': [1, 'as-needed'],
34 | 'arrow-spacing': [1, { before: true, after: true }],
35 | 'block-scoped-var': 1,
36 | 'block-spacing': [1, 'always'],
37 | 'brace-style': [1, '1tbs', { allowSingleLine: true }],
38 | 'callback-return': 0,
39 | 'camelcase': [0, { allow: [] }],
40 | 'capitalized-comments': 0,
41 | 'class-methods-use-this': 0,
42 | 'comma-dangle': [1, 'never'],
43 | 'comma-spacing': [1, { before: false, after: true }],
44 | 'comma-style': [1, 'last'],
45 | 'complexity': 1,
46 | 'computed-property-spacing': 1,
47 | 'consistent-return': 0,
48 | 'consistent-this': 1,
49 | 'constructor-super': 2,
50 | 'curly': [1, 'multi-line', 'consistent'],
51 | 'default-case': 1,
52 | 'dot-location': [1, 'property'],
53 | 'dot-notation': 1,
54 | 'eol-last': 1,
55 | 'eqeqeq': [1, 'allow-null'],
56 | 'for-direction': 1,
57 | 'func-call-spacing': 2,
58 | 'generator-star-spacing': [1, { before: true, after: true }],
59 | 'handle-callback-err': [2, '^(err|error)$'],
60 | 'indent': [1, 2, { SwitchCase: 1 }],
61 | 'key-spacing': [1, { beforeColon: false, afterColon: true }],
62 | 'keyword-spacing': [1, { before: true, after: true }],
63 | 'linebreak-style': [1, 'unix'],
64 | 'new-cap': [1, { newIsCap: true, capIsNew: false }],
65 | 'new-parens': 2,
66 | 'no-alert': 1,
67 | 'no-array-constructor': 1,
68 | 'no-async-promise-executor': 1,
69 | 'no-await-in-loop': 0,
70 | 'no-caller': 2,
71 | 'no-case-declarations': 1,
72 | 'no-class-assign': 2,
73 | 'no-cond-assign': 2,
74 | 'no-console': 0,
75 | 'no-const-assign': 2,
76 | 'no-constant-condition': [1, { checkLoops: false }],
77 | 'no-control-regex': 2,
78 | 'no-debugger': 2,
79 | 'no-delete-var': 2,
80 | 'no-dupe-args': 2,
81 | 'no-dupe-class-members': 2,
82 | 'no-dupe-keys': 2,
83 | 'no-duplicate-case': 2,
84 | 'no-duplicate-imports': 0,
85 | 'no-else-return': 0,
86 | 'no-empty-character-class': 2,
87 | 'no-empty-function': 0,
88 | 'no-empty-pattern': 0,
89 | 'no-empty': [1, { allowEmptyCatch: true }],
90 | 'no-eval': 0,
91 | 'no-ex-assign': 2,
92 | 'no-extend-native': 2,
93 | 'no-extra-bind': 1,
94 | 'no-extra-boolean-cast': 1,
95 | 'no-extra-label': 1,
96 | 'no-extra-parens': [1, 'all', { conditionalAssign: false, returnAssign: false, nestedBinaryExpressions: false, ignoreJSX: 'multi-line', enforceForArrowConditionals: false }],
97 | 'no-extra-semi': 1,
98 | 'no-fallthrough': 2,
99 | 'no-floating-decimal': 2,
100 | 'no-func-assign': 2,
101 | 'no-global-assign': 2,
102 | 'no-implicit-coercion': 2,
103 | 'no-implicit-globals': 1,
104 | 'no-implied-eval': 2,
105 | 'no-inner-declarations': [1, 'functions'],
106 | 'no-invalid-regexp': 2,
107 | 'no-invalid-this': 1,
108 | 'no-irregular-whitespace': 2,
109 | 'no-iterator': 2,
110 | 'no-label-var': 2,
111 | 'no-labels': 2,
112 | 'no-lone-blocks': 2,
113 | 'no-lonely-if': 2,
114 | 'no-loop-func': 1,
115 | 'no-mixed-requires': 1,
116 | 'no-mixed-spaces-and-tabs': 2,
117 | 'no-multi-assign': 0,
118 | 'no-multi-spaces': 1,
119 | 'no-multi-str': 2,
120 | 'no-multiple-empty-lines': [1, { max: 1 }],
121 | 'no-native-reassign': 2,
122 | 'no-negated-condition': 0,
123 | 'no-negated-in-lhs': 2,
124 | 'no-new-func': 2,
125 | 'no-new-object': 2,
126 | 'no-new-require': 2,
127 | 'no-new-symbol': 1,
128 | 'no-new-wrappers': 2,
129 | 'no-new': 1,
130 | 'no-obj-calls': 2,
131 | 'no-octal-escape': 2,
132 | 'no-octal': 2,
133 | 'no-path-concat': 1,
134 | 'no-proto': 2,
135 | 'no-prototype-builtins': 0,
136 | 'no-redeclare': 2,
137 | 'no-regex-spaces': 2,
138 | 'no-restricted-globals': 2,
139 | 'no-return-assign': 1,
140 | 'no-return-await': 2,
141 | 'no-script-url': 1,
142 | 'no-self-assign': 1,
143 | 'no-self-compare': 1,
144 | 'no-sequences': 2,
145 | 'no-shadow-restricted-names': 2,
146 | 'no-shadow': 0,
147 | 'no-spaced-func': 2,
148 | 'no-sparse-arrays': 2,
149 | 'no-template-curly-in-string': 0,
150 | 'no-this-before-super': 2,
151 | 'no-throw-literal': 2,
152 | 'no-trailing-spaces': 1,
153 | 'no-undef-init': 2,
154 | 'no-undef': 2,
155 | 'no-unexpected-multiline': 2,
156 | 'no-unneeded-ternary': [1, { defaultAssignment: false }],
157 | 'no-unreachable-loop': 1,
158 | 'no-unreachable': 2,
159 | 'no-unsafe-assignment': 0,
160 | 'no-unsafe-call': 0,
161 | 'no-unsafe-finally': 2,
162 | 'no-unsafe-member-access': 0,
163 | 'no-unsafe-negation': 2,
164 | 'no-unsafe-optional-chaining': 0,
165 | 'no-unsafe-return': 0,
166 | 'no-unused-expressions': 2,
167 | 'no-unused-vars': [1, { vars: 'all', args: 'after-used' }],
168 | 'no-use-before-define': 0,
169 | 'no-useless-call': 2,
170 | 'no-useless-catch': 0,
171 | 'no-useless-escape': 0,
172 | 'no-useless-rename': 1,
173 | 'no-useless-return': 1,
174 | 'no-var': 1,
175 | 'no-void': 1,
176 | 'no-warning-comments': 0,
177 | 'no-with': 2,
178 | 'object-curly-spacing': [1, 'always', { objectsInObjects: true }],
179 | 'object-shorthand': 1,
180 | 'one-var': [1, { initialized: 'never' }],
181 | 'operator-linebreak': [0, 'after', { overrides: { '?': 'before', ':': 'before' } }],
182 | 'padded-blocks': [1, { switches: 'never' }],
183 | 'prefer-const': [1, { destructuring: 'all', ignoreReadBeforeAssign: false }],
184 | 'prefer-promise-reject-errors': 1,
185 | 'quotes': [1, 'single', 'avoid-escape'],
186 | 'radix': 2,
187 | 'rest-spread-spacing': 1,
188 | 'semi-spacing': [1, { before: false, after: true }],
189 | 'semi-style': 1,
190 | 'semi': [1, 'always'],
191 | 'space-before-blocks': [1, 'always'],
192 | 'space-before-function-paren': [1, { anonymous: 'never', named: 'never', asyncArrow: 'always' }],
193 | 'space-in-parens': [1, 'never'],
194 | 'space-infix-ops': 1,
195 | 'space-unary-ops': [1, { words: true, nonwords: false }],
196 | 'spaced-comment': [0, 'always', { markers: ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ','] }],
197 | 'strict': 2,
198 | 'switch-colon-spacing': 1,
199 | 'symbol-description': 1,
200 | 'template-curly-spacing': [2, 'never'],
201 | 'template-tag-spacing': [2, 'never'],
202 | 'unicode-bom': 1,
203 | 'use-isnan': 2,
204 | 'valid-jsdoc': 1,
205 | 'valid-typeof': 2,
206 | 'wrap-iife': [1, 'any'],
207 | 'yoda': [1, 'never'],
208 |
209 | // TypeScript
210 | '@typescript-eslint/consistent-type-imports': 1,
211 | '@typescript-eslint/no-unused-vars': [1, { vars: 'all', args: 'after-used', argsIgnorePattern: '^_' }]
212 | },
213 |
214 | ignorePatterns: [
215 | '.cache',
216 | '.config',
217 | '.vscode',
218 | '.git',
219 | '**/node_modules/**',
220 | 'build',
221 | 'dist',
222 | // 'tmp',
223 | 'temp'
224 | ]
225 | };
226 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Enforce Unix newlines
2 | * text eol=lf
3 |
4 | # binaries
5 | *.ai binary
6 | *.psd binary
7 | *.jpg binary
8 | *.gif binary
9 | *.png binary
10 | *.jpeg binary
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # always ignore files
2 | *.sublime-*
3 | *.code-*
4 | *.log
5 | .DS_Store
6 | .env
7 | .env.*
8 |
9 | # always ignore dirs
10 | temp
11 | tmp
12 | vendor
13 |
14 | # test related, or directories generated by tests
15 | test/actual
16 | actual
17 | coverage
18 | .nyc*
19 |
20 | # package managers
21 | node_modules
22 | package-lock.json
23 | yarn.lock
24 | *-lock.*
25 |
26 | # misc
27 | _gh_pages
28 | _draft
29 | _drafts
30 | _inbox
31 | bower_components
32 | vendor
33 | temp
34 | tmp
35 |
36 | # AI
37 |
38 | *.generated.*
39 | *.updated.*
40 | .chat
41 | .smith
42 | dist
43 | todo.md
44 |
--------------------------------------------------------------------------------
/.verb.md:
--------------------------------------------------------------------------------
1 | ## What is this?
2 |
3 | A JSON Schema resolver and validator that transforms and verifies data based on a provided JSON Schema. It combines value resolution (providing defaults, handling conditionals, and managing complex compositions) with strict validation (enforcing types, formats, and constraints) to ensure data consistency and correctness.
4 |
5 | **Why another JSON Schema library?**
6 |
7 | This library focuses on resolution of data, versus validation only. You can use any validation library, then use this one to resolve values.
8 |
9 | Note that this library is not a full JSON Schema validator and _does not resolve $refs_, but rather a _value resolver_ that can be used in conjunction with a validator to provide a more complete solution.
10 |
11 | ## Usage and Examples
12 |
13 | ```js
14 | import { resolveValues } from '{%= name %}';
15 |
16 | const schema = {
17 | type: 'object',
18 | properties: {
19 | username: {
20 | type: 'string',
21 | default: 'jonschlinkert'
22 | },
23 | company: {
24 | type: 'string'
25 | }
26 | }
27 | };
28 |
29 | const data = { company: 'Sellside' };
30 | const result = await resolveValues(schema, data);
31 | console.log(result.value); // { username: 'jonschlinkert', company: 'Sellside' }
32 | ```
33 |
34 | **Conditional Schema Resolution**
35 |
36 | ```ts
37 | const schema = {
38 | type: 'object',
39 | properties: {
40 | userType: { type: 'string' }
41 | },
42 | if: {
43 | properties: { userType: { const: 'business' } }
44 | },
45 | then: {
46 | properties: {
47 | taxId: { type: 'string', default: 'REQUIRED' },
48 | employees: { type: 'number', default: 0 }
49 | }
50 | },
51 | else: {
52 | properties: {
53 | personalId: { type: 'string', default: 'REQUIRED' }
54 | }
55 | }
56 | };
57 |
58 | const data = { userType: 'business' };
59 | const result = await resolveValues(schema, data);
60 | console.log(result.value);
61 | // {
62 | // userType: 'business',
63 | // taxId: 'REQUIRED',
64 | // employees: 0
65 | // }
66 | ```
67 |
68 | **Composition with allOf**
69 |
70 | ```ts
71 | const schema = {
72 | type: 'object',
73 | allOf: [
74 | {
75 | properties: {
76 | name: { type: 'string', default: 'Unnamed' }
77 | }
78 | },
79 | {
80 | properties: {
81 | age: { type: 'number', default: 0 }
82 | }
83 | }
84 | ]
85 | };
86 |
87 | const data = {};
88 | const result = await resolveValues(schema, data);
89 | console.log(result.value); // { name: 'Unnamed', age: 0 }
90 | ```
91 |
92 | **Pattern Properties**
93 |
94 | ```ts
95 | const schema = {
96 | type: 'object',
97 | patternProperties: {
98 | '^field\\d+$': {
99 | type: 'string',
100 | default: 'empty'
101 | }
102 | }
103 | };
104 |
105 | const data = {
106 | field1: undefined,
107 | field2: undefined,
108 | otherField: undefined
109 | };
110 |
111 | const result = await resolveValues(schema, data);
112 | console.log(result.value);
113 | // {
114 | // field1: 'empty',
115 | // field2: 'empty',
116 | // otherField: undefined
117 | // }
118 | ```
119 |
120 | **Dependent Schemas**
121 |
122 | ```ts
123 | const schema = {
124 | type: 'object',
125 | properties: {
126 | creditCard: { type: 'string' }
127 | },
128 | dependentSchemas: {
129 | creditCard: {
130 | properties: {
131 | billingAddress: { type: 'string', default: 'REQUIRED' },
132 | securityCode: { type: 'string', default: 'REQUIRED' }
133 | }
134 | }
135 | }
136 | };
137 |
138 | const data = { creditCard: '1234-5678-9012-3456' };
139 | const result = await resolveValues(schema, data);
140 | console.log(result.value);
141 | // {
142 | // creditCard: '1234-5678-9012-3456',
143 | // billingAddress: 'REQUIRED',
144 | // securityCode: 'REQUIRED'
145 | // }
146 | ```
147 |
148 | **Array Items Resolution**
149 |
150 | ```ts
151 | const schema = {
152 | type: 'array',
153 | items: {
154 | type: 'object',
155 | properties: {
156 | id: { type: 'number' },
157 | status: { type: 'string', default: 'pending' }
158 | }
159 | }
160 | };
161 |
162 | const data = [
163 | { id: 1 },
164 | { id: 2 },
165 | { id: 3 }
166 | ];
167 | const result = await resolveValues(schema, data);
168 | console.log(result.value);
169 | // [
170 | // { id: 1, status: 'pending' },
171 | // { id: 2, status: 'pending' },
172 | // { id: 3, status: 'pending' }
173 | // ]
174 | ```
175 |
176 | **OneOf with Type Validation**
177 |
178 | ```ts
179 | const schema = {
180 | type: 'object',
181 | properties: {
182 | value: {
183 | oneOf: [
184 | { type: 'number' },
185 | { type: 'string', pattern: '^\\d+$' }
186 | ],
187 | default: 0
188 | }
189 | }
190 | };
191 |
192 | const data = { value: '123' };
193 | const result = await resolveValues(schema, data);
194 | console.log(result.value);
195 | // { value: '123' } // Validates as it matches the string pattern
196 |
197 | const invalidData = { value: 'abc' };
198 | const invalidResult = await resolveValues(schema, invalidData);
199 | if (!invalidResult.ok) {
200 | console.log('Validation failed:', invalidResult.errors);
201 | } else {
202 | console.log(invalidResult.value);
203 | // { value: 0 } // Falls back to default as it matches neither schema
204 | }
205 | ```
206 |
207 | **Additional Properties with Schema**
208 |
209 | ```ts
210 | const schema = {
211 | type: 'object',
212 | properties: {
213 | name: { type: 'string' }
214 | },
215 | additionalProperties: {
216 | type: 'string',
217 | default: 'additional'
218 | }
219 | };
220 |
221 | const data = {
222 | name: 'John',
223 | customField1: undefined,
224 | customField2: undefined
225 | };
226 | const result = await resolveValues(schema, data);
227 | console.log(result.value);
228 | // {
229 | // name: 'John',
230 | // customField1: 'additional',
231 | // customField2: 'additional'
232 | // }
233 | ```
234 |
235 | ## Example Validation
236 |
237 | ```ts
238 | import util from 'node:util';
239 | import { resolveValues } from '{%= name %}';
240 |
241 | const inspect = (obj: any) => util.inspect(obj, { depth: null, colors: true });
242 |
243 | async function runExample() {
244 | // Example 1: Basic type validation
245 | console.log('\n=== Example 1: Number type validation ===');
246 | const numberSchem = {
247 | type: 'number',
248 | minimum: 0,
249 | maximum: 100
250 | };
251 |
252 | console.log('Testing with string input:');
253 | let result = await resolveValues(numberSchema, 'not a number');
254 | console.log(inspect(result));
255 |
256 | console.log('\nTesting with valid number:');
257 | result = await resolveValues(numberSchema, 50);
258 | console.log(inspect(result));
259 |
260 | console.log('\nTesting with out of range number:');
261 | result = await resolveValues(numberSchema, 150);
262 | console.log(inspect(result));
263 |
264 | // Example 2: Object validation
265 | console.log('\n=== Example 2: Object validation ===');
266 | const userSchema: JSONSchema = {
267 | type: 'object',
268 | properties: {
269 | name: { type: 'string', minLength: 1 },
270 | age: { type: 'number' }
271 | },
272 | required: ['name', 'age']
273 | };
274 |
275 | console.log('Testing with invalid types:');
276 | result = await resolveValues(userSchema, {
277 | name: 123,
278 | age: 'invalid'
279 | });
280 | console.log(inspect(result));
281 |
282 | // Example 2: Object validation
283 | console.log('\n=== Example 3: Object validation ===');
284 | const nestedSchema: JSONSchema = {
285 | type: 'object',
286 | properties: {
287 | person: {
288 | type: 'object',
289 | properties: {
290 | name: { type: 'string', minLength: 1 },
291 | age: { type: 'number' }
292 | },
293 | required: ['name', 'age']
294 | }
295 | },
296 | required: ['person']
297 | };
298 |
299 | console.log('Testing with invalid types:');
300 | result = await resolveValues(nestedSchema, {
301 | person: {
302 | name: 123,
303 | age: 'invalid'
304 | }
305 | });
306 |
307 | console.log(inspect(result));
308 | }
309 |
310 | runExample();
311 | ```
312 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # resolve-schema-values [](https://www.npmjs.com/package/resolve-schema-values) [](https://npmjs.org/package/resolve-schema-values) [](https://npmjs.org/package/resolve-schema-values)
2 |
3 | > Resolve values based on a JSON schema. Supports conditionals and composition. Useful for configuration, preferences, LLM chat completions, etc.
4 |
5 | Please consider following this project's author, [Jon Schlinkert](https://github.com/jonschlinkert), and consider starring the project to show your :heart: and support.
6 |
7 | ## Install
8 |
9 | Install with [npm](https://www.npmjs.com/):
10 |
11 | ```sh
12 | $ npm install --save resolve-schema-values
13 | ```
14 |
15 | ## What is this?
16 |
17 | A JSON Schema resolver and validator that transforms and verifies data based on a provided JSON Schema. It combines value resolution (providing defaults, handling conditionals, and managing complex compositions) with strict validation (enforcing types, formats, and constraints) to ensure data consistency and correctness.
18 |
19 | **Why another JSON Schema library?**
20 |
21 | This library focuses on resolution of data, versus validation only. You can use any validation library, then use this one to resolve values.
22 |
23 | Note that this library is not a full JSON Schema validator and _does not resolve $refs_, but rather a _value resolver_ that can be used in conjunction with a validator to provide a more complete solution.
24 |
25 | ## Usage and Examples
26 |
27 | ```js
28 | import { resolveValues } from 'resolve-schema-values';
29 |
30 | const schema = {
31 | type: 'object',
32 | properties: {
33 | username: {
34 | type: 'string',
35 | default: 'jonschlinkert'
36 | },
37 | company: {
38 | type: 'string'
39 | }
40 | }
41 | };
42 |
43 | const data = { company: 'Sellside' };
44 | const result = await resolveValues(schema, data);
45 | console.log(result.value); // { username: 'jonschlinkert', company: 'Sellside' }
46 | ```
47 |
48 | **Conditional Schema Resolution**
49 |
50 | ```ts
51 | const schema = {
52 | type: 'object',
53 | properties: {
54 | userType: { type: 'string' }
55 | },
56 | if: {
57 | properties: { userType: { const: 'business' } }
58 | },
59 | then: {
60 | properties: {
61 | taxId: { type: 'string', default: 'REQUIRED' },
62 | employees: { type: 'number', default: 0 }
63 | }
64 | },
65 | else: {
66 | properties: {
67 | personalId: { type: 'string', default: 'REQUIRED' }
68 | }
69 | }
70 | };
71 |
72 | const data = { userType: 'business' };
73 | const result = await resolveValues(schema, data);
74 | console.log(result.value);
75 | // {
76 | // userType: 'business',
77 | // taxId: 'REQUIRED',
78 | // employees: 0
79 | // }
80 | ```
81 |
82 | **Composition with allOf**
83 |
84 | ```ts
85 | const schema = {
86 | type: 'object',
87 | allOf: [
88 | {
89 | properties: {
90 | name: { type: 'string', default: 'Unnamed' }
91 | }
92 | },
93 | {
94 | properties: {
95 | age: { type: 'number', default: 0 }
96 | }
97 | }
98 | ]
99 | };
100 |
101 | const data = {};
102 | const result = await resolveValues(schema, data);
103 | console.log(result.value); // { name: 'Unnamed', age: 0 }
104 | ```
105 |
106 | **Pattern Properties**
107 |
108 | ```ts
109 | const schema = {
110 | type: 'object',
111 | patternProperties: {
112 | '^field\\d+$': {
113 | type: 'string',
114 | default: 'empty'
115 | }
116 | }
117 | };
118 |
119 | const data = {
120 | field1: undefined,
121 | field2: undefined,
122 | otherField: undefined
123 | };
124 |
125 | const result = await resolveValues(schema, data);
126 | console.log(result.value);
127 | // {
128 | // field1: 'empty',
129 | // field2: 'empty',
130 | // otherField: undefined
131 | // }
132 | ```
133 |
134 | **Dependent Schemas**
135 |
136 | ```ts
137 | const schema = {
138 | type: 'object',
139 | properties: {
140 | creditCard: { type: 'string' }
141 | },
142 | dependentSchemas: {
143 | creditCard: {
144 | properties: {
145 | billingAddress: { type: 'string', default: 'REQUIRED' },
146 | securityCode: { type: 'string', default: 'REQUIRED' }
147 | }
148 | }
149 | }
150 | };
151 |
152 | const data = { creditCard: '1234-5678-9012-3456' };
153 | const result = await resolveValues(schema, data);
154 | console.log(result.value);
155 | // {
156 | // creditCard: '1234-5678-9012-3456',
157 | // billingAddress: 'REQUIRED',
158 | // securityCode: 'REQUIRED'
159 | // }
160 | ```
161 |
162 | **Array Items Resolution**
163 |
164 | ```ts
165 | const schema = {
166 | type: 'array',
167 | items: {
168 | type: 'object',
169 | properties: {
170 | id: { type: 'number' },
171 | status: { type: 'string', default: 'pending' }
172 | }
173 | }
174 | };
175 |
176 | const data = [
177 | { id: 1 },
178 | { id: 2 },
179 | { id: 3 }
180 | ];
181 | const result = await resolveValues(schema, data);
182 | console.log(result.value);
183 | // [
184 | // { id: 1, status: 'pending' },
185 | // { id: 2, status: 'pending' },
186 | // { id: 3, status: 'pending' }
187 | // ]
188 | ```
189 |
190 | **OneOf with Type Validation**
191 |
192 | ```ts
193 | const schema = {
194 | type: 'object',
195 | properties: {
196 | value: {
197 | oneOf: [
198 | { type: 'number' },
199 | { type: 'string', pattern: '^\\d+$' }
200 | ],
201 | default: 0
202 | }
203 | }
204 | };
205 |
206 | const data = { value: '123' };
207 | const result = await resolveValues(schema, data);
208 | console.log(result.value);
209 | // { value: '123' } // Validates as it matches the string pattern
210 |
211 | const invalidData = { value: 'abc' };
212 | const invalidResult = await resolveValues(schema, invalidData);
213 | if (!invalidResult.ok) {
214 | console.log('Validation failed:', invalidResult.errors);
215 | } else {
216 | console.log(invalidResult.value);
217 | // { value: 0 } // Falls back to default as it matches neither schema
218 | }
219 | ```
220 |
221 | **Additional Properties with Schema**
222 |
223 | ```ts
224 | const schema = {
225 | type: 'object',
226 | properties: {
227 | name: { type: 'string' }
228 | },
229 | additionalProperties: {
230 | type: 'string',
231 | default: 'additional'
232 | }
233 | };
234 |
235 | const data = {
236 | name: 'John',
237 | customField1: undefined,
238 | customField2: undefined
239 | };
240 | const result = await resolveValues(schema, data);
241 | console.log(result.value);
242 | // {
243 | // name: 'John',
244 | // customField1: 'additional',
245 | // customField2: 'additional'
246 | // }
247 | ```
248 |
249 | ## Example Validation
250 |
251 | ```ts
252 | import util from 'node:util';
253 | import { resolveValues } from 'resolve-schema-values';
254 |
255 | const inspect = (obj: any) => util.inspect(obj, { depth: null, colors: true });
256 |
257 | async function runExample() {
258 | // Example 1: Basic type validation
259 | console.log('\n=== Example 1: Number type validation ===');
260 | const numberSchem = {
261 | type: 'number',
262 | minimum: 0,
263 | maximum: 100
264 | };
265 |
266 | console.log('Testing with string input:');
267 | let result = await resolveValues(numberSchema, 'not a number');
268 | console.log(inspect(result));
269 |
270 | console.log('\nTesting with valid number:');
271 | result = await resolveValues(numberSchema, 50);
272 | console.log(inspect(result));
273 |
274 | console.log('\nTesting with out of range number:');
275 | result = await resolveValues(numberSchema, 150);
276 | console.log(inspect(result));
277 |
278 | // Example 2: Object validation
279 | console.log('\n=== Example 2: Object validation ===');
280 | const userSchema: JSONSchema = {
281 | type: 'object',
282 | properties: {
283 | name: { type: 'string', minLength: 1 },
284 | age: { type: 'number' }
285 | },
286 | required: ['name', 'age']
287 | };
288 |
289 | console.log('Testing with invalid types:');
290 | result = await resolveValues(userSchema, {
291 | name: 123,
292 | age: 'invalid'
293 | });
294 | console.log(inspect(result));
295 |
296 | // Example 2: Object validation
297 | console.log('\n=== Example 3: Object validation ===');
298 | const nestedSchema: JSONSchema = {
299 | type: 'object',
300 | properties: {
301 | person: {
302 | type: 'object',
303 | properties: {
304 | name: { type: 'string', minLength: 1 },
305 | age: { type: 'number' }
306 | },
307 | required: ['name', 'age']
308 | }
309 | },
310 | required: ['person']
311 | };
312 |
313 | console.log('Testing with invalid types:');
314 | result = await resolveValues(nestedSchema, {
315 | person: {
316 | name: 123,
317 | age: 'invalid'
318 | }
319 | });
320 |
321 | console.log(inspect(result));
322 | }
323 |
324 | runExample();
325 | ```
326 |
327 | ## About
328 |
329 |
330 | Contributing
331 |
332 | Pull requests and stars are always welcome. For bugs and feature requests, [please create an issue](../../issues/new).
333 |
334 |
335 |
336 |
337 | Running Tests
338 |
339 | Running and reviewing unit tests is a great way to get familiarized with a library and its API. You can install dependencies and run tests with the following command:
340 |
341 | ```sh
342 | $ npm install && npm test
343 | ```
344 |
345 |
346 |
347 |
348 | Building docs
349 |
350 | _(This project's readme.md is generated by [verb](https://github.com/verbose/verb-generate-readme), please don't edit the readme directly. Any changes to the readme must be made in the [.verb.md](.verb.md) readme template.)_
351 |
352 | To generate the readme, run the following command:
353 |
354 | ```sh
355 | $ npm install -g verbose/verb#dev verb-generate-readme && verb
356 | ```
357 |
358 |
359 |
360 | ### Related projects
361 |
362 | You might also be interested in these projects:
363 |
364 | * [clone-deep](https://www.npmjs.com/package/clone-deep): Recursively (deep) clone JavaScript native types, like Object, Array, RegExp, Date as well as primitives. | [homepage](https://github.com/jonschlinkert/clone-deep "Recursively (deep) clone JavaScript native types, like Object, Array, RegExp, Date as well as primitives.")
365 | * [kind-of](https://www.npmjs.com/package/kind-of): Get the native type of a value. | [homepage](https://github.com/jonschlinkert/kind-of "Get the native type of a value.")
366 |
367 | ### Author
368 |
369 | **Jon Schlinkert**
370 |
371 | * [GitHub Profile](https://github.com/jonschlinkert)
372 | * [Twitter Profile](https://twitter.com/jonschlinkert)
373 | * [LinkedIn Profile](https://linkedin.com/in/jonschlinkert)
374 |
375 | ### License
376 |
377 | Copyright © 2025, [Jon Schlinkert](https://github.com/jonschlinkert).
378 | Released under the MIT License.
379 |
380 | ***
381 |
382 | _This file was generated by [verb-generate-readme](https://github.com/verbose/verb-generate-readme), v0.8.0, on March 06, 2025._
--------------------------------------------------------------------------------
/examples/resolve.ts:
--------------------------------------------------------------------------------
1 | import util from 'node:util';
2 | import { resolveValues } from '~/resolve';
3 | import type { JSONSchema } from '~/types';
4 |
5 | const inspect = (obj: any) => util.inspect(obj, { depth: null, colors: true });
6 |
7 | async function runExample() {
8 | // Example 1: Basic type validation
9 | console.log('\n=== Example 1: Number type validation ===');
10 | const numberSchema: JSONSchema = {
11 | type: 'number',
12 | minimum: 0,
13 | maximum: 100
14 | };
15 |
16 | console.log('Testing with string input:');
17 | let result = await resolveValues(numberSchema, 'not a number');
18 | console.log(inspect(result));
19 |
20 | console.log('\nTesting with valid number:');
21 | result = await resolveValues(numberSchema, 50);
22 | console.log(inspect(result));
23 |
24 | console.log('\nTesting with out of range number:');
25 | result = await resolveValues(numberSchema, 150);
26 | console.log(inspect(result));
27 |
28 | // Example 2: Object validation
29 | console.log('\n=== Example 2: Object validation ===');
30 | const userSchema: JSONSchema = {
31 | type: 'object',
32 | properties: {
33 | name: { type: 'string', minLength: 1 },
34 | age: { type: 'number' }
35 | },
36 | required: ['name', 'age']
37 | };
38 |
39 | console.log('Testing with invalid types:');
40 | result = await resolveValues(userSchema, {
41 | name: 123,
42 | age: 'invalid'
43 | });
44 | console.log(inspect(result));
45 |
46 | // Example 2: Object validation
47 | console.log('\n=== Example 3: Object validation ===');
48 | const nestedSchema: JSONSchema = {
49 | type: 'object',
50 | properties: {
51 | person: {
52 | type: 'object',
53 | properties: {
54 | name: { type: 'string', minLength: 1 },
55 | age: { type: 'number' }
56 | },
57 | required: ['name', 'age']
58 | }
59 | },
60 | required: ['person']
61 | };
62 |
63 | console.log('Testing with invalid types:');
64 | result = await resolveValues(nestedSchema, {
65 | person: {
66 | name: 123,
67 | age: 'invalid'
68 | }
69 | });
70 |
71 | console.log(inspect(result));
72 | }
73 |
74 | runExample();
75 |
--------------------------------------------------------------------------------
/examples/validate.ts:
--------------------------------------------------------------------------------
1 | import { resolveValues } from '~/resolve';
2 |
3 | // Example schema
4 | const userSchema = {
5 | type: 'object',
6 | required: ['username', 'email', 'age'],
7 | properties: {
8 | username: {
9 | type: 'string',
10 | minLength: 3,
11 | maxLength: 20
12 | },
13 | email: {
14 | type: 'string',
15 | format: 'email'
16 | },
17 | age: {
18 | type: 'integer',
19 | minimum: 18
20 | },
21 | preferences: {
22 | type: 'object',
23 | properties: {
24 | notifications: {
25 | type: 'boolean',
26 | default: true
27 | }
28 | }
29 | }
30 | }
31 | };
32 |
33 | async function validateUser(userData: any) {
34 | const result = await resolveValues(userSchema, userData);
35 |
36 | if (!result.ok) {
37 | return {
38 | valid: false,
39 | errors: result.errors.map(err => ({
40 | message: err.message,
41 | path: err.path?.join('.') || ''
42 | }))
43 | };
44 | }
45 |
46 | return {
47 | valid: true,
48 | data: result.value
49 | };
50 | }
51 |
52 | // Example usage:
53 | const validUser = {
54 | username: 'johndoe',
55 | email: 'john@example.com',
56 | age: 25
57 | };
58 |
59 | const invalidUser = {
60 | username: 'j', // too short
61 | email: 'not-an-email',
62 | age: 16 // under minimum
63 | };
64 |
65 | const main = async () => {
66 | // These would show how validation works:
67 | console.log(await validateUser(validUser));
68 | // Returns: { valid: true, data: { username: "johndoe", ... } }
69 |
70 | console.log(await validateUser(invalidUser));
71 | // Returns: {
72 | // valid: false,
73 | // errors: [
74 | // { message: "String length must be >= 3", path: "username" },
75 | // { message: "Invalid email format", path: "email" },
76 | // { message: "Value must be >= 18", path: "age" }
77 | // ]
78 | // }
79 | };
80 |
81 | main();
82 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | export * from './src';
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "resolve-schema-values",
3 | "description": "Resolve values based on a JSON schema. Supports conditionals and composition. Useful for configuration, preferences, LLM chat completions, etc.",
4 | "version": "3.0.1",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/jonschlinkert/resolve-schema-values"
8 | },
9 | "author": {
10 | "name": "Jon Schlinkert",
11 | "url": "https://github.com/jonschlinkert"
12 | },
13 | "scripts": {
14 | "prepublish": "npx tsup",
15 | "eslint": "npx eslint --ext .ts .",
16 | "test": "ts-mocha -r esbuild-register 'test/**/*.test.ts'",
17 | "tsup": "npx tsup"
18 | },
19 | "module": "dist/index.mjs",
20 | "main": "dist/index.js",
21 | "files": [
22 | "dist"
23 | ],
24 | "exports": {
25 | ".": {
26 | "import": "./dist/index.mjs",
27 | "require": "./dist/index.js"
28 | },
29 | "./merge": {
30 | "import": "./dist/merge.mjs",
31 | "require": "./dist/merge.js"
32 | },
33 | "./resolve": {
34 | "import": "./dist/resolve.mjs",
35 | "require": "./dist/resolve.js"
36 | }
37 | },
38 | "dependencies": {
39 | "clone-deep": "^4.0.1",
40 | "expand-json-schema": "^1.0.1"
41 | },
42 | "devDependencies": {
43 | "@types/node": "^20.12.7",
44 | "@typescript-eslint/eslint-plugin": "^8.12.2",
45 | "@typescript-eslint/parser": "^8.12.2",
46 | "esbuild-register": "^3.5.0",
47 | "eslint": "^8.57.0",
48 | "get-value": "^3.0.1",
49 | "gulp-format-md": "^2.0.0",
50 | "prettier": "^3.3.3",
51 | "ts-mocha": "^10.0.0",
52 | "ts-node": "^10.9.2",
53 | "tsconfig-paths": "^4.2.0",
54 | "tsup": "^8.0.2",
55 | "typescript": "^5.4.5"
56 | },
57 | "verb": {
58 | "toc": false,
59 | "layout": "default",
60 | "tasks": [
61 | "readme"
62 | ],
63 | "plugins": [
64 | "gulp-format-md"
65 | ],
66 | "reflinks": [
67 | "verb"
68 | ],
69 | "related": {
70 | "list": [
71 | "clone-deep",
72 | "kind-of"
73 | ]
74 | },
75 | "lint": {
76 | "reflinks": true
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | trailingComma: 'none',
3 | tabWidth: 2,
4 | semi: true,
5 | singleQuote: true,
6 | printWidth: 120,
7 | arrowParens: 'avoid',
8 | bracketSpacing: true,
9 | jsxBracketSameLine: false,
10 | jsxSingleQuote: false,
11 | quoteProps: 'consistent'
12 | };
13 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './merge';
2 | export * from './resolve';
3 |
--------------------------------------------------------------------------------
/src/merge.ts:
--------------------------------------------------------------------------------
1 | import type { JSONSchema } from '~/types';
2 |
3 | /**
4 | * Merges arrays by concatenating them and removing duplicates
5 | */
6 |
7 | const mergeArrays = (arr1: any[] = [], arr2: any[] = []): any[] => {
8 | return [...new Set([...arr1, ...arr2])];
9 | };
10 |
11 | /**
12 | * Deep merges two objects
13 | */
14 |
15 | const deepMerge = (obj1: any, obj2: any): any => {
16 | if (obj1 === null || obj2 === null) {
17 | return obj2 ?? obj1;
18 | }
19 |
20 | if (Array.isArray(obj1) || Array.isArray(obj2)) {
21 | return mergeArrays(obj1, obj2);
22 | }
23 |
24 | if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
25 | return obj2 ?? obj1;
26 | }
27 |
28 | const result = { ...obj1 };
29 |
30 | for (const key in obj2) {
31 | if (key in obj1) {
32 | result[key] = deepMerge(obj1[key], obj2[key]);
33 | } else {
34 | result[key] = obj2[key];
35 | }
36 | }
37 |
38 | return result;
39 | };
40 |
41 | const hasNumberTypes = type => {
42 | return [].concat(type).some(t => t === 'number' || t === 'integer');
43 | };
44 |
45 | export const mergeTypes = (schema1: JSONSchema, schema2: JSONSchema, options) => {
46 | const types1 = [].concat(schema1.type || []);
47 | const types2 = [].concat(schema2.type || []);
48 | let type;
49 |
50 | // When merging "allOf" sub-schemas, we need to find the intersection of types,
51 | // since values cannot be more than one type, with the exception of number and integer
52 | if (options.isAllOf) {
53 | // Find intersection for allOf
54 | type = types1.filter(t => types2.includes(t));
55 |
56 | if (type.length === 1) {
57 | type = type[0];
58 | } else if (type.length === 0) {
59 | // Special case for number and integer
60 | if (hasNumberTypes(types1) && hasNumberTypes(types2)) {
61 | // No need to check if integer exists, since it has to exist
62 | // based on the intersection check above. At this point, we
63 | // know that there is at leat one "number" and at least one "integer" type
64 | type = 'integer';
65 | } else {
66 |
67 | // No valid types satisfy both schemas
68 | return { errors: [{ message: 'No valid types satisfy both schemas', path: ['merge'] }] };
69 | }
70 | }
71 | } else {
72 | // Union for other cases
73 | type = [...new Set([...types1, ...types2])];
74 | }
75 |
76 | return type;
77 | };
78 |
79 | const isSameConst = (value1, value2) => {
80 | const v1 = [].concat(value1);
81 | const v2 = [].concat(value2);
82 | return v1.length === 1 && v2.length === 1 && v1[0] === v2[0];
83 | };
84 |
85 | // eslint-disable-next-line complexity
86 | export const mergeSchemas = (schema1: JSONSchema = {}, schema2: JSONSchema = {}, options = {}): JSONSchema => {
87 | const result: JSONSchema = { ...schema1, ...schema2 };
88 |
89 | if (options.mergeType === true) {
90 | if (schema1.type && schema2.type && schema1.type !== schema2.type) {
91 | const type = mergeTypes(schema1, schema2, options);
92 |
93 | if (type.errors) {
94 | return type;
95 | }
96 |
97 | result.type = type;
98 | }
99 | }
100 |
101 | if (schema1.enum || schema2.enum || schema1.const || schema2.const) {
102 | if (isSameConst(schema1.const, schema2.enum) || isSameConst(schema2.const, schema1.enum)) {
103 | const value = schema1.const || schema2.const || schema1.enum || schema2.enum;
104 | result.const = [].concat(value)[0];
105 | delete result.enum;
106 | } else {
107 | result.enum = mergeArrays(schema1.enum, schema2.enum);
108 | }
109 | } else if (schema1.const !== undefined || schema2.const !== undefined) {
110 | result.const = schema2.const ?? schema1.const;
111 | }
112 |
113 | // Merge number validation
114 | result.minimum = schema2.minimum ?? schema1.minimum;
115 | result.maximum = schema2.maximum ?? schema1.maximum;
116 | result.exclusiveMinimum = schema2.exclusiveMinimum ?? schema1.exclusiveMinimum;
117 | result.exclusiveMaximum = schema2.exclusiveMaximum ?? schema1.exclusiveMaximum;
118 | result.multipleOf = schema2.multipleOf ?? schema1.multipleOf;
119 |
120 | // Merge string validation
121 | result.minLength = schema2.minLength ?? schema1.minLength;
122 | result.maxLength = schema2.maxLength ?? schema1.maxLength;
123 | result.pattern = schema2.pattern ?? schema1.pattern;
124 | result.format = schema2.format ?? schema1.format;
125 |
126 | // Merge array validation
127 | result.minItems = schema2.minItems ?? schema1.minItems;
128 | result.maxItems = schema2.maxItems ?? schema1.maxItems;
129 | result.uniqueItems = schema2.uniqueItems ?? schema1.uniqueItems;
130 |
131 | if (schema1.items || schema2.items) {
132 | result.items = schema2.items
133 | ? schema1.items ? mergeSchemas(schema1.items, schema2.items) : schema2.items
134 | : schema1.items;
135 | }
136 |
137 | // Merge object validation
138 | result.minProperties = schema2.minProperties ?? schema1.minProperties;
139 | result.maxProperties = schema2.maxProperties ?? schema1.maxProperties;
140 |
141 | // Only merge required arrays if at least one schema has them
142 | if (schema1.required || schema2.required) {
143 | result.required = mergeArrays(schema1.required, schema2.required);
144 | }
145 |
146 | // Merge properties
147 | if (schema1.properties || schema2.properties) {
148 | result.properties = {};
149 |
150 | const allPropertyKeys = new Set([
151 | ...Object.keys(schema1.properties || {}),
152 | ...Object.keys(schema2.properties || {})
153 | ]);
154 |
155 | for (const key of allPropertyKeys) {
156 | const prop1 = schema1.properties?.[key];
157 | const prop2 = schema2.properties?.[key];
158 |
159 | if (prop1 && prop2) {
160 | result.properties[key] = mergeSchemas(prop1, prop2);
161 | } else {
162 | result.properties[key] = prop2 ?? prop1;
163 | }
164 | }
165 | }
166 |
167 | // Merge pattern properties
168 | if (schema1.patternProperties || schema2.patternProperties) {
169 | const left = schema1.patternProperties || {};
170 | const right = schema2.patternProperties || {};
171 | result.patternProperties = deepMerge(left, right);
172 | }
173 |
174 | // Merge additional properties
175 | if (schema1.additionalProperties !== undefined || schema2.additionalProperties !== undefined) {
176 | if (typeof schema1.additionalProperties === 'object' && typeof schema2.additionalProperties === 'object') {
177 | result.additionalProperties = mergeSchemas(schema1.additionalProperties, schema2.additionalProperties);
178 | } else {
179 | result.additionalProperties = schema2.additionalProperties ?? schema1.additionalProperties;
180 | }
181 | }
182 |
183 | // Merge dependent schemas
184 | if (schema1.dependentSchemas || schema2.dependentSchemas) {
185 | result.dependentSchemas = deepMerge(schema1.dependentSchemas || {}, schema2.dependentSchemas || {});
186 | }
187 |
188 | // Merge conditional schemas
189 | if (schema1.if || schema2.if) {
190 | result.if = schema2.if ?? schema1.if;
191 | result.then = schema2.then ?? schema1.then;
192 | result.else = schema2.else ?? schema1.else;
193 | }
194 |
195 | // Merge boolean schemas
196 | if (schema1.not || schema2.not) {
197 | result.not = mergeSchemas(schema1.not, schema2.not);
198 | }
199 |
200 | // Merge composition keywords
201 | if (schema1.allOf || schema2.allOf) {
202 | result.allOf = mergeArrays(schema1.allOf, schema2.allOf);
203 | }
204 |
205 | if (schema1.anyOf || schema2.anyOf) {
206 | result.anyOf = mergeArrays(schema1.anyOf, schema2.anyOf);
207 | }
208 |
209 | if (schema1.oneOf || schema2.oneOf) {
210 | result.oneOf = mergeArrays(schema1.oneOf, schema2.oneOf);
211 | }
212 |
213 | // Clean up undefined values
214 | return Object.fromEntries(Object.entries(result).filter(([_, value]) => value !== undefined)) as JSONSchema;
215 | };
216 |
--------------------------------------------------------------------------------
/src/resolve.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable complexity */
2 | import type { JSONSchema, ResolveOptions } from '~/types';
3 | import cloneDeep from 'clone-deep';
4 | import { resolveRef } from 'expand-json-schema';
5 | import { mergeSchemas } from '~/merge';
6 | import { deepAssign, filterProps, getSegments, getValueType, isComposition, isObject } from '~/utils';
7 |
8 | interface ValidationError {
9 | message: string;
10 | path?: string[];
11 | }
12 |
13 | interface Success {
14 | ok: true;
15 | value: T;
16 | parent: any;
17 | key?: string;
18 | }
19 |
20 | interface Failure {
21 | ok: false;
22 | errors: ValidationError[];
23 | parent: any;
24 | key?: string;
25 | }
26 |
27 | type Result = Success | Failure;
28 |
29 | class SchemaResolver {
30 | private readonly options: ResolveOptions & { getValue?: (obj: any, key: string) => any };
31 | private negationDepth: number;
32 | private stack: string[];
33 | private errors: ValidationError[] = [];
34 | private root: JSONSchema;
35 | private resolvedType: boolean = false;
36 |
37 | constructor(options: ResolveOptions = {}) {
38 | this.options = {
39 | ...options,
40 | getValue: options.getValue || ((obj, key) => obj?.[key])
41 | };
42 |
43 | this.negationDepth = 0;
44 | this.errors = [];
45 | this.stack = [];
46 | }
47 |
48 | private success(value: T, parent, key?: string): Success {
49 | return {
50 | ok: true,
51 | value,
52 | parent,
53 | key
54 | };
55 | }
56 |
57 | private failure(errors: ValidationError[], parent, key?: string): Failure {
58 | const stack = this.stack.length > 0 ? [...this.stack] : [];
59 |
60 | const errorsWithPath = errors.map(error => {
61 | return {
62 | ...error,
63 | path: stack.concat(error.path || [])
64 | };
65 | });
66 |
67 | const result = {
68 | ok: false,
69 | errors: errorsWithPath,
70 | parent,
71 | key
72 | };
73 |
74 | if (!this.isInside(['if', 'contains', 'oneOf'])) {
75 | this.errors.push(...errorsWithPath);
76 | }
77 |
78 | if (this.isInside(['oneOf']) && stack.join('.') === 'oneOf') {
79 | this.errors.push(...errorsWithPath);
80 | }
81 |
82 | return result;
83 | }
84 |
85 | private isInside(keys: string[]): boolean {
86 | return keys.some(key => this.stack.includes(key));
87 | }
88 |
89 | private isInsideNegation(): boolean {
90 | return this.negationDepth > 0;
91 | }
92 |
93 | private isValidFormat(value: string, format: string): boolean {
94 | switch (format) {
95 | case 'date-time':
96 | return !isNaN(Date.parse(value));
97 | case 'date':
98 | return /^\d{4}-\d{2}-\d{2}$/.test(value);
99 | case 'time':
100 | return /^\d{2}:\d{2}:\d{2}$/.test(value);
101 | case 'email':
102 | return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
103 | case 'ipv4': {
104 | if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) return false;
105 | return value.split('.').every(num => {
106 | const n = parseInt(num, 10);
107 | return n >= 0 && n <= 255;
108 | });
109 | }
110 | case 'uuid':
111 | return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
112 | default: {
113 | return true;
114 | }
115 | }
116 | }
117 |
118 | private async evaluateCondition(
119 | schema: JSONSchema,
120 | value: any,
121 | parent,
122 | key?: string,
123 | options: ResolveOptions = {}
124 | ): Promise {
125 | const { getValue = this.options.getValue } = options;
126 |
127 | if (schema.contains?.const && !value?.some(item => item === schema.contains?.const)) {
128 | return false;
129 | }
130 |
131 | if (schema.items) {
132 | if (!Array.isArray(value)) {
133 | return false;
134 | }
135 |
136 | this.stack.push('items');
137 | for (let i = 0; i < value.length; i++) {
138 | const itemValue = getValue(value, String(i), schema);
139 | const resolved = await this.internalResolveValues(schema.items, itemValue, schema, key, options);
140 |
141 | if (!resolved.ok) {
142 | this.stack.pop();
143 | return false;
144 | }
145 | }
146 |
147 | this.stack.pop();
148 | return true;
149 | }
150 |
151 | if (schema.properties) {
152 | if (!isObject(value)) {
153 | return false;
154 | }
155 |
156 | for (const [prop, condition] of Object.entries(schema.properties)) {
157 | this.stack.push(prop);
158 | const parentProp = parent?.properties?.[prop];
159 | const propValue = getValue(value, prop, condition, schema);
160 |
161 | if (propValue === undefined) {
162 | if (condition.default !== undefined) {
163 | value[prop] = condition.default;
164 | } else {
165 | this.stack.pop();
166 | return false;
167 | }
168 | }
169 |
170 | const propSchema = mergeSchemas(parentProp, condition, { mergeType: false });
171 | const resolved = await this.internalResolveValues(propSchema, propValue, schema, prop, {
172 | ...options,
173 | skipValidation: true,
174 | skipConditional: true
175 | });
176 |
177 | this.stack.pop();
178 |
179 | if (!resolved.ok) {
180 | return false;
181 | }
182 | }
183 |
184 | return true;
185 | }
186 |
187 | const resolved = await this.internalResolveValues(schema, value, parent, key, {
188 | ...options,
189 | skipValidation: true,
190 | skipConditional: true
191 | });
192 |
193 | return resolved.ok;
194 | }
195 |
196 | private async resolveNull(schema: JSONSchema, value: any, parent, key?: string): Result {
197 | const errors: ValidationError[] = [];
198 |
199 | if (value !== undefined && value !== null) {
200 | errors.push({ message: 'Value must be null' });
201 | return this.failure(errors, parent, key);
202 | }
203 |
204 | return this.success(null, parent, key);
205 | }
206 |
207 | private async resolveBoolean(schema: JSONSchema, value: any, parent, key?: string): Result {
208 | const required = parent?.required || [];
209 | const errors: ValidationError[] = [];
210 |
211 | if (value === undefined || value === null) {
212 | return this.success(schema.default !== undefined ? schema.default : false, parent, key);
213 | }
214 |
215 | if (typeof value !== 'boolean' && (value != null || (required.includes(key) && !this.isInsideNegation()))) {
216 | errors.push({ message: 'Value must be a boolean' });
217 | return this.failure(errors, parent, key);
218 | }
219 |
220 | return this.success(value, parent, key);
221 | }
222 |
223 | private async resolveInteger(schema: JSONSchema, value: any, parent, key?: string): Result {
224 | const required = parent?.required || [];
225 | const errors: ValidationError[] = [];
226 |
227 | if (value === undefined || value === null) {
228 | if (schema.default !== undefined) {
229 | return this.success(schema.default, parent, key);
230 | }
231 |
232 | if (required.includes(key) && !this.isInsideNegation()) {
233 | errors.push({ message: `Missing required integer: ${key}` });
234 | return this.failure(errors, parent, key);
235 | }
236 |
237 | return this.success(0, parent, key);
238 | }
239 |
240 | if (typeof value !== 'number' && (value != null || (required.includes(key) && !this.isInsideNegation()))) {
241 | errors.push({ message: 'Value must be a number' });
242 | }
243 |
244 | if (schema.type === 'integer' && !Number.isInteger(value)) {
245 | errors.push({ message: 'Value must be an integer' });
246 | }
247 |
248 | if (schema.minimum !== undefined && value < schema.minimum) {
249 | errors.push({ message: `Value must be >= ${schema.minimum}` });
250 | }
251 |
252 | if (schema.maximum !== undefined && value > schema.maximum) {
253 | errors.push({ message: `Value must be <= ${schema.maximum}` });
254 | }
255 |
256 | if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) {
257 | errors.push({ message: `Value must be > ${schema.exclusiveMinimum}` });
258 | }
259 |
260 | if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) {
261 | errors.push({ message: `Value must be < ${schema.exclusiveMaximum}` });
262 | }
263 |
264 | if (schema.multipleOf !== undefined && value % schema.multipleOf !== 0) {
265 | errors.push({ message: `Value must be a multiple of ${schema.multipleOf}` });
266 | }
267 |
268 | if (errors.length > 0) {
269 | return this.failure(errors, parent, key);
270 | }
271 |
272 | return this.success(value, parent, key);
273 | }
274 |
275 | private async resolveNumber(schema: JSONSchema, value: any, parent, key?: string): Result {
276 | const required = parent?.required || [];
277 | const errors: ValidationError[] = [];
278 |
279 | if (value === undefined || value === null) {
280 | if (schema.default !== undefined) {
281 | return this.success(schema.default, parent, key);
282 | }
283 |
284 | if (required.includes(key) && !this.isInsideNegation()) {
285 | errors.push({ message: `Missing required number: ${key}` });
286 | return this.failure(errors, parent, key);
287 | }
288 |
289 | return this.success(value, parent, key);
290 | }
291 |
292 | if (typeof value !== 'number' && (value != null || (required.includes(key) && !this.isInsideNegation()))) {
293 | errors.push({ message: 'Value must be a number' });
294 | }
295 |
296 | if (schema.minimum !== undefined && value < schema.minimum) {
297 | errors.push({ message: `Value must be >= ${schema.minimum}` });
298 | }
299 |
300 | if (schema.maximum !== undefined && value > schema.maximum) {
301 | errors.push({ message: `Value must be <= ${schema.maximum}` });
302 | }
303 |
304 | if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) {
305 | errors.push({ message: `Value must be > ${schema.exclusiveMinimum}` });
306 | }
307 |
308 | if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) {
309 | errors.push({ message: `Value must be < ${schema.exclusiveMaximum}` });
310 | }
311 |
312 | if (schema.multipleOf !== undefined && value % schema.multipleOf !== 0) {
313 | errors.push({ message: `Value must be a multiple of ${schema.multipleOf}` });
314 | }
315 |
316 | if (errors.length > 0) {
317 | return this.failure(errors, parent, key);
318 | }
319 |
320 | return this.success(value, parent, key);
321 | }
322 |
323 | private async resolveString(schema: JSONSchema, value: any, parent, key?: string): Result {
324 | const required = parent?.required || [];
325 | const errors: ValidationError[] = [];
326 |
327 | if (value === undefined || value === null) {
328 | if (schema.default !== undefined) {
329 | return this.success(schema.default, parent, key);
330 | }
331 |
332 | if (required.includes(key) && !this.isInsideNegation()) {
333 | errors.push({ message: `Missing required string: ${key}` });
334 | return this.failure(errors, parent, key);
335 | }
336 |
337 | return this.success(undefined, parent, key);
338 | }
339 |
340 | if (typeof value !== 'string' && (value != null || (required.includes(key) && !this.isInsideNegation()))) {
341 | errors.push({ message: 'Value must be a string' });
342 | }
343 |
344 | let valueLength;
345 | const length = () => {
346 | if (valueLength === undefined) {
347 | const segments = getSegments(value);
348 | valueLength = segments.length;
349 | }
350 | return valueLength;
351 | };
352 |
353 | if (schema.minLength !== undefined && length() < schema.minLength) {
354 | errors.push({ message: `String length must be >= ${schema.minLength}` });
355 | }
356 |
357 | if (schema.maxLength !== undefined && length() > schema.maxLength) {
358 | errors.push({ message: `String length must be <= ${schema.maxLength}` });
359 | }
360 |
361 | if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) {
362 | errors.push({ message: `String must match pattern: ${schema.pattern}` });
363 | }
364 |
365 | if (schema.format && !this.isValidFormat(value, schema.format)) {
366 | errors.push({ message: `Invalid ${schema.format} format` });
367 | }
368 |
369 | if (errors.length > 0) {
370 | return this.failure(errors, parent, key);
371 | }
372 |
373 | return this.success(value, parent, key);
374 | }
375 |
376 | private async resolveConditional(
377 | schema: JSONSchema,
378 | value: any,
379 | parent,
380 | key?: string,
381 | options: ResolveOptions = {}
382 | ): Promise> {
383 | const { if: ifSchema, then: thenSchema, else: elseSchema = {}, ...partialSchema } = schema;
384 |
385 | this.stack.push('if');
386 | const isSatisfied = await this.evaluateCondition(ifSchema, value, parent, key, options);
387 | this.stack.pop();
388 |
389 | const targetSchema = isSatisfied ? thenSchema : elseSchema;
390 | const completeSchema = mergeSchemas(partialSchema, targetSchema, { mergeType: false });
391 | const resolved = await this.internalResolveValues(completeSchema, value, parent, key, options);
392 | return resolved;
393 | }
394 |
395 | private async resolveAllOf(
396 | schema: JSONSchema,
397 | value: any,
398 | parent,
399 | key?: string,
400 | options: ResolveOptions = {}
401 | ): Promise> {
402 | const { allOf, ...partialSchema } = schema;
403 | const errors: ValidationError[] = [];
404 | let values = {};
405 |
406 | for (const subSchema of allOf) {
407 | let merged;
408 |
409 | if (subSchema.if) {
410 | const mergedSubSchema = mergeSchemas(subSchema, partialSchema, { mergeType: false });
411 | const condResult = await this.resolveConditional(mergedSubSchema, value, parent, 'allOf', options);
412 |
413 | if (!condResult.ok) {
414 | errors.push(...condResult.errors);
415 | }
416 |
417 | merged = partialSchema;
418 | } else {
419 | merged = mergeSchemas(subSchema, partialSchema, { mergeType: false });
420 | }
421 |
422 | const resolved = await this.internalResolveValues(merged, value, parent, 'allOf', options);
423 | if (!resolved.ok) {
424 | errors.push(...resolved.errors);
425 | } else {
426 | values = deepAssign(values, resolved.value);
427 | }
428 | }
429 |
430 | if (errors.length > 0) {
431 | return this.failure(errors, parent, key);
432 | }
433 |
434 | if (isObject(value) && isObject(values)) {
435 | return this.success(values, parent, key);
436 | }
437 |
438 | return this.internalResolveValues(partialSchema, value, parent, key);
439 | }
440 |
441 | private async resolveAnyOf(
442 | schema: JSONSchema,
443 | value: any,
444 | parent,
445 | key?: string,
446 | options: ResolveOptions = {}
447 | ): Promise> {
448 | const errors: ValidationError[] = [];
449 | const { anyOf, ...rest } = schema;
450 |
451 | for (const subSchema of anyOf) {
452 | const mergedSchema = mergeSchemas(subSchema, rest);
453 | const resolved = await this.internalResolveValues(mergedSchema, value, parent, 'anyOf', options);
454 |
455 | if (resolved.ok) {
456 | return this.success(value, parent, key);
457 | }
458 |
459 | errors.push(...resolved.errors);
460 | }
461 |
462 | if (schema.default !== undefined) {
463 | return this.success(schema.default, parent, key);
464 | }
465 |
466 | return this.failure([{ message: 'Value must match at least one schema in anyOf' }], parent, key);
467 | }
468 |
469 | private async resolveOneOf(
470 | schema: JSONSchema,
471 | value: any,
472 | parent,
473 | key?: string,
474 | options: ResolveOptions = {}
475 | ): Promise> {
476 | const { oneOf, ...rest } = schema;
477 | const candidates = [];
478 |
479 | for (const subSchema of oneOf) {
480 | const mergedSchema = mergeSchemas(subSchema, rest);
481 | const resolved = await this.internalResolveValues(mergedSchema, value, parent, key, options);
482 | candidates.push({ resolved, schema: subSchema });
483 | }
484 |
485 | const matches = candidates.filter(c => c.resolved.ok);
486 |
487 | if (matches.length === 1) {
488 | return matches[0].resolved;
489 | }
490 |
491 | if (matches.length === 0) {
492 | // First check which schema structurally matches our value
493 | const valueProps = Object.keys(value || {});
494 | const matchingCandidate = candidates.find(c =>
495 | // Find the schema that declares the properties our value has
496 | valueProps.some(prop => prop in (c.schema.properties || {}))
497 | );
498 |
499 | // If we found a matching schema, use its errors
500 | if (matchingCandidate) {
501 | return matchingCandidate.resolved;
502 | }
503 |
504 | // If no structural match found, return generic oneOf error
505 | return this.failure([{ message: 'Value must match exactly one schema in oneOf' }], parent, key);
506 | }
507 |
508 | // Multiple matches - oneOf violation
509 | if (schema.default !== undefined) {
510 | return this.success(schema.default, parent, key);
511 | }
512 |
513 | return this.failure([{ message: 'Value must match exactly one schema in oneOf' }], parent, key);
514 | }
515 |
516 | private async resolveNot(
517 | schema: JSONSchema,
518 | value: any,
519 | parent,
520 | key?: string,
521 | options: ResolveOptions = {}
522 | ): Promise> {
523 | this.negationDepth++;
524 |
525 | try {
526 | const { not: notSchema, ...partialSchema } = schema;
527 | this.stack.push('not');
528 | const notResult = await this.internalResolveValues(notSchema, value, parent, 'not', options);
529 |
530 | if (notResult.ok) {
531 | const failure = this.failure([{ message: 'Value must not match schema' }], parent, key);
532 | this.stack.pop();
533 | return failure;
534 | }
535 |
536 | this.stack.pop();
537 | return this.internalResolveValues(partialSchema, value, parent, key, options);
538 | } finally {
539 | this.negationDepth--;
540 | }
541 | }
542 |
543 | private resolveNotRequired(schema: JSONSchema, value: any): boolean {
544 | const { getValue = this.options.getValue } = {};
545 |
546 | if (schema.allOf) {
547 | const notRequiredSchemas = schema.allOf.filter(s => s.not?.required);
548 | if (notRequiredSchemas.length > 0) {
549 | return notRequiredSchemas.every(s => {
550 | return !s.not.required.every(prop => {
551 | return getValue(value, prop, schema) !== undefined;
552 | });
553 | });
554 | }
555 | }
556 |
557 | if (schema.not?.required) {
558 | return !schema.not.required.every(prop => getValue(value, prop, schema) !== undefined);
559 | }
560 |
561 | return true;
562 | }
563 |
564 | private async resolveComposition(
565 | schema: JSONSchema,
566 | value: any,
567 | parent,
568 | key?: string,
569 | options: ResolveOptions = {}
570 | ): Promise> {
571 | if (schema.allOf) {
572 | this.stack.push('allOf');
573 | const resolved = await this.resolveAllOf(schema, value, parent, key, options);
574 | this.stack.pop();
575 | return resolved;
576 | }
577 |
578 | if (schema.anyOf) {
579 | this.stack.push('anyOf');
580 | const resolved = await this.resolveAnyOf(schema, value, parent, key, options);
581 | this.stack.pop();
582 | return resolved;
583 | }
584 |
585 | if (schema.oneOf) {
586 | this.stack.push('oneOf');
587 | const resolved = await this.resolveOneOf(schema, value, parent, key, options);
588 | this.stack.pop();
589 | return resolved;
590 | }
591 |
592 | if (schema.not) {
593 | this.stack.push('not');
594 | const resolved = await this.resolveNot(schema, value, parent, key, options);
595 | this.stack.pop();
596 | return resolved;
597 | }
598 |
599 | return this.success(value, parent, key);
600 | }
601 |
602 | private async resolveArray(
603 | schema: JSONSchema,
604 | value: any,
605 | parent,
606 | key?: string,
607 | options: ResolveOptions = {}
608 | ): Promise> {
609 | const errors: ValidationError[] = [];
610 | const required = parent?.required || [];
611 | const { getValue = this.options.getValue } = options;
612 |
613 | if (!Array.isArray(value)) {
614 | if (value === undefined || value === null) {
615 | if (schema.default !== undefined) {
616 | return this.success([].concat(schema.default), parent, key);
617 | }
618 |
619 | if (required.includes(key) && !this.isInsideNegation()) {
620 | errors.push({ message: `Missing required array: ${key}` });
621 | return this.failure(errors, parent, key);
622 | }
623 |
624 | return this.success(undefined, parent, key);
625 | }
626 |
627 | errors.push({ message: 'Value must be an array' });
628 | return this.failure(errors, parent, key);
629 | }
630 |
631 | if (schema.minItems !== undefined && value.length < schema.minItems) {
632 | errors.push({ message: `Array length must be >= ${schema.minItems}` });
633 | }
634 |
635 | if (schema.maxItems !== undefined && value.length > schema.maxItems) {
636 | errors.push({ message: `Array length must be <= ${schema.maxItems}` });
637 | }
638 |
639 | if (schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) {
640 | errors.push({ message: 'Array items must be unique' });
641 | }
642 |
643 | if (schema.contains) {
644 | this.stack.push('contains');
645 | let containsValid = false;
646 |
647 | for (let i = 0; i < value.length; i++) {
648 | const itemValue = getValue(value, String(i), schema);
649 | const resolved = await this.internalResolveValues(schema.contains, itemValue, parent, key, options);
650 |
651 | if (resolved.ok) {
652 | containsValid = true;
653 | break;
654 | }
655 | }
656 |
657 | this.stack.pop();
658 |
659 | if (!containsValid) {
660 | errors.push({ message: 'Array must contain at least one matching item' });
661 | }
662 | }
663 |
664 | if (errors.length > 0) {
665 | return this.failure(errors, parent, key);
666 | }
667 |
668 | return this.resolveArrayItems(schema, value, parent, key, options);
669 | }
670 |
671 | private async resolveArrayItems(
672 | schema: JSONSchema,
673 | values: any[],
674 | parent,
675 | key?: string,
676 | options: ResolveOptions = {}
677 | ): Promise> {
678 | const { getValue = this.options.getValue } = options;
679 |
680 | if (!schema.items && !schema.prefixItems) {
681 | return this.success(values, parent, key);
682 | }
683 |
684 | const result = [];
685 | const errors: ValidationError[] = [];
686 | const maxLength = Math.max(values.length, schema.prefixItems?.length || 0);
687 |
688 | if (Array.isArray(schema.items)) {
689 | this.stack.push('items');
690 |
691 | for (let i = 0; i < values.length; i++) {
692 | const itemValue = getValue(values, String(i), schema);
693 |
694 | if (i < schema.items.length) {
695 | const resolved = await this.internalResolveValues(schema.items[i], itemValue, schema, String(i), options);
696 |
697 | if (!resolved.ok) {
698 | errors.push(...resolved.errors);
699 | } else {
700 | result.push(resolved.value);
701 | }
702 | } else if (schema.additionalItems === false) {
703 | errors.push({ message: 'Additional items not allowed' });
704 | break;
705 | } else if (schema.additionalItems) {
706 | const resolved = await this.internalResolveValues(schema.additionalItems, itemValue, schema, String(i), options);
707 |
708 | if (!resolved.ok) {
709 | errors.push(...resolved.errors);
710 | }
711 | }
712 | }
713 |
714 | this.stack.pop();
715 |
716 | if (errors.length > 0) {
717 | return this.failure(errors, parent, key);
718 | }
719 |
720 | return this.success(result, parent, key);
721 | }
722 |
723 | for (let i = 0; i < maxLength; i++) {
724 | const itemValue = getValue(values, String(i), schema);
725 |
726 | if (schema.prefixItems && i < schema.prefixItems.length) {
727 | this.stack.push('prefixItems');
728 | const resolved = await this.internalResolveValues(schema.prefixItems[i], itemValue, schema, String(i), options);
729 | this.stack.pop();
730 |
731 | if (!resolved.ok) {
732 | errors.push(...resolved.errors);
733 | } else {
734 | result.push(resolved.value);
735 | }
736 | } else if (isObject(schema.items)) {
737 | this.stack.push('items');
738 |
739 | // Check if we have conditional logic in the items schema
740 | if (schema.items.if) {
741 | const resolved = await this.resolveConditional(schema.items, itemValue, schema, String(i), options);
742 | if (!resolved.ok) {
743 | errors.push(...resolved.errors);
744 | } else {
745 | result.push(resolved.value);
746 | }
747 | } else {
748 | // If no conditionals, process normally
749 | const resolved = await this.internalResolveValues(schema.items, itemValue, schema, String(i), options);
750 | if (!resolved.ok) {
751 | errors.push(...resolved.errors);
752 | } else {
753 | result.push(resolved.value);
754 | }
755 | }
756 |
757 | this.stack.pop();
758 | } else {
759 | result.push(itemValue);
760 | }
761 | }
762 |
763 | if (errors.length > 0) {
764 | return this.failure(errors, parent, key);
765 | }
766 |
767 | return this.success(result, parent, key);
768 | }
769 |
770 | private async resolveObject(
771 | schema: JSONSchema,
772 | value: any,
773 | parent,
774 | key?: string,
775 | options: ResolveOptions = {}
776 | ): Promise> {
777 | const errors: ValidationError[] = [];
778 | const required = parent?.required || [];
779 | const { getValue = this.options.getValue } = options;
780 |
781 | if (this.isInsideNegation()) {
782 | this.stack.push('required');
783 | if (!this.resolveNotRequired(schema, value)) {
784 | errors.push({ message: 'Object does not satisfy required property constraints' });
785 | }
786 | this.stack.pop();
787 | } else if (schema.required) {
788 | for (const propKey of schema.required) {
789 | const prop = schema.properties?.[propKey];
790 | const propValue = getValue(value, propKey, prop, schema);
791 |
792 | if (propValue === undefined) {
793 | const defaultValue = prop?.default;
794 |
795 | if (defaultValue !== undefined) {
796 | value[propKey] = defaultValue;
797 | } else {
798 | errors.push({ message: `Missing required property: ${propKey}`, path: [propKey] });
799 | }
800 | }
801 | }
802 | }
803 |
804 | if (schema.propertyNames) {
805 | this.stack.push('propertyNames');
806 |
807 | for (const propName in value) {
808 | const resolved = await this.internalResolveValues(schema.propertyNames, propName, parent, key, options);
809 |
810 | if (!resolved.ok) {
811 | errors.push(...resolved.errors);
812 | }
813 | }
814 |
815 | this.stack.pop();
816 | }
817 |
818 | if (errors.length > 0) {
819 | return this.failure(errors, parent, key);
820 | }
821 |
822 | let result = value;
823 |
824 | if (schema.properties) {
825 | const resolvedProperties = await this.resolveObjectProperties(schema.properties, value, parent, key, options);
826 | if (!resolvedProperties.ok) {
827 | return resolvedProperties;
828 | }
829 | result = { ...result, ...resolvedProperties.value };
830 | }
831 |
832 | if (schema.patternProperties) {
833 | const patternResult = await this.resolvePatternProperties(schema, value, result, parent, key, options);
834 | if (!patternResult.ok) {
835 | return patternResult;
836 | }
837 | result = patternResult.value;
838 | }
839 |
840 | if (schema.additionalProperties === true) {
841 | const additionalResult = await this.resolveAdditionalProperties(schema, value, result, parent, key, options);
842 | if (!additionalResult.ok) {
843 | return additionalResult;
844 | }
845 | result = additionalResult.value;
846 | }
847 |
848 | if (schema.dependentSchemas) {
849 | const dependentResult = await this.resolveDependentSchemas(schema, value, result, parent, key, options);
850 | if (!dependentResult.ok) {
851 | return dependentResult;
852 | }
853 | result = dependentResult.value;
854 | }
855 |
856 | if (schema.if) {
857 | const conditionalResult = await this.resolveConditional(schema, result, parent, key, options);
858 | if (!conditionalResult.ok) {
859 | return conditionalResult;
860 | }
861 | result = conditionalResult.value;
862 | }
863 |
864 | if (result !== undefined) {
865 | value = result;
866 | }
867 |
868 | if (!isObject(value)) {
869 | if (value === undefined || value === null) {
870 | const defaultValue = schema.default;
871 |
872 | if (defaultValue !== undefined) {
873 | return this.success(defaultValue, parent, key);
874 | }
875 |
876 | if (required.includes(key) && !this.isInsideNegation()) {
877 | errors.push({ message: `Missing required object: ${key}` });
878 | return this.failure(errors, parent, key);
879 | }
880 |
881 | // Instead of returning early, set value to empty object and continue
882 | // This allows for default values to be set for nested properties
883 | value = {};
884 | } else {
885 | errors.push({ message: 'Value must be an object' });
886 | return this.failure(errors, parent, key);
887 | }
888 | }
889 |
890 | if (schema.minProperties !== undefined && Object.keys(value).length < schema.minProperties) {
891 | errors.push({ message: `Object must have >= ${schema.minProperties} properties` });
892 | }
893 |
894 | if (schema.maxProperties !== undefined && Object.keys(value).length > schema.maxProperties) {
895 | errors.push({ message: `Object must have <= ${schema.maxProperties} properties` });
896 | }
897 |
898 | return this.success(result, parent, key);
899 | }
900 |
901 | private async resolveObjectProperties(
902 | properties: Record,
903 | value: any,
904 | parent,
905 | key?: string,
906 | options: ResolveOptions = {}
907 | ): Promise>> {
908 | const result: Record = {};
909 | const errors: ValidationError[] = [];
910 | const { getValue = this.options.getValue } = options;
911 |
912 | for (const [propKey, propSchema] of Object.entries(properties)) {
913 | const propValue = getValue(value, propKey, propSchema, parent);
914 |
915 | if (propValue === undefined && propSchema.default !== undefined) {
916 | result[propKey] = propSchema.default;
917 | }
918 |
919 | this.stack.push(propKey);
920 | const resolved = await this.internalResolveValues(propSchema, propValue, parent, propKey, options);
921 | this.stack.pop();
922 |
923 | if (!resolved.ok) {
924 | errors.push(...resolved.errors);
925 | } else if (resolved.value !== undefined) {
926 | result[propKey] = resolved.value;
927 | }
928 | }
929 |
930 | if (errors.length > 0) {
931 | return this.failure(errors, parent, key);
932 | }
933 |
934 | return this.success(result, parent, key);
935 | }
936 |
937 | private async resolvePatternProperties(
938 | schema: JSONSchema,
939 | value: any,
940 | result: Record,
941 | parent,
942 | key?: string,
943 | options: ResolveOptions = {}
944 | ): Promise>> {
945 | if (!schema.patternProperties) {
946 | return this.success(result, parent, key);
947 | }
948 |
949 | const { getValue = this.options.getValue } = options;
950 | const newResult = { ...result };
951 | const errors: ValidationError[] = [];
952 |
953 | for (const [pattern, propSchema] of Object.entries(schema.patternProperties)) {
954 | const regex = new RegExp(pattern, 'u');
955 |
956 | for (const [k, v] of Object.entries(value)) {
957 | if (regex.test(k) && !(k in newResult)) {
958 | const propValue = getValue(value, k, propSchema, parent);
959 | const resolved = await this.internalResolveValues(propSchema, propValue, parent, k, options);
960 |
961 | if (!resolved.ok) {
962 | errors.push(...resolved.errors);
963 | } else {
964 | newResult[k] = resolved.value;
965 | }
966 | }
967 | }
968 | }
969 |
970 | if (errors.length > 0) {
971 | return this.failure(errors, parent, key);
972 | }
973 |
974 | return this.success(newResult, parent, key);
975 | }
976 |
977 | private async resolveAdditionalProperties(
978 | schema: JSONSchema,
979 | value: any,
980 | result: Record,
981 | parent,
982 | key?: string,
983 | options: ResolveOptions = {}
984 | ): Promise>> {
985 | const addlProps = schema.additionalProperties;
986 |
987 | if (addlProps?.$ref) {
988 | const refSchema = addlProps.$ref === '#'
989 | ? this.root
990 | : resolveRef(addlProps.$ref, this.root);
991 |
992 | if (!refSchema) {
993 | return this.failure([{ message: `Schema not found: ${addlProps.$ref}` }], parent, key);
994 | }
995 |
996 | this.stack.push('additionalProperties');
997 | const merged = mergeSchemas(refSchema, addlProps);
998 | const resolved = await this.internalResolveValues(merged, result, parent, key, options);
999 | this.stack.pop();
1000 | return resolved;
1001 | }
1002 |
1003 | if (addlProps === false) {
1004 | return this.success(result, parent, key);
1005 | }
1006 |
1007 | const { getValue = this.options.getValue } = options;
1008 | const newResult = { ...result };
1009 | const errors: ValidationError[] = [];
1010 |
1011 | for (const [k, v] of Object.entries(value)) {
1012 | if (!newResult.hasOwnProperty(k)) {
1013 | const propValue = getValue(v, k, schema);
1014 |
1015 | if (typeof addlProps === 'object') {
1016 | const resolved = await this.internalResolveValues(addlProps, propValue, parent, k, options);
1017 |
1018 | if (!resolved.ok) {
1019 | errors.push(...resolved.errors);
1020 | } else {
1021 | newResult[k] = resolved.value;
1022 | }
1023 | } else {
1024 | newResult[k] = propValue;
1025 | }
1026 | }
1027 | }
1028 |
1029 | if (errors.length > 0) {
1030 | return this.failure(errors, parent, key);
1031 | }
1032 |
1033 | return this.success(newResult, parent, key);
1034 | }
1035 |
1036 | private async resolveDependentSchemas(
1037 | schema: JSONSchema,
1038 | value: any,
1039 | result: Record,
1040 | parent,
1041 | key?: string,
1042 | options: ResolveOptions = {}
1043 | ): Promise>> {
1044 | if (!schema.dependentSchemas || !value) {
1045 | return this.success(result, parent, key);
1046 | }
1047 |
1048 | const { dependentSchemas, ...rest } = schema;
1049 | const { getValue = this.options.getValue } = options;
1050 |
1051 | const depSchemas = Object.entries(dependentSchemas)
1052 | .filter(([prop]) => getValue(value, prop, dependentSchemas) !== undefined)
1053 | .map(([, schema]) => schema);
1054 |
1055 | if (depSchemas.length === 0) {
1056 | return this.success(result, parent, key);
1057 | }
1058 |
1059 | const mergedSchema = depSchemas.reduce((acc, schema) => mergeSchemas(acc, schema), rest);
1060 | return this.internalResolveValues(mergedSchema, result, parent, key, options);
1061 | }
1062 |
1063 | private async resolveValue(
1064 | schema: JSONSchema,
1065 | value: any,
1066 | parent,
1067 | key?: string,
1068 | options: ResolveOptions = {}
1069 | ): Promise> {
1070 | const errors: ValidationError[] = [];
1071 | const required = parent?.required || [];
1072 | const opts = { ...this.options, ...options };
1073 |
1074 | if (schema.allOf && schema.allOf.length === 1) {
1075 | const { allOf, ...rest } = schema;
1076 | const merged = mergeSchemas(rest, allOf[0]);
1077 | const result = this.internalResolveValues(merged, value, parent, key, options);
1078 | return result;
1079 | }
1080 |
1081 | if (schema.anyOf && schema.anyOf.length === 1) {
1082 | const { anyOf, ...rest } = schema;
1083 | const merged = mergeSchemas(rest, anyOf[0]);
1084 | return this.internalResolveValues(merged, value, parent, key, options);
1085 | }
1086 |
1087 | if (schema.oneOf && schema.oneOf.length === 1) {
1088 | const { oneOf, ...rest } = schema;
1089 | const merged = mergeSchemas(rest, oneOf[0]);
1090 | return this.internalResolveValues(merged, value, parent, key, options);
1091 | }
1092 |
1093 | if (schema.not && !opts.skipValidation) {
1094 | return this.resolveNot(schema, value, parent, key, options);
1095 | }
1096 |
1097 | if (schema.oneOf || schema.anyOf || schema.allOf) {
1098 | return this.resolveComposition(schema, value, parent, key, options);
1099 | }
1100 |
1101 | if (value === undefined || value === null) {
1102 | const defaultValue = schema.default ?? schema.const;
1103 |
1104 | if (defaultValue !== undefined) {
1105 | return this.success(defaultValue, parent, key);
1106 | }
1107 |
1108 | if (required.includes(key) && !this.isInsideNegation()) {
1109 | const result = this.failure([{ message: `Missing required value: ${key}` }], parent, key);
1110 | this.stack.pop();
1111 | return result;
1112 | }
1113 |
1114 | return this.success(value, parent, key);
1115 | }
1116 |
1117 | if (schema.const && value !== schema.const) {
1118 | errors.push({ message: `Value must be ${schema.const}` });
1119 | }
1120 |
1121 | if (schema.enum && !schema.enum.some(v => v === value || v?.name === value)) {
1122 | const values = schema.enum.map(v => {
1123 | if (typeof v === 'string') {
1124 | return v;
1125 | }
1126 |
1127 | return v?.name || JSON.stringify(v);
1128 | });
1129 |
1130 | errors.push({ message: `Value must be one of: ${values.join(', ')}`, invalidValue: value });
1131 | }
1132 |
1133 | if (schema.required?.length > 0 && !opts.skipValidation) {
1134 | const { getValue = this.options.getValue } = options;
1135 | const missingProps = schema.required.filter(prop => {
1136 | return getValue(value, prop, schema) === undefined && schema.properties?.[prop]?.default === undefined;
1137 | });
1138 |
1139 | if (missingProps.length > 0) {
1140 | missingProps.forEach(prop => this.stack.push(prop));
1141 | const error = this.failure(missingProps.map(prop => ({ message: `Missing required property: ${prop}` })), parent, key);
1142 | missingProps.forEach(() => this.stack.pop());
1143 | return error;
1144 | }
1145 | }
1146 |
1147 | if (errors.length > 0) {
1148 | if (key) this.stack.push(key);
1149 | const result = this.failure(errors, parent, key);
1150 | if (key) this.stack.pop();
1151 | return result;
1152 | }
1153 |
1154 | return this.success(value, parent, key);
1155 | }
1156 |
1157 | private async internalResolveValues(
1158 | schema: JSONSchema,
1159 | value: any,
1160 | parent = schema,
1161 | key?: string,
1162 | options: ResolveOptions = {}
1163 | ): Promise> {
1164 | let result = value;
1165 |
1166 | const resolveType = () => {
1167 | this.resolvedType = true;
1168 | switch (schema.type) {
1169 | case 'null': return this.resolveNull(schema, result, parent, key, options);
1170 | case 'array': return this.resolveArray(schema, result, parent, key, options);
1171 | case 'boolean': return this.resolveBoolean(schema, result, parent, key, options);
1172 | case 'integer': return this.resolveInteger(schema, result, parent, key, options);
1173 | case 'number': return this.resolveNumber(schema, result, parent, key, options);
1174 | case 'object': return this.resolveObject(schema, result, parent, key, options);
1175 | case 'string': return this.resolveString(schema, result, parent, key, options);
1176 | default: {
1177 | return this.failure([{ message: `Unsupported type: ${schema.type}` }], parent, key);
1178 | }
1179 | }
1180 | };
1181 |
1182 | const valueResult = await this.resolveValue(schema, result, parent, key, options);
1183 | if (!valueResult.ok) {
1184 | if (!this.resolvedType && !this.isInside(['not'])) {
1185 | // If we haven't resolved the type yet, try to resolve it now
1186 | // so we can get any error messages pushed onto the error stack
1187 | await resolveType();
1188 | }
1189 |
1190 | return valueResult;
1191 | }
1192 |
1193 | result = valueResult.value;
1194 |
1195 | if (result && typeof result === 'object' && schema.if) {
1196 | const conditionalResult = await this.resolveConditional(schema, result, parent, key, options);
1197 | if (!conditionalResult.ok) {
1198 | return conditionalResult;
1199 | }
1200 |
1201 | result = conditionalResult.value;
1202 | }
1203 |
1204 | if (isObject(result) && isComposition(schema)) {
1205 | const compositionResult = await this.resolveComposition(schema, result, parent, key, options);
1206 | if (!compositionResult.ok) {
1207 | return compositionResult;
1208 | }
1209 |
1210 | result = compositionResult.value;
1211 | }
1212 |
1213 | if (!schema.type) {
1214 | return this.success(result, parent, key);
1215 | }
1216 |
1217 | if (Array.isArray(schema.type)) {
1218 | const valueType = getValueType(result, schema.type);
1219 |
1220 | if (valueType === undefined) {
1221 | const error = { message: `Value must be one of type: ${schema.type.join(', ')}` };
1222 | return this.failure([error], parent, key);
1223 | }
1224 |
1225 | const typeSchema = filterProps({ ...schema, type: valueType }, valueType);
1226 | return this.internalResolveValues(typeSchema, result, parent, key, options);
1227 | }
1228 |
1229 | return resolveType();
1230 | }
1231 |
1232 | async resolveValues(schema: JSONSchema, values: any): Promise> {
1233 | this.root ||= cloneDeep(schema);
1234 |
1235 | const { ok, value } = await this.internalResolveValues(schema, values);
1236 | const seen = new Set();
1237 | let errors = [];
1238 |
1239 | for (const error of this.errors) {
1240 | if (!seen.has(error.message)) {
1241 | seen.add(error.message);
1242 | errors.push(error);
1243 | }
1244 | }
1245 |
1246 | const isOneOfError = error => error.path.join('') === 'oneOf';
1247 | const isRequiredError = error => error.message.startsWith('Missing required');
1248 | const isDisposableError = error => isOneOfError(error) || isRequiredError(error);
1249 | const isInsideOneOf = error => error.path?.includes('oneOf');
1250 |
1251 | if (errors.length > 1 && errors.some(e => isDisposableError(e)) && errors.some(e => (e.path?.length > 1 && !isInsideOneOf(e)) || !isDisposableError(e))) {
1252 | errors = errors.filter(e => (e.path?.length > 1 && !isInsideOneOf(e)) || !isDisposableError(e));
1253 | }
1254 |
1255 | return ok ? { ok, value } : { ok, errors };
1256 | }
1257 | }
1258 |
1259 | export const resolveValues = async (
1260 | schema: JSONSchema,
1261 | values: any,
1262 | options: ResolveOptions = {}
1263 | ): Promise> => {
1264 | const validator = new SchemaResolver(options);
1265 | return validator.resolveValues(schema, values);
1266 | };
1267 |
--------------------------------------------------------------------------------
/src/schema-props.ts:
--------------------------------------------------------------------------------
1 | export const schemaProps = {
2 | base: [
3 | 'type',
4 | 'title',
5 | 'description',
6 | 'default',
7 | 'examples',
8 | 'deprecated',
9 | 'readOnly',
10 | 'writeOnly',
11 | '$id',
12 | '$schema',
13 | '$ref',
14 | 'definitions',
15 | 'enum',
16 | 'const',
17 | 'allOf',
18 | 'anyOf',
19 | 'oneOf',
20 | 'not',
21 | 'if',
22 | 'then',
23 | 'else'
24 | ],
25 |
26 | string: [
27 | 'maxLength',
28 | 'minLength',
29 | 'pattern',
30 | 'format',
31 | 'contentMediaType',
32 | 'contentEncoding'
33 | ],
34 |
35 | number: [
36 | 'multipleOf',
37 | 'maximum',
38 | 'exclusiveMaximum',
39 | 'minimum',
40 | 'exclusiveMinimum'
41 | ],
42 |
43 | integer: [
44 | 'multipleOf',
45 | 'maximum',
46 | 'exclusiveMaximum',
47 | 'minimum',
48 | 'exclusiveMinimum'
49 | ],
50 |
51 | array: [
52 | 'items',
53 | 'additionalItems',
54 | 'maxItems',
55 | 'minItems',
56 | 'uniqueItems',
57 | 'contains',
58 | 'maxContains',
59 | 'minContains'
60 | ],
61 |
62 | object: [
63 | 'maxProperties',
64 | 'minProperties',
65 | 'required',
66 | 'properties',
67 | 'patternProperties',
68 | 'additionalProperties',
69 | 'dependencies',
70 | 'propertyNames'
71 | ],
72 |
73 | boolean: [],
74 |
75 | null: []
76 | } as const;
77 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface JSONSchema {
2 | // Basic
3 | type?: string;
4 | enum?: any[];
5 | const?: any;
6 | default?: any;
7 |
8 | // String validation
9 | minLength?: number;
10 | maxLength?: number;
11 | pattern?: string;
12 | format?: string;
13 | contentEncoding?: string;
14 | contentMediaType?: string;
15 |
16 | // Number validation
17 | minimum?: number;
18 | maximum?: number;
19 | exclusiveMinimum?: number;
20 | exclusiveMaximum?: number;
21 | multipleOf?: number;
22 |
23 | // Array validation
24 | items?: JSONSchema;
25 | prefixItems?: JSONSchema[];
26 | minItems?: number;
27 | maxItems?: number;
28 | uniqueItems?: boolean;
29 | contains?: JSONSchema;
30 | minContains?: number;
31 | maxContains?: number;
32 |
33 | // Object validation
34 | properties?: Record;
35 | required?: string[];
36 | minProperties?: number;
37 | maxProperties?: number;
38 | additionalProperties?: boolean | JSONSchema;
39 | patternProperties?: Record;
40 | propertyNames?: JSONSchema;
41 | dependentSchemas?: Record;
42 | dependentRequired?: Record;
43 |
44 | // Conditional
45 | if?: JSONSchema;
46 | then?: JSONSchema;
47 | else?: JSONSchema;
48 |
49 | // Composition
50 | allOf?: JSONSchema[];
51 | anyOf?: JSONSchema[];
52 | oneOf?: JSONSchema[];
53 | }
54 |
55 | export interface ResolveOptions {
56 | skipValidation?: boolean;
57 | currentPath?: string[];
58 | }
59 |
60 | export interface ValidationResult {
61 | valid: boolean;
62 | errors: ValidationError[];
63 | }
64 |
65 | export interface ValidationError {
66 | message: string;
67 | path?: string[];
68 | }
69 |
70 | export interface Success {
71 | ok: true;
72 | value: T;
73 | parent: any;
74 | key?: string;
75 | }
76 |
77 | export interface Failure {
78 | ok: false;
79 | errors: ValidationError[];
80 | parent: any;
81 | key?: string;
82 | }
83 |
84 | export type Result = Success | Failure;
85 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { JSONSchema } from '~/types';
2 | import { schemaProps } from '~/schema-props';
3 | import util from 'node:util';
4 |
5 | type JsonValue =
6 | | string
7 | | number
8 | | boolean
9 | | null
10 | | JsonValue[]
11 | | { [key: string]: JsonValue };
12 |
13 | export const inspect = v => util.inspect(v, { depth: null, colors: true, maxArrayLength: null });
14 |
15 | export const isPrimitive = (v): boolean => Object(v) !== v;
16 |
17 | export const isObject = (v: any): v is Record => {
18 | return typeof v === 'object' && v !== null && !Array.isArray(v);
19 | };
20 |
21 | export const isComposition = (schema: JSONSchema): boolean => {
22 | return schema.allOf || schema.anyOf || schema.oneOf || schema.not;
23 | };
24 |
25 | export const getSegments = (
26 | input: string,
27 | options: {
28 | language?: string;
29 | granularity?: 'grapheme' | 'word' | 'sentence' | 'line',
30 | localeMatcher: 'lookup' | 'best fit'
31 | } = {}
32 | ): Intl.SegmentData[] => {
33 | const { language, granularity = 'grapheme', ...opts } = options;
34 | const segmenter = new Intl.Segmenter(language, { granularity, ...opts });
35 | return Array.from(segmenter.segment(input));
36 | };
37 |
38 | export const getValueType = (value: any, types: string | string[]): string | undefined => {
39 | const typeArr = Array.isArray(types) ? types : [types];
40 | if (value === null && typeArr.includes('null')) return 'null';
41 | if (Array.isArray(value) && typeArr.includes('array')) return 'array';
42 | if (isObject(value) && typeArr.includes('object')) return 'object';
43 | if (typeof value === 'boolean' && typeArr.includes('boolean')) return 'boolean';
44 | if (typeof value === 'string' && typeArr.includes('string')) return 'string';
45 | if (typeof value === 'number' && typeArr.includes('number')) return 'number';
46 | if (typeof value === 'number' && typeArr.includes('integer')) return 'integer';
47 | return undefined;
48 | };
49 |
50 | export const filterProps = (schema: JSONSchema, valueType: string) => {
51 | const typeProps = new Set(schemaProps[valueType]);
52 | const filtered = { ...schema };
53 |
54 | for (const key of Object.keys(schema)) {
55 | if (!schemaProps.base.includes(key) && !typeProps.has(key)) {
56 | delete filtered[key];
57 | }
58 | }
59 |
60 | return filtered;
61 | };
62 |
63 | export const isValidValueType = (value: any, type: string): boolean => {
64 | if (Array.isArray(type)) {
65 | return type.some(t => isValidValueType(value, t));
66 | }
67 |
68 | switch (type) {
69 | case 'null': return value === null;
70 | case 'array': return Array.isArray(value);
71 | case 'object': return isObject(value);
72 | case 'boolean': return typeof value === 'boolean';
73 | case 'number': return typeof value === 'number';
74 | case 'integer': return typeof value === 'number' && Number.isInteger(value);
75 | case 'string': return typeof value === 'string';
76 | default: return false;
77 | }
78 | };
79 |
80 | export function deepAssign(target: T, ...sources: T[]): T {
81 | // Handle null, undefined, or primitive values
82 | if (target === null || typeof target !== 'object') {
83 | return sources.length ? sources[sources.length - 1] as T : target;
84 | }
85 |
86 | // Handle arrays
87 | if (Array.isArray(target)) {
88 | for (let i = 0; i < sources.length; i++) {
89 | const source = sources[i];
90 | if (Array.isArray(source)) {
91 | const result = [...target] as unknown as T;
92 | for (let j = 0; j < source.length; j++) {
93 | if (j < target.length) {
94 | (result as any)[j] = deepAssign(target[j], source[j]);
95 | } else {
96 | (result as any)[j] = source[j];
97 | }
98 | }
99 | target = result;
100 | }
101 | }
102 | return target;
103 | }
104 |
105 | // Handle objects
106 | for (let i = 0; i < sources.length; i++) {
107 | const source = sources[i];
108 | if (source === null || typeof source !== 'object') {
109 | continue;
110 | }
111 |
112 | const keys = Object.keys(source);
113 | for (let j = 0; j < keys.length; j++) {
114 | const key = keys[j];
115 | const targetValue = (target as any)[key];
116 | const sourceValue = (source as any)[key];
117 |
118 | if (targetValue === null || typeof targetValue !== 'object') {
119 | (target as any)[key] = sourceValue;
120 | } else {
121 | (target as any)[key] = deepAssign(targetValue, sourceValue);
122 | }
123 | }
124 | }
125 |
126 | return target;
127 | }
128 |
129 | export const isEmpty = (value: any, omitZero: boolean = false): boolean => {
130 | if (value == null) return true;
131 | if (value === '') return true;
132 |
133 | const seen = new Set();
134 |
135 | const walk = (v: any): boolean => {
136 | if (!isPrimitive(v) && seen.has(v)) {
137 | return true;
138 | }
139 |
140 | if (v == null) return true;
141 | if (v === '') return true;
142 | if (Number.isNaN(v)) return true;
143 |
144 | if (typeof v === 'number') {
145 | return omitZero ? v === 0 : false;
146 | }
147 |
148 | if (v instanceof RegExp) {
149 | return v.source === '';
150 | }
151 |
152 | if (v instanceof Error) {
153 | return v.message === '';
154 | }
155 |
156 | if (v instanceof Date) {
157 | return false;
158 | }
159 |
160 | if (Array.isArray(v)) {
161 | seen.add(v);
162 |
163 | for (const e of v) {
164 | if (!isEmpty(e, omitZero)) {
165 | return false;
166 | }
167 | }
168 | return true;
169 | }
170 |
171 | if (isObject(v)) {
172 | seen.add(v);
173 |
174 | if (typeof v.size === 'number') {
175 | return v.size === 0;
176 | }
177 |
178 | for (const k of Object.keys(v)) {
179 | if (!isEmpty(v[k], omitZero)) {
180 | return false;
181 | }
182 | }
183 |
184 | return true;
185 | }
186 |
187 | return false;
188 | };
189 |
190 | return walk(value);
191 | };
192 |
--------------------------------------------------------------------------------
/test/merge.test.ts:
--------------------------------------------------------------------------------
1 | import type { JSONSchema } from '~/types';
2 | import assert from 'node:assert/strict';
3 | import { mergeSchemas } from '~/merge';
4 |
5 | describe('mergeSchemas', () => {
6 | it('should merge basic properties', () => {
7 | const schema1: JSONSchema = {
8 | type: 'object',
9 | title: 'Schema 1',
10 | description: 'First schema'
11 | };
12 |
13 | const schema2: JSONSchema = {
14 | type: 'object',
15 | description: 'Second schema'
16 | };
17 |
18 | const result = mergeSchemas(schema1, schema2);
19 | assert.deepStrictEqual(result, {
20 | type: 'object',
21 | title: 'Schema 1',
22 | description: 'Second schema'
23 | });
24 | });
25 |
26 | it('should merge validation constraints', () => {
27 | const schema1: JSONSchema = {
28 | type: 'number',
29 | minimum: 0,
30 | maximum: 100
31 | };
32 |
33 | const schema2: JSONSchema = {
34 | type: 'number',
35 | minimum: 10,
36 | exclusiveMaximum: 90
37 | };
38 |
39 | const result = mergeSchemas(schema1, schema2);
40 | assert.deepStrictEqual(result, {
41 | type: 'number',
42 | minimum: 10,
43 | maximum: 100,
44 | exclusiveMaximum: 90
45 | });
46 | });
47 |
48 | it('should merge object properties', () => {
49 | const schema1: JSONSchema = {
50 | type: 'object',
51 | properties: {
52 | name: { type: 'string' },
53 | age: { type: 'number', minimum: 0 }
54 | }
55 | };
56 |
57 | const schema2: JSONSchema = {
58 | type: 'object',
59 | properties: {
60 | age: { type: 'number', maximum: 120 },
61 | email: { type: 'string', format: 'email' }
62 | }
63 | };
64 |
65 | const result = mergeSchemas(schema1, schema2);
66 | assert.deepStrictEqual(result, {
67 | type: 'object',
68 | properties: {
69 | name: { type: 'string' },
70 | age: { type: 'number', minimum: 0, maximum: 120 },
71 | email: { type: 'string', format: 'email' }
72 | }
73 | });
74 | });
75 |
76 | it('should merge array schemas', () => {
77 | const schema1: JSONSchema = {
78 | type: 'array',
79 | items: { type: 'string', minLength: 1 },
80 | minItems: 1
81 | };
82 |
83 | const schema2: JSONSchema = {
84 | type: 'array',
85 | items: { type: 'string', maxLength: 10 },
86 | maxItems: 5
87 | };
88 |
89 | const result = mergeSchemas(schema1, schema2);
90 | assert.deepStrictEqual(result, {
91 | type: 'array',
92 | items: { type: 'string', minLength: 1, maxLength: 10 },
93 | minItems: 1,
94 | maxItems: 5
95 | });
96 | });
97 |
98 | it('should merge conditional schemas', () => {
99 | const schema1: JSONSchema = {
100 | type: 'object',
101 | properties: {
102 | age: { type: 'number' }
103 | },
104 | if: { properties: { age: { minimum: 18 } } },
105 | then: { properties: { canVote: { type: 'boolean', const: true } } }
106 | };
107 |
108 | const schema2: JSONSchema = {
109 | type: 'object',
110 | if: { properties: { age: { minimum: 21 } } },
111 | then: { properties: { canDrink: { type: 'boolean', const: true } } }
112 | };
113 |
114 | const result = mergeSchemas(schema1, schema2);
115 | assert.deepStrictEqual(result, {
116 | type: 'object',
117 | properties: {
118 | age: { type: 'number' }
119 | },
120 | if: { properties: { age: { minimum: 21 } } },
121 | then: { properties: { canDrink: { type: 'boolean', const: true } } }
122 | });
123 | });
124 |
125 | it('should merge composition keywords', () => {
126 | const schema1: JSONSchema = {
127 | allOf: [
128 | { properties: { name: { type: 'string' } } }
129 | ],
130 | anyOf: [
131 | { properties: { age: { type: 'number' } } }
132 | ]
133 | };
134 |
135 | const schema2: JSONSchema = {
136 | allOf: [
137 | { properties: { email: { type: 'string' } } }
138 | ],
139 | oneOf: [
140 | { properties: { type: { enum: ['user', 'admin'] } } }
141 | ]
142 | };
143 |
144 | const result = mergeSchemas(schema1, schema2);
145 | assert.deepStrictEqual(result, {
146 | allOf: [
147 | { properties: { name: { type: 'string' } } },
148 | { properties: { email: { type: 'string' } } }
149 | ],
150 | anyOf: [
151 | { properties: { age: { type: 'number' } } }
152 | ],
153 | oneOf: [
154 | { properties: { type: { enum: ['user', 'admin'] } } }
155 | ]
156 | });
157 | });
158 | });
159 |
--------------------------------------------------------------------------------
/test/resolve-get-value.test.ts:
--------------------------------------------------------------------------------
1 | import type { JSONSchema } from '~/types';
2 | import assert from 'node:assert/strict';
3 | import getValue from 'get-value';
4 | import { resolveValues } from '~/resolve';
5 |
6 | describe('getValue custom resolution', () => {
7 | describe('basic value access', () => {
8 | it('should resolve simple object properties', async () => {
9 | const schema: JSONSchema = {
10 | type: 'object',
11 | properties: {
12 | name: { type: 'string' }
13 | }
14 | };
15 |
16 | const result = await resolveValues(schema, { name: 'test' }, {
17 | getValue: (obj, key) => getValue(obj, key)
18 | });
19 |
20 | assert.ok(result.ok);
21 | assert.strictEqual(result.value.name, 'test');
22 | });
23 |
24 | it('should handle undefined properties', async () => {
25 | const schema: JSONSchema = {
26 | type: 'object',
27 | properties: {
28 | name: { type: 'string', default: 'default' }
29 | }
30 | };
31 |
32 | const result = await resolveValues(schema, {}, {
33 | getValue: (obj, key) => getValue(obj, key)
34 | });
35 |
36 | assert.ok(result.ok);
37 | assert.strictEqual(result.value.name, 'default');
38 | });
39 | });
40 |
41 | describe('nested object access', () => {
42 | it('should resolve deeply nested properties', async () => {
43 | const schema: JSONSchema = {
44 | type: 'object',
45 | properties: {
46 | user: {
47 | type: 'object',
48 | properties: {
49 | profile: {
50 | type: 'object',
51 | properties: {
52 | details: {
53 | type: 'object',
54 | properties: {
55 | name: { type: 'string' }
56 | }
57 | }
58 | }
59 | }
60 | }
61 | }
62 | }
63 | };
64 |
65 | const data = {
66 | user: {
67 | profile: {
68 | details: {
69 | name: 'John Doe'
70 | }
71 | }
72 | }
73 | };
74 |
75 | const result = await resolveValues(schema, data, {
76 | getValue: (obj, key) => getValue(obj, key)
77 | });
78 |
79 | assert.ok(result.ok);
80 | assert.strictEqual(result.value.user.profile.details.name, 'John Doe');
81 | });
82 |
83 | it('should handle missing nested properties', async () => {
84 | const schema: JSONSchema = {
85 | type: 'object',
86 | properties: {
87 | user: {
88 | type: 'object',
89 | properties: {
90 | profile: {
91 | type: 'object',
92 | properties: {
93 | name: { type: 'string', default: 'Anonymous' }
94 | }
95 | }
96 | }
97 | }
98 | }
99 | };
100 |
101 | const result = await resolveValues(schema, { user: {} }, {
102 | getValue: (obj, key) => getValue(obj, key)
103 | });
104 |
105 | assert.ok(result.ok);
106 | assert.strictEqual(result.value.user.profile.name, 'Anonymous');
107 | });
108 | });
109 |
110 | describe('array access', () => {
111 | it('should resolve array indices', async () => {
112 | const schema: JSONSchema = {
113 | type: 'array',
114 | items: {
115 | type: 'object',
116 | properties: {
117 | id: { type: 'number' }
118 | }
119 | }
120 | };
121 |
122 | const data = [
123 | { id: 1 },
124 | { id: 2 },
125 | { id: 3 }
126 | ];
127 |
128 | const result = await resolveValues(schema, data, {
129 | getValue: (obj, key) => getValue(obj, key)
130 | });
131 |
132 | assert.ok(result.ok);
133 | assert.strictEqual(result.value[0].id, 1);
134 | assert.strictEqual(result.value[1].id, 2);
135 | assert.strictEqual(result.value[2].id, 3);
136 | });
137 |
138 | it('should handle nested arrays', async () => {
139 | const schema: JSONSchema = {
140 | type: 'object',
141 | properties: {
142 | matrix: {
143 | type: 'array',
144 | items: {
145 | type: 'array',
146 | items: { type: 'number' }
147 | }
148 | }
149 | }
150 | };
151 |
152 | const data = {
153 | matrix: [
154 | [1, 2, 3],
155 | [4, 5, 6],
156 | [7, 8, 9]
157 | ]
158 | };
159 |
160 | const result = await resolveValues(schema, data, {
161 | getValue: (obj, key) => getValue(obj, key)
162 | });
163 |
164 | assert.ok(result.ok);
165 | assert.deepStrictEqual(result.value.matrix, [
166 | [1, 2, 3],
167 | [4, 5, 6],
168 | [7, 8, 9]
169 | ]);
170 | });
171 | });
172 |
173 | describe('complex paths', () => {
174 | it('should handle array access within nested objects', async () => {
175 | const schema: JSONSchema = {
176 | type: 'object',
177 | properties: {
178 | users: {
179 | type: 'array',
180 | items: {
181 | type: 'object',
182 | properties: {
183 | addresses: {
184 | type: 'array',
185 | items: {
186 | type: 'object',
187 | properties: {
188 | street: { type: 'string' }
189 | }
190 | }
191 | }
192 | }
193 | }
194 | }
195 | }
196 | };
197 |
198 | const data = {
199 | users: [
200 | {
201 | addresses: [
202 | { street: '123 Main St' },
203 | { street: '456 Oak Ave' }
204 | ]
205 | },
206 | {
207 | addresses: [
208 | { street: '789 Pine Rd' }
209 | ]
210 | }
211 | ]
212 | };
213 |
214 | const result = await resolveValues(schema, data, {
215 | getValue: (obj, key) => getValue(obj, key)
216 | });
217 |
218 | assert.ok(result.ok);
219 | assert.strictEqual(result.value.users[0].addresses[0].street, '123 Main St');
220 | assert.strictEqual(result.value.users[0].addresses[1].street, '456 Oak Ave');
221 | assert.strictEqual(result.value.users[1].addresses[0].street, '789 Pine Rd');
222 | });
223 |
224 | it('should handle object paths with special characters', async () => {
225 | const schema: JSONSchema = {
226 | type: 'object',
227 | properties: {
228 | 'special.key': {
229 | type: 'object',
230 | properties: {
231 | 'nested.value': { type: 'string' }
232 | }
233 | }
234 | }
235 | };
236 |
237 | const data = {
238 | 'special.key': {
239 | 'nested.value': 'test'
240 | }
241 | };
242 |
243 | const result = await resolveValues(schema, data, {
244 | getValue: (obj, key) => getValue(obj, key, { separator: '.' })
245 | });
246 |
247 | assert.ok(result.ok);
248 | assert.strictEqual(result.value['special.key']['nested.value'], 'test');
249 | });
250 | });
251 |
252 | describe('error handling', () => {
253 | it('should handle null values in path', async () => {
254 | const schema: JSONSchema = {
255 | type: 'object',
256 | properties: {
257 | user: {
258 | type: 'object',
259 | properties: {
260 | name: { type: 'string', default: 'Anonymous' }
261 | }
262 | }
263 | }
264 | };
265 |
266 | const result = await resolveValues(schema, { user: null }, {
267 | getValue: (obj, key) => getValue(obj, key)
268 | });
269 |
270 | assert.ok(result.ok);
271 | assert.strictEqual(result.value.user.name, 'Anonymous');
272 | });
273 |
274 | it('should handle undefined values in nested paths', async () => {
275 | const schema: JSONSchema = {
276 | type: 'object',
277 | properties: {
278 | deeply: {
279 | type: 'object',
280 | properties: {
281 | nested: {
282 | type: 'object',
283 | properties: {
284 | value: { type: 'string', default: 'default' }
285 | }
286 | }
287 | }
288 | }
289 | }
290 | };
291 |
292 | const result = await resolveValues(schema, { deeply: { nested: undefined } }, {
293 | getValue: (obj, key) => getValue(obj, key)
294 | });
295 |
296 | assert.ok(result.ok);
297 | assert.strictEqual(result.value.deeply.nested.value, 'default');
298 | });
299 | });
300 | });
301 |
--------------------------------------------------------------------------------
/test/resolve.test.ts:
--------------------------------------------------------------------------------
1 | import type { JSONSchema } from '~/types';
2 | import assert from 'node:assert';
3 | import { resolveValues } from '~/resolve';
4 |
5 | describe('resolve', () => {
6 | describe('required properties', () => {
7 | it('should validate required properties', async () => {
8 | const schema: JSONSchema = {
9 | type: 'object',
10 | properties: {
11 | something: { type: 'string', minLength: 1 },
12 | other: { type: 'string' }
13 | },
14 | required: ['something']
15 | };
16 |
17 | const result = await resolveValues(schema, {});
18 | assert.ok(!result.ok);
19 | assert.strictEqual(result.errors.length, 1);
20 | assert.strictEqual(result.errors[0].message, 'Missing required property: something');
21 | });
22 |
23 | it('should validate missing required properties', async () => {
24 | const schema: JSONSchema = {
25 | type: 'object',
26 | properties: {
27 | something: { type: 'string' },
28 | other: { type: 'string' }
29 | },
30 | required: ['something']
31 | };
32 |
33 | const result = await resolveValues(schema, {});
34 | assert.ok(!result.ok);
35 | assert.strictEqual(result.errors.length, 1);
36 | assert.strictEqual(result.errors[0].message, 'Missing required property: something');
37 | });
38 |
39 | it('should validate nested missing required properties', async () => {
40 | const schema: JSONSchema = {
41 | type: 'object',
42 | properties: {
43 | steps: {
44 | type: 'object',
45 | required: ['nonexistent_prop']
46 | }
47 | }
48 | };
49 |
50 | const result = await resolveValues(schema, { steps: {} });
51 | assert.ok(!result.ok);
52 | assert.strictEqual(result.errors.length, 1);
53 | assert.strictEqual(result.errors[0].message, 'Missing required property: nonexistent_prop');
54 | });
55 |
56 | it('should not ignore required names when no properties exist', async () => {
57 | const schema: JSONSchema = {
58 | type: 'object',
59 | required: ['foo', 'bar']
60 | };
61 |
62 | const result = await resolveValues(schema, {});
63 | assert.ok(!result.ok);
64 | });
65 |
66 | it('should ignore required names on items when no items are passed', async () => {
67 | const schema: JSONSchema = {
68 | type: 'array',
69 | items: {
70 | type: 'object',
71 | required: ['id', 'name']
72 | }
73 | };
74 |
75 | const result = await resolveValues(schema);
76 | assert.ok(result.ok);
77 | });
78 |
79 | it('should not ignore required names on items', async () => {
80 | const schema: JSONSchema = {
81 | type: 'array',
82 | items: {
83 | type: 'object',
84 | required: ['id', 'name']
85 | }
86 | };
87 |
88 | const result = await resolveValues(schema, [{}]);
89 | assert.ok(!result.ok);
90 | assert.equal(result.errors[0].message, 'Missing required property: id');
91 | assert.equal(result.errors[1].message, 'Missing required property: name');
92 | });
93 |
94 | it('should throw an error when "items" value is not an array', async () => {
95 | const schema: JSONSchema = {
96 | type: 'array',
97 | items: {
98 | type: 'object',
99 | required: ['id', 'name']
100 | }
101 | };
102 |
103 | const result = await resolveValues(schema, {});
104 | assert.ok(!result.ok);
105 | assert.strictEqual(result.errors[0].message, 'Value must be an array');
106 | });
107 |
108 | it('should use default value on required properties without an error', async () => {
109 | const schema: JSONSchema = {
110 | type: 'object',
111 | properties: {
112 | something: { type: 'string', minLength: 1, default: 'foo' },
113 | other: { type: 'string' }
114 | },
115 | required: ['something']
116 | };
117 |
118 | const result = await resolveValues(schema, {});
119 | assert.ok(result.ok);
120 | });
121 |
122 | it('should handle missing required nested field', async () => {
123 | const schema: JSONSchema = {
124 | type: 'object',
125 | properties: {
126 | user: {
127 | type: 'object',
128 | properties: {
129 | name: { type: 'string', minLength: 1 }
130 | },
131 | required: ['name']
132 | }
133 | }
134 | };
135 |
136 | const result = await resolveValues(schema, {
137 | user: {
138 | settings: {}
139 | }
140 | });
141 |
142 | assert.ok(!result.ok);
143 | assert.strictEqual(result.errors.length, 1);
144 | assert.strictEqual(result.errors[0].message, 'Missing required property: name');
145 | });
146 | });
147 |
148 | describe('string validation', () => {
149 | it('should validate string minimum length', async () => {
150 | const schema: JSONSchema = {
151 | type: 'object',
152 | properties: {
153 | name: {
154 | type: 'string',
155 | minLength: 3
156 | },
157 | other: {
158 | type: 'string'
159 | }
160 | }
161 | };
162 |
163 | const result = await resolveValues(schema, {
164 | name: 'ab'
165 | });
166 |
167 | assert.ok(!result.ok);
168 | assert.strictEqual(result.errors.length, 1);
169 | assert.strictEqual(result.errors[0].message, 'String length must be >= 3');
170 | });
171 |
172 | it('should validate string minimum length with multiple properties', async () => {
173 | const schema: JSONSchema = {
174 | type: 'object',
175 | properties: {
176 | name: {
177 | type: 'string',
178 | minLength: 3
179 | },
180 | other: {
181 | type: 'string'
182 | }
183 | }
184 | };
185 |
186 | const result = await resolveValues(schema, {
187 | name: 'ab'
188 | });
189 |
190 | assert.ok(!result.ok);
191 | assert.strictEqual(result.errors.length, 1);
192 | assert.strictEqual(result.errors[0].message, 'String length must be >= 3');
193 | });
194 |
195 | it('should correctly calculate length of strings with emoji', async () => {
196 | const schema: JSONSchema = {
197 | type: 'object',
198 | properties: {
199 | text: {
200 | type: 'string',
201 | minLength: 3,
202 | maxLength: 5
203 | }
204 | }
205 | };
206 |
207 | // '👨👩👧👦' is a single grapheme (family emoji) but multiple code points
208 | const tooShort = await resolveValues(schema, { text: '👨👩👧👦a' });
209 | assert.ok(!tooShort.ok);
210 | assert.strictEqual(tooShort.errors[0].message, 'String length must be >= 3');
211 |
212 | // 'abc👨👩👧👦de' is 6 graphemes
213 | const tooLong = await resolveValues(schema, { text: 'abc👨👩👧👦de' });
214 | assert.ok(!tooLong.ok);
215 | assert.strictEqual(tooLong.errors[0].message, 'String length must be <= 5');
216 |
217 | // 'a👨👩👧👦b' is 3 graphemes
218 | const justRight = await resolveValues(schema, { text: 'a👨👩👧👦b' });
219 | assert.ok(justRight.ok);
220 | });
221 |
222 | it('should handle combining characters correctly', async () => {
223 | const schema: JSONSchema = {
224 | type: 'object',
225 | properties: {
226 | text: {
227 | type: 'string',
228 | minLength: 3,
229 | maxLength: 5
230 | }
231 | }
232 | };
233 |
234 | // 'é' can be composed of 'e' + '´' (combining acute accent)
235 | const combining = await resolveValues(schema, { text: 'café' }); // should be 4 graphemes
236 | assert.ok(combining.ok);
237 |
238 | const decomposed = await resolveValues(schema, { text: 'cafe\u0301' }); // same text with decomposed é
239 | assert.ok(decomposed.ok);
240 | });
241 |
242 | it('should validate string maximum length', async () => {
243 | const schema: JSONSchema = {
244 | type: 'object',
245 | properties: {
246 | name: {
247 | type: 'string',
248 | maxLength: 10
249 | },
250 | other: {
251 | type: 'string'
252 | }
253 | }
254 | };
255 |
256 | const result = await resolveValues(schema, {
257 | name: 'this is too long'
258 | });
259 |
260 | assert.ok(!result.ok);
261 | assert.strictEqual(result.errors.length, 1);
262 | assert.strictEqual(result.errors[0].message, 'String length must be <= 10');
263 | });
264 |
265 | it('should validate string constraints as valid', async () => {
266 | const schema: JSONSchema = {
267 | type: 'object',
268 | properties: {
269 | name: {
270 | type: 'string',
271 | minLength: 3,
272 | maxLength: 10
273 | },
274 | other: {
275 | type: 'string'
276 | }
277 | },
278 | required: ['other']
279 | };
280 |
281 | const result = await resolveValues(schema, {
282 | name: 'valid',
283 | other: 'something'
284 | });
285 |
286 | assert.ok(result.ok);
287 | assert.strictEqual(result.value.name, 'valid');
288 | });
289 | });
290 |
291 | describe('number validation', () => {
292 | it('should validate number type', async () => {
293 | const schema: JSONSchema = {
294 | type: 'object',
295 | properties: {
296 | age: {
297 | type: 'number'
298 | },
299 | count: {
300 | type: 'integer'
301 | }
302 | }
303 | };
304 |
305 | const result = await resolveValues(schema, {
306 | age: 'not a number'
307 | });
308 |
309 | assert.ok(!result.ok);
310 | assert.strictEqual(result.errors.length, 1);
311 | assert.strictEqual(result.errors[0].message, 'Value must be a number');
312 | });
313 |
314 | it('should validate integer type', async () => {
315 | const schema: JSONSchema = {
316 | type: 'object',
317 | properties: {
318 | count: {
319 | type: 'integer'
320 | },
321 | other: {
322 | type: 'string'
323 | }
324 | }
325 | };
326 |
327 | const result = await resolveValues(schema, {
328 | count: 1.5
329 | });
330 |
331 | assert.ok(!result.ok);
332 | assert.strictEqual(result.errors.length, 1);
333 | assert.strictEqual(result.errors[0].message, 'Value must be an integer');
334 | });
335 |
336 | it('should validate required integer', async () => {
337 | const schema: JSONSchema = {
338 | type: 'object',
339 | properties: {
340 | count: {
341 | type: 'integer'
342 | },
343 | other: {
344 | type: 'string'
345 | }
346 | },
347 | required: ['count']
348 | };
349 |
350 | const result = await resolveValues(schema, {
351 | count: 1.1
352 | });
353 |
354 | assert.ok(!result.ok);
355 | assert.strictEqual(result.errors.length, 1);
356 | assert.strictEqual(result.errors[0].message, 'Value must be an integer');
357 | });
358 |
359 | it('should validate number constraints - range', async () => {
360 | const schema: JSONSchema = {
361 | type: 'object',
362 | properties: {
363 | age: {
364 | type: 'number',
365 | minimum: 0
366 | },
367 | count: {
368 | type: 'integer',
369 | minimum: 1
370 | }
371 | }
372 | };
373 |
374 | const result = await resolveValues(schema, {
375 | age: -1,
376 | count: 0
377 | });
378 |
379 | assert.ok(!result.ok);
380 | assert.strictEqual(result.errors.length, 2);
381 | assert.strictEqual(result.errors[0].message, 'Value must be >= 0');
382 | assert.strictEqual(result.errors[1].message, 'Value must be >= 1');
383 | });
384 |
385 | it('should validate valid numbers', async () => {
386 | const schema: JSONSchema = {
387 | type: 'object',
388 | properties: {
389 | age: { type: 'number', minimum: 0 },
390 | count: { type: 'integer', minimum: 1 }
391 | }
392 | };
393 |
394 | const result = await resolveValues(schema, {
395 | age: 25,
396 | count: 5
397 | });
398 |
399 | assert.ok(result.ok);
400 | });
401 | });
402 |
403 | describe('if/then conditionals', () => {
404 | it('should pass validation when no conditions are present', async () => {
405 | const schema: JSONSchema = {
406 | type: 'object',
407 | properties: {
408 | type: { type: 'string', enum: ['personal'] }
409 | }
410 | };
411 |
412 | const result = await resolveValues(schema, {
413 | type: 'personal'
414 | });
415 |
416 | assert.ok(result.ok);
417 | });
418 |
419 | it('should enforce required fields based on conditional', async () => {
420 | const schema: JSONSchema = {
421 | type: 'object',
422 | properties: {
423 | type: { type: 'string', enum: ['business'] },
424 | taxId: { type: 'string', pattern: '^\\d{9}$' }
425 | },
426 | if: {
427 | properties: { type: { const: 'business' } }
428 | },
429 | then: {
430 | required: ['taxId']
431 | }
432 | };
433 |
434 | const result = await resolveValues(schema, {
435 | type: 'business'
436 | });
437 |
438 | assert.ok(!result.ok);
439 | assert.strictEqual(result.errors.length, 1);
440 | assert.strictEqual(result.errors[0].message, 'Missing required property: taxId');
441 | });
442 |
443 | it('should handle regex special characters in patterns', async () => {
444 | const schema: JSONSchema = {
445 | type: 'object',
446 | properties: {
447 | text: {
448 | type: 'string',
449 | pattern: 'hello\\(world\\)'
450 | }
451 | }
452 | };
453 |
454 | const result1 = await resolveValues(schema, { text: 'hello(world)' });
455 | assert.ok(result1.ok);
456 |
457 | const result2 = await resolveValues(schema, { text: 'helloworld' });
458 | assert.ok(!result2.ok);
459 | });
460 |
461 | it('should support Unicode patterns', async () => {
462 | const schema: JSONSchema = {
463 | type: 'object',
464 | properties: {
465 | text: {
466 | type: 'string',
467 | pattern: '^[\\p{L}]+$' // Unicode letter category
468 | }
469 | }
470 | };
471 |
472 | const result1 = await resolveValues(schema, { text: 'HelloПривет你好' }); // mixed scripts
473 | assert.ok(result1.ok);
474 |
475 | const result2 = await resolveValues(schema, { text: 'Hello123' }); // includes numbers
476 | assert.ok(!result2.ok);
477 | });
478 |
479 | it('should validate field patterns when condition is met', async () => {
480 | const schema: JSONSchema = {
481 | type: 'object',
482 | properties: {
483 | type: { type: 'string', enum: ['business'] },
484 | taxId: { type: 'string', pattern: '^\\d{9}$' }
485 | },
486 | if: {
487 | properties: { type: { const: 'business' } }
488 | },
489 | then: {
490 | required: ['taxId']
491 | }
492 | };
493 |
494 | const result = await resolveValues(schema, {
495 | type: 'business',
496 | taxId: '12345'
497 | });
498 |
499 | assert.ok(!result.ok);
500 | assert.strictEqual(result.errors.length, 1);
501 | assert.strictEqual(result.errors[0].message, 'String must match pattern: ^\\d{9}$');
502 | });
503 |
504 | it('should pass validation when condition and pattern are satisfied', async () => {
505 | const schema: JSONSchema = {
506 | type: 'object',
507 | properties: {
508 | type: { type: 'string', enum: ['business'] },
509 | taxId: { type: 'string', pattern: '^\\d{9}$' }
510 | },
511 | if: {
512 | properties: { type: { const: 'business' } }
513 | },
514 | then: {
515 | required: ['taxId']
516 | }
517 | };
518 |
519 | const result = await resolveValues(schema, {
520 | type: 'business',
521 | taxId: '123456789'
522 | });
523 |
524 | assert.ok(result.ok);
525 | });
526 | });
527 |
528 | describe('arrays', () => {
529 | it('should handle array default', async () => {
530 | const schema: JSONSchema = {
531 | type: 'object',
532 | properties: {
533 | tags: {
534 | type: 'array',
535 | items: { type: 'string', minLength: 2 },
536 | default: ['foo', 'bar', 'baz']
537 | }
538 | }
539 | };
540 |
541 | const result = await resolveValues(schema, {});
542 | assert.ok(result.ok);
543 | assert.deepStrictEqual(result.value.tags, ['foo', 'bar', 'baz']);
544 | });
545 |
546 | it('should be undefined when no tags or default is defined', async () => {
547 | const schema: JSONSchema = {
548 | type: 'object',
549 | properties: {
550 | tags: {
551 | type: 'array',
552 | items: { type: 'string' }
553 | }
554 | }
555 | };
556 |
557 | const result = await resolveValues(schema, {});
558 | assert.ok(result.ok);
559 | assert.deepEqual(result.value.tags, undefined);
560 | });
561 |
562 | it('should handle array constraints', async () => {
563 | const schema: JSONSchema = {
564 | type: 'object',
565 | properties: {
566 | tags: {
567 | type: 'array',
568 | items: { type: 'string', minLength: 2 },
569 | minItems: 1,
570 | maxItems: 3,
571 | uniqueItems: true
572 | }
573 | }
574 | };
575 |
576 | const result = await resolveValues(schema, {
577 | tags: ['a', 'b', 'b', 'c', 'd']
578 | });
579 |
580 | assert.ok(!result.ok);
581 | assert.strictEqual(result.errors.length, 2);
582 | });
583 |
584 | it('should use array from args over default', async () => {
585 | const schema: JSONSchema = {
586 | type: 'object',
587 | properties: {
588 | tags: {
589 | type: 'array',
590 | items: { type: 'string', minLength: 2 },
591 | minItems: 1,
592 | maxItems: 3,
593 | uniqueItems: true,
594 | default: ['foo', 'bar']
595 | }
596 | }
597 | };
598 |
599 | const result = await resolveValues(schema, {
600 | tags: ['tag1', 'tag2', 'tag3']
601 | });
602 |
603 | assert.ok(result.ok);
604 | assert.deepEqual(result.value.tags, ['tag1', 'tag2', 'tag3']);
605 | });
606 | });
607 |
608 | describe('nested objects', () => {
609 | it('should handle nested object defaults', async () => {
610 | const schema: JSONSchema = {
611 | type: 'object',
612 | properties: {
613 | user: {
614 | type: 'object',
615 | properties: {
616 | name: { type: 'string', minLength: 1 },
617 | settings: {
618 | type: 'object',
619 | properties: {
620 | theme: { type: 'string', default: 'light' },
621 | notifications: { type: 'boolean', default: true }
622 | }
623 | }
624 | },
625 | required: ['name']
626 | }
627 | }
628 | };
629 |
630 | const result = await resolveValues(schema, {
631 | user: {
632 | name: 'test',
633 | settings: {}
634 | }
635 | });
636 |
637 | assert.ok(result.ok);
638 | assert.strictEqual(result.value.user.settings.theme, 'light');
639 | assert.strictEqual(result.value.user.settings.notifications, true);
640 | });
641 | });
642 |
643 | describe('schema composition', () => {
644 | describe('allOf', () => {
645 | it('should resolve allOf composition', async () => {
646 | const schema: JSONSchema = {
647 | type: 'object',
648 | allOf: [
649 | {
650 | properties: {
651 | name: { type: 'string', default: 'John' }
652 | }
653 | },
654 | {
655 | properties: {
656 | age: { type: 'number', default: 30 }
657 | }
658 | }
659 | ]
660 | };
661 |
662 | const result = await resolveValues(schema, {});
663 | assert.ok(result.ok);
664 | assert.deepStrictEqual(result.value, {
665 | name: 'John',
666 | age: 30
667 | });
668 | });
669 |
670 | it('should resolve allOf composition with required properties and defaults', async () => {
671 | const schema: JSONSchema = {
672 | type: 'object',
673 | allOf: [
674 | {
675 | properties: {
676 | name: { type: 'string', default: 'John' },
677 | email: { type: 'string' }
678 | },
679 | required: ['email']
680 | },
681 | {
682 | properties: {
683 | age: { type: 'number', default: 30 },
684 | country: { type: 'string' }
685 | },
686 | required: ['country']
687 | }
688 | ]
689 | };
690 |
691 | const result = await resolveValues(schema, {
692 | email: 'john@example.com',
693 | country: 'USA'
694 | });
695 |
696 | assert.ok(result.ok);
697 | assert.deepStrictEqual(result.value, {
698 | name: 'John',
699 | email: 'john@example.com',
700 | age: 30,
701 | country: 'USA'
702 | });
703 | });
704 |
705 | it('should fail when required properties are missing in allOf composition', async () => {
706 | const schema: JSONSchema = {
707 | type: 'object',
708 | allOf: [
709 | {
710 | properties: {
711 | username: { type: 'string' },
712 | password: { type: 'string' }
713 | },
714 | required: ['username', 'password']
715 | },
716 | {
717 | properties: {
718 | role: { type: 'string' },
719 | active: { type: 'boolean', default: true }
720 | },
721 | required: ['role']
722 | }
723 | ]
724 | };
725 |
726 | const result = await resolveValues(schema, {
727 | username: 'johndoe'
728 | // missing password and role
729 | });
730 |
731 | assert.ok(!result.ok);
732 | assert.strictEqual(result.errors.length, 2);
733 | });
734 | });
735 |
736 | describe('anyOf', () => {
737 | it('should resolve anyOf composition with string', async () => {
738 | const schema: JSONSchema = {
739 | type: 'object',
740 | properties: {
741 | value: {
742 | anyOf: [{ type: 'string' }],
743 | default: 'default'
744 | }
745 | }
746 | };
747 |
748 | const result = await resolveValues(schema, { value: 'test' });
749 | assert.ok(result.ok);
750 | assert.strictEqual(result.value.value, 'test');
751 | });
752 |
753 | it('should resolve anyOf composition with number', async () => {
754 | const schema: JSONSchema = {
755 | type: 'object',
756 | properties: {
757 | value: {
758 | anyOf: [{ type: 'number' }],
759 | default: 'default'
760 | }
761 | }
762 | };
763 |
764 | const result = await resolveValues(schema, { value: 42 });
765 | assert.ok(result.ok);
766 | assert.strictEqual(result.value.value, 42);
767 | });
768 |
769 | it('should resolve anyOf composition with default', async () => {
770 | const schema: JSONSchema = {
771 | type: 'object',
772 | properties: {
773 | value: {
774 | anyOf: [{ type: 'string' }, { type: 'number' }],
775 | default: 'default'
776 | }
777 | }
778 | };
779 |
780 | const result = await resolveValues(schema, {});
781 | assert.ok(result.ok);
782 | assert.strictEqual(result.value.value, 'default');
783 | });
784 | });
785 |
786 | describe('oneOf', () => {
787 | it('should resolve oneOf composition - valid', async () => {
788 | const schema: JSONSchema = {
789 | type: 'object',
790 | properties: {
791 | value: {
792 | oneOf: [{ type: 'number', minimum: 0 }]
793 | }
794 | }
795 | };
796 |
797 | const result = await resolveValues(schema, { value: 5 });
798 | assert.ok(result.ok);
799 | assert.strictEqual(result.value.value, 5);
800 | });
801 |
802 | it('should resolve oneOf composition - invalid', async () => {
803 | const schema: JSONSchema = {
804 | type: 'object',
805 | properties: {
806 | value: {
807 | oneOf: [{ type: 'number', maximum: 0 }]
808 | }
809 | }
810 | };
811 | const result = await resolveValues(schema, { value: 1 });
812 | assert.ok(!result.ok);
813 | assert.strictEqual(result.errors.length, 1);
814 | });
815 |
816 | it('should resolve oneOf composition with default', async () => {
817 | const schema: JSONSchema = {
818 | type: 'object',
819 | properties: {
820 | value: {
821 | oneOf: [
822 | { type: 'number', minimum: 0 },
823 | { type: 'string', minLength: 1 }
824 | ],
825 | default: 'default'
826 | }
827 | }
828 | };
829 |
830 | const result = await resolveValues(schema, {});
831 | assert.ok(result.ok);
832 | assert.strictEqual(result.value.value, 'default');
833 | });
834 | });
835 | });
836 |
837 | describe('array items', () => {
838 | it('should resolve array items', async () => {
839 | const schema: JSONSchema = {
840 | type: 'array',
841 | items: {
842 | type: 'object',
843 | properties: {
844 | id: { type: 'number' },
845 | name: { type: 'string', default: 'unnamed' }
846 | }
847 | }
848 | };
849 |
850 | const result = await resolveValues(schema, [{ id: 1 }, { id: 2, name: 'test' }]);
851 | assert.ok(result.ok);
852 | assert.deepStrictEqual(result.value, [
853 | { id: 1, name: 'unnamed' },
854 | { id: 2, name: 'test' }
855 | ]);
856 | });
857 |
858 | it('should resolve array default value', async () => {
859 | const schema: JSONSchema = {
860 | type: 'array',
861 | items: {
862 | type: 'object',
863 | properties: {
864 | id: { type: 'number' },
865 | name: { type: 'string', default: 'unnamed' }
866 | }
867 | },
868 | default: [{ id: 0, name: 'default' }]
869 | };
870 |
871 | const result = await resolveValues(schema);
872 | assert.ok(result.ok);
873 | assert.deepStrictEqual(result.value, [
874 | { id: 0, name: 'default' }
875 | ]);
876 | });
877 |
878 | it('should resolve cascading array default values', async () => {
879 | const schema: JSONSchema = {
880 | type: 'array',
881 | items: {
882 | type: 'object',
883 | properties: {
884 | id: { type: 'number' },
885 | name: { type: 'string', default: 'unnamed' }
886 | }
887 | },
888 | default: [{ id: 0 }]
889 | };
890 |
891 | const result = await resolveValues(schema);
892 | assert.ok(result.ok);
893 | assert.deepStrictEqual(result.value, [
894 | { id: 0, name: 'unnamed' }
895 | ]);
896 | });
897 |
898 | it('should resolve nested cascading array default values', async () => {
899 | const schema: JSONSchema = {
900 | type: 'object',
901 | properties: {
902 | list: {
903 | type: 'array',
904 | items: {
905 | type: 'object',
906 | properties: {
907 | id: { type: 'number' },
908 | name: { type: 'string', default: 'unnamed' }
909 | }
910 | },
911 | default: [{ id: 0 }]
912 | }
913 | }
914 | };
915 |
916 | const result = await resolveValues(schema);
917 | assert.ok(result.ok);
918 | assert.deepStrictEqual(result.value, {
919 | list: [
920 | { id: 0, name: 'unnamed' }
921 | ]
922 | });
923 | });
924 |
925 | it('should resolve partial value from defaults', async () => {
926 | const schema: JSONSchema = {
927 | type: 'object',
928 | properties: {
929 | list: {
930 | type: 'array',
931 | items: {
932 | type: 'object',
933 | properties: {
934 | id: { type: 'number' },
935 | name: { type: 'string', default: 'unnamed' }
936 | }
937 | },
938 | default: [{ id: 0 }]
939 | }
940 | }
941 | };
942 |
943 | const result = await resolveValues(schema, { list: [{ id: 1 }] });
944 | assert.ok(result.ok);
945 | assert.deepStrictEqual(result.value, {
946 | list: [
947 | { id: 1, name: 'unnamed' }
948 | ]
949 | });
950 | });
951 |
952 | it('should reject invalid value for array type', async () => {
953 | const schema: JSONSchema = {
954 | type: 'object',
955 | properties: {
956 | list: {
957 | type: 'array',
958 | items: {
959 | type: 'object',
960 | properties: {
961 | id: { type: 'number' },
962 | name: { type: 'string', default: 'unnamed' }
963 | }
964 | },
965 | default: [{ id: 0 }]
966 | }
967 | }
968 | };
969 |
970 | const result = await resolveValues(schema, { list: true });
971 | assert.ok(!result.ok);
972 | assert.strictEqual(result.errors.length, 1);
973 | });
974 |
975 | it('should reject invalid array values', async () => {
976 | const schema: JSONSchema = {
977 | type: 'object',
978 | properties: {
979 | list: {
980 | type: 'array',
981 | items: {
982 | type: 'object',
983 | properties: {
984 | id: { type: 'number' },
985 | name: { type: 'string', default: 'unnamed' }
986 | }
987 | },
988 | default: [{ id: 0 }]
989 | }
990 | }
991 | };
992 |
993 | const result = await resolveValues(schema, { list: [{ id: true }] });
994 | assert.ok(!result.ok);
995 | assert.strictEqual(result.errors.length, 1);
996 | });
997 | });
998 |
999 | describe('dependent schemas', () => {
1000 | it('should resolve dependent schemas', async () => {
1001 | const schema: JSONSchema = {
1002 | type: 'object',
1003 | properties: {
1004 | credit_card: { type: 'string' }
1005 | },
1006 | dependentSchemas: {
1007 | credit_card: {
1008 | properties: {
1009 | billing_address: { type: 'string', default: '111222 abc' }
1010 | }
1011 | }
1012 | }
1013 | };
1014 |
1015 | const result = await resolveValues(schema, {
1016 | credit_card: '1234-5678-9012-3456'
1017 | });
1018 |
1019 | assert.ok(result.ok);
1020 | assert.deepStrictEqual(result.value, {
1021 | credit_card: '1234-5678-9012-3456',
1022 | billing_address: '111222 abc'
1023 | });
1024 | });
1025 | });
1026 |
1027 | describe('pattern properties', () => {
1028 | it('should resolve pattern properties', async () => {
1029 | const schema: JSONSchema = {
1030 | type: 'object',
1031 | patternProperties: {
1032 | '^S_': {
1033 | type: 'string',
1034 | default: 'string'
1035 | },
1036 | '^N_': {
1037 | type: 'number',
1038 | default: 0
1039 | }
1040 | }
1041 | };
1042 |
1043 | const result = await resolveValues(schema, {
1044 | S_name: 'test',
1045 | N_age: 25,
1046 | other: 'value'
1047 | });
1048 |
1049 | assert.ok(result.ok);
1050 | assert.strictEqual(result.value.S_name, 'test');
1051 | assert.strictEqual(result.value.N_age, 25);
1052 | assert.strictEqual(result.value.other, 'value');
1053 | });
1054 |
1055 | describe('pattern properties with special characters', () => {
1056 | it('should handle regex special characters in property patterns', async () => {
1057 | const schema: JSONSchema = {
1058 | type: 'object',
1059 | patternProperties: {
1060 | '^\\[.*\\]$': {
1061 | // matches properties wrapped in square brackets
1062 | type: 'string'
1063 | }
1064 | }
1065 | };
1066 |
1067 | const result = await resolveValues(schema, {
1068 | '[test]': 'value',
1069 | 'normalKey': 'other'
1070 | });
1071 |
1072 | assert.ok(result.ok);
1073 | assert.strictEqual(result.value['[test]'], 'value');
1074 | });
1075 |
1076 | it('should support Unicode in property patterns', async () => {
1077 | const schema: JSONSchema = {
1078 | type: 'object',
1079 | patternProperties: {
1080 | '^[\\p{Script=Cyrillic}]+$': {
1081 | // matches Cyrillic property names
1082 | type: 'string'
1083 | }
1084 | }
1085 | };
1086 |
1087 | const result = await resolveValues(schema, {
1088 | привет: 'hello',
1089 | hello: 'world'
1090 | });
1091 |
1092 | assert.ok(result.ok);
1093 | assert.strictEqual(result.value['привет'], 'hello');
1094 | });
1095 | });
1096 | });
1097 |
1098 | describe('additional properties', () => {
1099 | it('should handle additional properties', async () => {
1100 | const schema: JSONSchema = {
1101 | type: 'object',
1102 | properties: {
1103 | name: { type: 'string' }
1104 | },
1105 | additionalProperties: {
1106 | type: 'string',
1107 | default: 'additional'
1108 | }
1109 | };
1110 |
1111 | const result = await resolveValues(schema, {
1112 | name: 'test',
1113 | extra: 'value'
1114 | });
1115 |
1116 | assert.ok(result.ok);
1117 | assert.deepStrictEqual(result.value, {
1118 | name: 'test',
1119 | extra: 'value'
1120 | });
1121 | });
1122 | });
1123 |
1124 | describe('const and enum values', () => {
1125 | it('should resolve const values', async () => {
1126 | const schema: JSONSchema = {
1127 | type: 'object',
1128 | properties: {
1129 | status: { type: 'string', const: 'active' }
1130 | }
1131 | };
1132 |
1133 | const result = await resolveValues(schema, {
1134 | status: 'anything'
1135 | });
1136 |
1137 | assert.ok(!result.ok);
1138 | assert.strictEqual(result.errors.length, 1);
1139 | assert.strictEqual(result.errors[0].message, 'Value must be active');
1140 | });
1141 |
1142 | it('should resolve enum values', async () => {
1143 | const schema: JSONSchema = {
1144 | type: 'object',
1145 | properties: {
1146 | role: { type: 'string', enum: ['admin', 'user'], default: 'user' }
1147 | }
1148 | };
1149 |
1150 | const result = await resolveValues(schema, {
1151 | role: 'invalid'
1152 | });
1153 |
1154 | assert.ok(!result.ok);
1155 | assert.strictEqual(result.errors.length, 1);
1156 | assert.strictEqual(result.errors[0].message, 'Value must be one of: admin, user');
1157 | });
1158 | });
1159 |
1160 | describe('basic types', () => {
1161 | it('should resolve basic types with defaults', async () => {
1162 | const schema: JSONSchema = {
1163 | type: 'object',
1164 | properties: {
1165 | str: { type: 'string', default: 'default' },
1166 | num: { type: 'number', default: 42 },
1167 | bool: { type: 'boolean', default: true },
1168 | arr: { type: 'array', default: [] },
1169 | obj: { type: 'object', default: {} }
1170 | }
1171 | };
1172 |
1173 | const result = await resolveValues(schema, {});
1174 | assert.ok(result.ok);
1175 | assert.deepStrictEqual(result.value, {
1176 | str: 'default',
1177 | num: 42,
1178 | bool: true,
1179 | arr: [],
1180 | obj: {}
1181 | });
1182 | });
1183 | });
1184 |
1185 | describe('conditional schemas', () => {
1186 | it('should resolve conditional schemas (minimum)', async () => {
1187 | const schema: JSONSchema = {
1188 | type: 'object',
1189 | properties: {
1190 | age: { type: 'integer' }
1191 | },
1192 | if: {
1193 | properties: { age: { minimum: 18 } }
1194 | },
1195 | then: {
1196 | properties: {
1197 | canVote: { type: 'boolean', const: true }
1198 | }
1199 | },
1200 | else: {
1201 | properties: {
1202 | canVote: { type: 'boolean', const: false }
1203 | }
1204 | }
1205 | };
1206 |
1207 | const adult = await resolveValues(schema, { age: 20 });
1208 | assert.ok(adult.ok);
1209 | assert.strictEqual(adult.value.canVote, true);
1210 |
1211 | const minor = await resolveValues(schema, { age: 16 });
1212 | assert.ok(minor.ok);
1213 | assert.strictEqual(minor.value.canVote, false);
1214 | });
1215 |
1216 | it('should resolve conditional schemas (maximum)', async () => {
1217 | const schema: JSONSchema = {
1218 | type: 'object',
1219 | properties: {
1220 | age: { type: 'integer' }
1221 | },
1222 | if: {
1223 | properties: { age: { maximum: 18 } }
1224 | },
1225 | then: {
1226 | properties: {
1227 | canVote: { type: 'boolean', const: false }
1228 | }
1229 | },
1230 | else: {
1231 | properties: {
1232 | canVote: { type: 'boolean', const: true }
1233 | }
1234 | }
1235 | };
1236 |
1237 | const adult = await resolveValues(schema, { age: 20 });
1238 | assert.ok(adult.ok);
1239 | assert.strictEqual(adult.value.canVote, true);
1240 |
1241 | const minor = await resolveValues(schema, { age: 16 });
1242 | assert.ok(minor.ok);
1243 | assert.strictEqual(minor.value.canVote, false);
1244 | });
1245 | });
1246 |
1247 | describe('multiple types', () => {
1248 | describe('basic type validation', () => {
1249 | const schema: JSONSchema = {
1250 | type: 'object',
1251 | properties: {
1252 | value: {
1253 | type: ['string', 'number']
1254 | }
1255 | }
1256 | };
1257 |
1258 | it('should accept string values', async () => {
1259 | const result = await resolveValues(schema, {
1260 | value: 'hello'
1261 | });
1262 | assert.ok(result.ok);
1263 | assert.strictEqual(result.value.value, 'hello');
1264 | });
1265 |
1266 | it('should accept number values', async () => {
1267 | const result = await resolveValues(schema, {
1268 | value: 42
1269 | });
1270 | assert.ok(result.ok);
1271 | assert.strictEqual(result.value.value, 42);
1272 | });
1273 |
1274 | it('should reject invalid types', async () => {
1275 | const result = await resolveValues(schema, { value: true });
1276 | assert.ok(!result.ok);
1277 | assert.strictEqual(result.errors.length, 1);
1278 | });
1279 | });
1280 |
1281 | describe('type-specific constraints', () => {
1282 | const schema: JSONSchema = {
1283 | type: 'object',
1284 | properties: {
1285 | value: {
1286 | type: ['string', 'number'],
1287 | minLength: 2,
1288 | minimum: 10
1289 | }
1290 | }
1291 | };
1292 |
1293 | it('should validate string constraints', async () => {
1294 | const shortString = await resolveValues(schema, { value: 'a' });
1295 | assert.ok(!shortString.ok);
1296 | assert.strictEqual(shortString.errors.length, 1);
1297 |
1298 | const validString = await resolveValues(schema, { value: 'hello' });
1299 | assert.ok(validString.ok);
1300 | });
1301 |
1302 | it('should validate number constraints', async () => {
1303 | const lowNumber = await resolveValues(schema, { value: 5 });
1304 | assert.ok(!lowNumber.ok);
1305 | assert.strictEqual(lowNumber.errors.length, 1);
1306 |
1307 | const validNumber = await resolveValues(schema, { value: 42 });
1308 | assert.ok(validNumber.ok);
1309 | });
1310 | });
1311 |
1312 | describe('array with multiple types', () => {
1313 | const schema: JSONSchema = {
1314 | type: 'object',
1315 | properties: {
1316 | list: {
1317 | type: 'array',
1318 | items: {
1319 | type: ['string', 'number']
1320 | }
1321 | }
1322 | }
1323 | };
1324 |
1325 | it('should accept arrays with valid mixed types', async () => {
1326 | const result = await resolveValues(schema, {
1327 | list: ['hello', 42, 'world', 123]
1328 | });
1329 | assert.ok(result.ok);
1330 | assert.deepStrictEqual(result.value.list, ['hello', 42, 'world', 123]);
1331 | });
1332 |
1333 | it('should reject arrays containing invalid types', async () => {
1334 | const result = await resolveValues(schema, {
1335 | list: ['hello', 42, true]
1336 | });
1337 | assert.ok(!result.ok);
1338 | assert.strictEqual(result.errors.length, 1);
1339 | });
1340 | });
1341 |
1342 | describe('default values', () => {
1343 | const schema: JSONSchema = {
1344 | type: 'object',
1345 | properties: {
1346 | value: {
1347 | type: ['string', 'number'],
1348 | default: 'default'
1349 | }
1350 | }
1351 | };
1352 |
1353 | it('should use default value when property is missing', async () => {
1354 | const result = await resolveValues(schema, {});
1355 | assert.ok(result.ok);
1356 | assert.strictEqual(result.value.value, 'default');
1357 | });
1358 |
1359 | it('should allow overriding default with valid types', async () => {
1360 | const stringResult = await resolveValues(schema, {
1361 | value: 'test'
1362 | });
1363 | assert.ok(stringResult.ok);
1364 | assert.strictEqual(stringResult.value.value, 'test');
1365 |
1366 | const numberResult = await resolveValues(schema, { value: 42 });
1367 | assert.ok(numberResult.ok);
1368 | assert.strictEqual(numberResult.value.value, 42);
1369 | });
1370 |
1371 | it('should apply defaults when parent object is missing', async () => {
1372 | const schema: JSONSchema = {
1373 | type: 'object',
1374 | properties: {
1375 | user: {
1376 | type: 'object',
1377 | properties: {
1378 | settings: {
1379 | type: 'object',
1380 | properties: {
1381 | theme: { type: 'string', default: 'dark' }
1382 | }
1383 | }
1384 | }
1385 | }
1386 | }
1387 | };
1388 |
1389 | const result1 = await resolveValues(schema, {});
1390 | assert.ok(result1.ok);
1391 | assert.strictEqual(result1.value?.user?.settings?.theme, 'dark');
1392 |
1393 | const result2 = await resolveValues(schema, { user: {} });
1394 | assert.ok(result2.ok);
1395 | assert.strictEqual(result2.value?.user?.settings?.theme, 'dark');
1396 | });
1397 |
1398 | it('should handle multiple nested levels of defaults', async () => {
1399 | const schema: JSONSchema = {
1400 | type: 'object',
1401 | properties: {
1402 | user: {
1403 | type: 'object',
1404 | properties: {
1405 | settings: {
1406 | type: 'object',
1407 | default: {
1408 | theme: 'dark',
1409 | notifications: true
1410 | },
1411 | properties: {
1412 | theme: { type: 'string', default: 'dark' },
1413 | notifications: { type: 'boolean', default: true }
1414 | }
1415 | }
1416 | }
1417 | }
1418 | }
1419 | };
1420 |
1421 | const result = await resolveValues(schema, {});
1422 | assert.ok(result.ok);
1423 | assert.strictEqual(result.value?.user?.settings?.theme, 'dark');
1424 | assert.strictEqual(result.value?.user?.settings?.notifications, true);
1425 | });
1426 | });
1427 |
1428 | describe('nested properties', () => {
1429 | const schema: JSONSchema = {
1430 | type: 'object',
1431 | properties: {
1432 | nested: {
1433 | type: 'object',
1434 | properties: {
1435 | value: {
1436 | type: ['string', 'number'],
1437 | minLength: 2,
1438 | minimum: 10
1439 | }
1440 | },
1441 | required: ['value']
1442 | }
1443 | }
1444 | };
1445 |
1446 | it('should validate nested string values', async () => {
1447 | const result = await resolveValues(schema, {
1448 | nested: { value: 'hello' }
1449 | });
1450 | assert.ok(result.ok);
1451 | assert.strictEqual(result.value.nested.value, 'hello');
1452 | });
1453 |
1454 | it('should validate nested number values', async () => {
1455 | const result = await resolveValues(schema, {
1456 | nested: { value: 42 }
1457 | });
1458 | assert.ok(result.ok);
1459 | assert.strictEqual(result.value.nested.value, 42);
1460 | });
1461 |
1462 | it('should reject nested invalid types', async () => {
1463 | const result = await resolveValues(schema, {
1464 | nested: { value: true }
1465 | });
1466 | assert.ok(!result.ok);
1467 | assert.strictEqual(result.errors.length, 1);
1468 | });
1469 |
1470 | it('should require nested value property', async () => {
1471 | const result = await resolveValues(schema, {
1472 | nested: {}
1473 | });
1474 |
1475 | assert.ok(!result.ok);
1476 | assert.strictEqual(result.errors.length, 1);
1477 | assert.strictEqual(result.errors[0].message, 'Missing required property: value');
1478 | assert.strictEqual(result.errors[0].path[0], 'nested');
1479 | });
1480 | });
1481 |
1482 | describe('composition with multiple types', () => {
1483 | const schema: JSONSchema = {
1484 | type: 'object',
1485 | properties: {
1486 | value: {
1487 | allOf: [
1488 | {
1489 | type: ['string', 'number']
1490 | },
1491 | {
1492 | type: ['string', 'boolean']
1493 | }
1494 | ]
1495 | }
1496 | }
1497 | };
1498 |
1499 | it('should accept values valid for all schemas', async () => {
1500 | const result = await resolveValues(schema, {
1501 | value: 'test'
1502 | });
1503 | assert.ok(result.ok);
1504 | assert.strictEqual(result.value.value, 'test');
1505 | });
1506 |
1507 | it('should reject values not valid for all schemas', async () => {
1508 | const numberResult = await resolveValues(schema, {
1509 | value: 42
1510 | });
1511 |
1512 | assert.ok(!numberResult.ok);
1513 | assert.strictEqual(numberResult.errors.length, 1);
1514 | assert.strictEqual(numberResult.errors[0].path[0], 'value');
1515 |
1516 | const booleanResult = await resolveValues(schema, {
1517 | value: true
1518 | });
1519 |
1520 | assert.ok(!booleanResult.ok);
1521 | assert.strictEqual(booleanResult.errors.length, 1);
1522 |
1523 | // This is the only one that should pass
1524 | const stringResult = await resolveValues(schema, {
1525 | value: 'true'
1526 | });
1527 |
1528 | assert.ok(stringResult.ok);
1529 | });
1530 |
1531 | it('should correctly handle type validation in allOf', async () => {
1532 | const schema: JSONSchema = {
1533 | type: 'object',
1534 | properties: {
1535 | value: {
1536 | allOf: [
1537 | {
1538 | type: ['string', 'number']
1539 | },
1540 | {
1541 | type: ['string', 'boolean']
1542 | }
1543 | ]
1544 | }
1545 | }
1546 | };
1547 |
1548 | // A string should be valid as it satisfies both schemas
1549 | const stringResult = await resolveValues(schema, { value: 'test' });
1550 | assert.ok(stringResult.ok);
1551 | assert.strictEqual(stringResult.value.value, 'test');
1552 |
1553 | const numberResult = await resolveValues(schema, { value: 42 });
1554 | assert.ok(!numberResult.ok);
1555 |
1556 | const booleanResult = await resolveValues(schema, { value: true });
1557 | assert.ok(!booleanResult.ok);
1558 |
1559 | const arrayResult = await resolveValues(schema, { value: [] });
1560 | assert.ok(!arrayResult.ok);
1561 | });
1562 |
1563 | it('should correctly handle integer/number in allOf', async () => {
1564 | const schema: JSONSchema = {
1565 | type: 'object',
1566 | properties: {
1567 | value: {
1568 | allOf: [
1569 | {
1570 | type: ['string', 'number']
1571 | },
1572 | {
1573 | type: ['integer', 'boolean']
1574 | }
1575 | ]
1576 | }
1577 | }
1578 | };
1579 |
1580 | const numberResult = await resolveValues(schema, { value: 42 });
1581 | assert.ok(numberResult.ok);
1582 |
1583 | const stringResult = await resolveValues(schema, { value: 'test' });
1584 | assert.ok(!stringResult.ok);
1585 |
1586 | const booleanResult = await resolveValues(schema, { value: true });
1587 | assert.ok(!booleanResult.ok);
1588 |
1589 | const arrayResult = await resolveValues(schema, { value: [] });
1590 | assert.ok(!arrayResult.ok);
1591 | });
1592 | });
1593 | });
1594 |
1595 | describe('format validation', () => {
1596 | it('should validate date-time format', async () => {
1597 | const schema: JSONSchema = {
1598 | type: 'object',
1599 | properties: {
1600 | timestamp: { type: 'string', format: 'date-time' }
1601 | }
1602 | };
1603 |
1604 | const validResult = await resolveValues(schema, {
1605 | timestamp: '2024-01-01T12:00:00Z'
1606 | });
1607 | assert.ok(validResult.ok);
1608 | assert.strictEqual(validResult.value.timestamp, '2024-01-01T12:00:00Z');
1609 |
1610 | const invalidResult = await resolveValues(schema, {
1611 | timestamp: 'invalid'
1612 | });
1613 | assert.ok(!invalidResult.ok);
1614 | assert.strictEqual(invalidResult.errors[0].message, 'Invalid date-time format');
1615 | });
1616 |
1617 | it('should validate date format', async () => {
1618 | const schema: JSONSchema = {
1619 | type: 'object',
1620 | properties: {
1621 | date: { type: 'string', format: 'date' }
1622 | }
1623 | };
1624 |
1625 | const validResult = await resolveValues(schema, {
1626 | date: '2024-01-01'
1627 | });
1628 | assert.ok(validResult.ok);
1629 | assert.strictEqual(validResult.value.date, '2024-01-01');
1630 |
1631 | const invalidResult = await resolveValues(schema, {
1632 | date: '2024/01/01'
1633 | });
1634 | assert.ok(!invalidResult.ok);
1635 | assert.strictEqual(invalidResult.errors[0].message, 'Invalid date format');
1636 | });
1637 |
1638 | it('should validate email format', async () => {
1639 | const schema: JSONSchema = {
1640 | type: 'object',
1641 | properties: {
1642 | email: { type: 'string', format: 'email' }
1643 | }
1644 | };
1645 |
1646 | const validResult = await resolveValues(schema, {
1647 | email: 'test@example.com'
1648 | });
1649 | assert.ok(validResult.ok);
1650 | assert.strictEqual(validResult.value.email, 'test@example.com');
1651 |
1652 | const invalidResult = await resolveValues(schema, {
1653 | email: 'invalid-email'
1654 | });
1655 | assert.ok(!invalidResult.ok);
1656 | assert.strictEqual(invalidResult.errors[0].message, 'Invalid email format');
1657 | });
1658 |
1659 | it('should validate ipv4 format', async () => {
1660 | const schema: JSONSchema = {
1661 | type: 'object',
1662 | properties: {
1663 | ip: { type: 'string', format: 'ipv4' }
1664 | }
1665 | };
1666 |
1667 | const validResult = await resolveValues(schema, {
1668 | ip: '192.168.1.1'
1669 | });
1670 | assert.ok(validResult.ok);
1671 | assert.strictEqual(validResult.value.ip, '192.168.1.1');
1672 |
1673 | const invalidResult = await resolveValues(schema, {
1674 | ip: '256.256.256.256'
1675 | });
1676 | assert.ok(!invalidResult.ok);
1677 | assert.strictEqual(invalidResult.errors[0].message, 'Invalid ipv4 format');
1678 | });
1679 | });
1680 |
1681 | describe('property exclusion', () => {
1682 | it('should enforce mutually exclusive properties via not/required (1)', async () => {
1683 | const schema: JSONSchema = {
1684 | type: 'object',
1685 | properties: {
1686 | a: { type: 'string' },
1687 | b: { type: 'string' }
1688 | },
1689 | not: { required: ['a', 'b'] }
1690 | };
1691 |
1692 | const validResult = await resolveValues(schema, { a: 'test' });
1693 | assert.ok(validResult.ok);
1694 | assert.strictEqual(validResult.value.a, 'test');
1695 |
1696 | const invalidResult = await resolveValues(schema, { a: 'test', b: 'test' });
1697 | assert.ok(!invalidResult.ok);
1698 | assert.strictEqual(invalidResult.errors[0].message, 'Value must not match schema');
1699 | });
1700 |
1701 | it('should enforce mutually exclusive properties via not/required (2)', async () => {
1702 | const schema: JSONSchema = {
1703 | type: 'object',
1704 | properties: {
1705 | a: { type: 'string' },
1706 | b: { type: 'string' }
1707 | },
1708 | allOf: [{ not: { required: ['a', 'b'] } }]
1709 | };
1710 |
1711 | const result1 = await resolveValues(schema, { a: 'foo' });
1712 |
1713 | assert.ok(result1.ok);
1714 | assert.strictEqual(result1.value.a, 'foo');
1715 |
1716 | const result2 = await resolveValues(schema, { a: 'bar' });
1717 |
1718 | assert.ok(result2.ok);
1719 | assert.strictEqual(result2.value.a, 'bar');
1720 |
1721 | const result3 = await resolveValues(schema, { a: 'foo', b: 'bar' });
1722 | assert.ok(!result3.ok);
1723 | assert.strictEqual(result3.errors[0].message, 'Value must not match schema');
1724 | });
1725 |
1726 | it('should enforce mutually exclusive properties via not/required (3)', async () => {
1727 | const schema: JSONSchema = {
1728 | type: 'object',
1729 | properties: {
1730 | a: { type: 'string' },
1731 | b: { type: 'string' }
1732 | },
1733 | allOf: [{ not: { required: ['b'] } }, { not: { required: ['a'] } }]
1734 | };
1735 |
1736 | const result1 = await resolveValues(schema, { a: 'test' });
1737 |
1738 | assert.ok(!result1.ok);
1739 | assert.strictEqual(result1.errors[0].path.join('.'), 'allOf.not.b');
1740 | assert.strictEqual(result1.errors[0].message, 'Missing required property: b');
1741 | assert.strictEqual(result1.errors[1].path.join('.'), 'allOf.not');
1742 | assert.strictEqual(result1.errors[1].message, 'Value must not match schema');
1743 |
1744 | const result2 = await resolveValues(schema, { a: 'test', b: 'test' });
1745 | assert.ok(!result2.ok);
1746 | assert.strictEqual(result2.errors[0].message, 'Value must not match schema');
1747 | });
1748 |
1749 | it('should enforce exactly one property via oneOf/required', async () => {
1750 | const schema: JSONSchema = {
1751 | type: 'object',
1752 | properties: {
1753 | a: { type: 'string' },
1754 | b: { type: 'string' },
1755 | c: { type: 'string' }
1756 | },
1757 | oneOf: [
1758 | { required: ['a'] },
1759 | { required: ['b'], properties: { name: { type: 'string', default: 'doowb' } } },
1760 | { required: ['c'] }
1761 | ]
1762 | };
1763 |
1764 | const validResult = await resolveValues(schema, { b: 'test' });
1765 |
1766 | assert.ok(validResult.ok);
1767 | assert.strictEqual(validResult.value.b, 'test');
1768 | assert.strictEqual(validResult.value.name, 'doowb');
1769 |
1770 | const invalidTwoProps = await resolveValues(schema, { a: 'test', b: 'test' });
1771 | assert.ok(!invalidTwoProps.ok);
1772 |
1773 | assert.strictEqual(invalidTwoProps.errors[0].message, 'Value must match exactly one schema in oneOf');
1774 |
1775 | const invalidNoProps = await resolveValues(schema, {});
1776 | assert.ok(!invalidNoProps.ok);
1777 | assert.equal(invalidNoProps.errors.length, 2);
1778 | assert.strictEqual(invalidNoProps.errors[1].message, 'Value must match exactly one schema in oneOf');
1779 | });
1780 | });
1781 |
1782 | describe('nested composition', () => {
1783 | it('should validate deeply nested allOf/anyOf combinations', async () => {
1784 | const schema: JSONSchema = {
1785 | allOf: [
1786 | {
1787 | anyOf: [
1788 | { type: 'string', minLength: 5 },
1789 | { type: 'number', minimum: 10 }
1790 | ]
1791 | },
1792 | {
1793 | anyOf: [
1794 | { type: 'string', maxLength: 10 },
1795 | { type: 'number', maximum: 20 }
1796 | ]
1797 | }
1798 | ]
1799 | };
1800 |
1801 | const validString = await resolveValues(schema, 'valid');
1802 | assert.ok(validString.ok);
1803 | assert.strictEqual(validString.value, 'valid');
1804 |
1805 | const validNumber = await resolveValues(schema, 15);
1806 | assert.ok(validNumber.ok);
1807 | assert.strictEqual(validNumber.value, 15);
1808 |
1809 | const invalidShortString = await resolveValues(schema, 'hi');
1810 | assert.ok(!invalidShortString.ok);
1811 | assert.ok(invalidShortString.errors.length > 0);
1812 |
1813 | const invalidLargeNumber = await resolveValues(schema, 25);
1814 | assert.ok(!invalidLargeNumber.ok);
1815 | assert.ok(invalidLargeNumber.errors.length > 0);
1816 | });
1817 | });
1818 |
1819 | describe('dependent required properties', () => {
1820 | it('should enforce dependent required properties', async () => {
1821 | const schema: JSONSchema = {
1822 | type: 'object',
1823 | properties: {
1824 | type: { type: 'string' },
1825 | value: { type: 'string' },
1826 | format: { type: 'string' }
1827 | },
1828 | if: { properties: { type: { const: 'special' } } },
1829 | then: { required: ['value'] }
1830 | };
1831 |
1832 | const validNormal = await resolveValues(schema, { type: 'normal' });
1833 | assert.ok(validNormal.ok);
1834 |
1835 | const invalidMissingValue = await resolveValues(schema, { type: 'special' });
1836 | assert.ok(!invalidMissingValue.ok);
1837 | assert.strictEqual(invalidMissingValue.errors[0].message, 'Missing required property: value');
1838 | });
1839 |
1840 | it('should enforce dependent required properties across multiple conditions', async () => {
1841 | const schema: JSONSchema = {
1842 | type: 'object',
1843 | properties: {
1844 | type: { type: 'string' },
1845 | value: { type: 'string' },
1846 | format: { type: 'string' }
1847 | },
1848 | allOf: [
1849 | {
1850 | if: { properties: { type: { const: 'special' } } },
1851 | then: { required: ['value'] }
1852 | },
1853 | {
1854 | if: { properties: { value: { minLength: 1 } } },
1855 | then: { required: ['format'] }
1856 | }
1857 | ]
1858 | };
1859 |
1860 | const validNormal = await resolveValues(schema, { type: 'normal' });
1861 | assert.ok(validNormal.ok); // This should pass since no conditions are triggered.
1862 |
1863 | const invalidMissingValue = await resolveValues(schema, { type: 'special' });
1864 | assert.ok(!invalidMissingValue.ok);
1865 | assert.strictEqual(invalidMissingValue.errors[0].message, 'Missing required property: value');
1866 |
1867 | const invalidMissingFormat = await resolveValues(schema, { type: 'special', value: 'test' });
1868 | assert.ok(!invalidMissingFormat.ok);
1869 | assert.strictEqual(invalidMissingFormat.errors[0].message, 'Missing required property: format');
1870 | });
1871 |
1872 | it('should enforce conditions based on specific items in an array', async () => {
1873 | const schema: JSONSchema = {
1874 | type: 'array',
1875 | items: [
1876 | { type: 'string' },
1877 | {
1878 | type: 'object',
1879 | properties: {
1880 | requiredField: { type: 'string' }
1881 | },
1882 | required: ['requiredField']
1883 | }
1884 | ],
1885 | if: {
1886 | contains: { type: 'string', const: 'specialItem' }
1887 | },
1888 | then: {
1889 | contains: { type: 'object', required: ['requiredField'] }
1890 | }
1891 | };
1892 |
1893 | const validArray = await resolveValues(schema, ['normalItem', { requiredField: 'value' }]);
1894 | assert.ok(validArray.ok);
1895 |
1896 | const invalidArray = await resolveValues(schema, ['specialItem', {}]);
1897 | assert.ok(!invalidArray.ok);
1898 | assert.strictEqual(invalidArray.errors[0].message, 'Array must contain at least one matching item');
1899 | });
1900 |
1901 | it('should enforce conditions across nested arrays', async () => {
1902 | const schema: JSONSchema = {
1903 | type: 'array',
1904 | items: {
1905 | type: 'array',
1906 | items: [
1907 | { type: 'string' },
1908 | {
1909 | type: 'object',
1910 | properties: {
1911 | nestedField: { type: 'string' }
1912 | },
1913 | required: ['nestedField']
1914 | }
1915 | ]
1916 | },
1917 | allOf: [
1918 | {
1919 | if: {
1920 | contains: {
1921 | type: 'array',
1922 | contains: { type: 'string', const: 'trigger' }
1923 | }
1924 | },
1925 | then: {
1926 | contains: {
1927 | type: 'array',
1928 | contains: { type: 'object', required: ['nestedField'] }
1929 | }
1930 | }
1931 | }
1932 | ]
1933 | };
1934 |
1935 | // Scenario: Nested arrays without 'trigger'
1936 | const validNestedArrays = await resolveValues(schema, [['item1', { nestedField: 'value' }]]);
1937 | assert.ok(validNestedArrays.ok);
1938 |
1939 | // Scenario: Nested arrays with 'trigger' but missing 'nestedField' in the object
1940 | const invalidNestedArrays = await resolveValues(schema, [['trigger', {}]]);
1941 | assert.ok(!invalidNestedArrays.ok);
1942 | assert.strictEqual(invalidNestedArrays.errors[0].message, 'Array must contain at least one matching item');
1943 | });
1944 |
1945 | it('should enforce item types conditionally with arrays', async () => {
1946 | const schema: JSONSchema = {
1947 | type: 'array',
1948 | items: { type: 'string' },
1949 | if: {
1950 | contains: { const: 'trigger' }
1951 | },
1952 | then: {
1953 | items: { type: 'number' }
1954 | }
1955 | };
1956 |
1957 | const result = await resolveValues(schema, ['one', 'two']);
1958 | assert.ok(result.ok);
1959 |
1960 | const result2 = await resolveValues(schema, ['trigger', 'notANumber']);
1961 | assert.ok(!result2.ok);
1962 | assert.strictEqual(result2.errors[0].message, 'Value must be a number');
1963 | });
1964 |
1965 | it('should enforce conditions based on item presence', async () => {
1966 | const schema: JSONSchema = {
1967 | type: 'array',
1968 | items: { type: 'string' },
1969 | allOf: [
1970 | {
1971 | if: { contains: { const: 'error' } },
1972 | then: { minItems: 3 }
1973 | },
1974 | {
1975 | if: { contains: { const: 'warning' } },
1976 | then: { maxItems: 3 }
1977 | }
1978 | ]
1979 | };
1980 |
1981 | const validArrayWithError = await resolveValues(schema, ['error', 'more', 'items']);
1982 | assert.ok(validArrayWithError.ok);
1983 |
1984 | const invalidArrayWithError = await resolveValues(schema, ['error', 'less']);
1985 | assert.ok(!invalidArrayWithError.ok);
1986 | assert.strictEqual(invalidArrayWithError.errors[0].message, 'Array length must be >= 3');
1987 |
1988 | const validArrayWithWarning = await resolveValues(schema, ['warning', 'still', 'valid']);
1989 | assert.ok(validArrayWithWarning.ok);
1990 |
1991 | const invalidArrayWithWarning = await resolveValues(schema, ['warning', 'too', 'many', 'items']);
1992 | assert.ok(!invalidArrayWithWarning.ok);
1993 | assert.strictEqual(invalidArrayWithWarning.errors[0].message, 'Array length must be <= 3');
1994 | });
1995 | });
1996 |
1997 | describe('array contains validation', () => {
1998 | it('should validate array contains constraint', async () => {
1999 | const schema: JSONSchema = {
2000 | type: 'array',
2001 | contains: {
2002 | type: 'number',
2003 | minimum: 5
2004 | }
2005 | };
2006 |
2007 | const validResult = await resolveValues(schema, [1, 2, 6, 3]);
2008 | assert.ok(validResult.ok);
2009 | assert.deepStrictEqual(validResult.value, [1, 2, 6, 3]);
2010 |
2011 | const invalidResult = await resolveValues(schema, [1, 2, 3, 4]);
2012 | assert.ok(!invalidResult.ok);
2013 | assert.strictEqual(invalidResult.errors[0].message, 'Array must contain at least one matching item');
2014 | });
2015 | });
2016 |
2017 | describe('multiple type validation with constraints', () => {
2018 | it('should validate value against type-specific constraints', async () => {
2019 | const schema: JSONSchema = {
2020 | type: ['string', 'number'],
2021 | minLength: 3,
2022 | minimum: 10
2023 | };
2024 |
2025 | const validString = await resolveValues(schema, 'test');
2026 | assert.ok(validString.ok);
2027 | assert.strictEqual(validString.value, 'test');
2028 |
2029 | const validNumber = await resolveValues(schema, 15);
2030 | assert.ok(validNumber.ok);
2031 | assert.strictEqual(validNumber.value, 15);
2032 |
2033 | const invalidShortString = await resolveValues(schema, 'ab');
2034 | assert.ok(!invalidShortString.ok);
2035 | assert.strictEqual(invalidShortString.errors[0].message, 'String length must be >= 3');
2036 |
2037 | const invalidSmallNumber = await resolveValues(schema, 5);
2038 | assert.ok(!invalidSmallNumber.ok);
2039 | assert.strictEqual(invalidSmallNumber.errors[0].message, 'Value must be >= 10');
2040 | });
2041 | });
2042 |
2043 | describe('property names validation', () => {
2044 | it('should validate property names against schema', async () => {
2045 | const schema: JSONSchema = {
2046 | type: 'object',
2047 | propertyNames: {
2048 | type: 'string',
2049 | pattern: '^[a-z]+$'
2050 | }
2051 | };
2052 |
2053 | const validResult = await resolveValues(schema, { abc: 1, def: 2 });
2054 | assert.ok(validResult.ok);
2055 | assert.deepStrictEqual(validResult.value, { abc: 1, def: 2 });
2056 |
2057 | const invalidResult = await resolveValues(schema, { 'invalid-key': 1 });
2058 | assert.ok(!invalidResult.ok);
2059 | assert.ok(invalidResult.errors[0].message.includes('must match pattern'));
2060 | assert.equal(invalidResult.errors[0].path[0], 'propertyNames');
2061 | });
2062 | });
2063 |
2064 | describe('deeply nested conditional validation', () => {
2065 | it('should validate nested conditionals with multiple dependencies', async () => {
2066 | const schema: JSONSchema = {
2067 | type: 'object',
2068 | properties: {
2069 | user: {
2070 | type: 'object',
2071 | properties: {
2072 | type: { type: 'string' },
2073 | age: { type: 'number' }
2074 | },
2075 | if: {
2076 | properties: { type: { const: 'minor' } }
2077 | },
2078 | then: {
2079 | properties: { age: { maximum: 18 } }
2080 | },
2081 | else: {
2082 | properties: { age: { minimum: 18 } }
2083 | }
2084 | }
2085 | }
2086 | };
2087 |
2088 | const validMinor = await resolveValues(schema, {
2089 | user: { type: 'minor', age: 15 }
2090 | });
2091 | assert.ok(validMinor.ok);
2092 |
2093 | const validAdult = await resolveValues(schema, {
2094 | user: { type: 'adult', age: 25 }
2095 | });
2096 | assert.ok(validAdult.ok);
2097 |
2098 | const invalidMinor = await resolveValues(schema, {
2099 | user: { type: 'minor', age: 20 }
2100 | });
2101 | assert.ok(!invalidMinor.ok);
2102 | assert.strictEqual(invalidMinor.errors[0].message, 'Value must be <= 18');
2103 |
2104 | const invalidAdult = await resolveValues(schema, {
2105 | user: { type: 'adult', age: 15 }
2106 | });
2107 | assert.ok(!invalidAdult.ok);
2108 | assert.strictEqual(invalidAdult.errors[0].message, 'Value must be >= 18');
2109 | });
2110 | });
2111 |
2112 | describe('array items condition evaluation', () => {
2113 | it('should evaluate nested array items with conditions', async () => {
2114 | const schema: JSONSchema = {
2115 | type: 'array',
2116 | if: {
2117 | items: {
2118 | type: 'object',
2119 | properties: {
2120 | status: { const: 'active' }
2121 | }
2122 | }
2123 | },
2124 | then: {
2125 | items: {
2126 | required: ['id']
2127 | }
2128 | }
2129 | };
2130 |
2131 | // All items are active, should require id
2132 | const valid = await resolveValues(schema, [
2133 | { status: 'active', id: '1' },
2134 | { status: 'active', id: '2' }
2135 | ]);
2136 | assert.ok(valid.ok);
2137 |
2138 | // Missing id when all items are active
2139 | const invalid = await resolveValues(schema, [{ status: 'active', id: '1' }, { status: 'active' }]);
2140 | assert.ok(!invalid.ok);
2141 | assert.strictEqual(invalid.errors[0].message, 'Missing required property: id');
2142 | });
2143 |
2144 | it('should evaluate array items with nested conditional logic', async () => {
2145 | const schema: JSONSchema = {
2146 | type: 'array',
2147 | items: {
2148 | type: 'object',
2149 | if: {
2150 | properties: {
2151 | type: { const: 'user' }
2152 | }
2153 | },
2154 | then: {
2155 | required: ['name', 'email']
2156 | }
2157 | }
2158 | };
2159 |
2160 | // Valid: non-user items don't need name/email
2161 | const validMixed = await resolveValues(schema, [
2162 | { type: 'user', name: 'John', email: 'john@test.com' },
2163 | { type: 'system' }
2164 | ]);
2165 | assert.ok(validMixed.ok);
2166 |
2167 | // Invalid: user type missing required fields
2168 | const invalidUser = await resolveValues(schema, [
2169 | { type: 'user', name: 'John' }, // missing email
2170 | { type: 'system' }
2171 | ]);
2172 | assert.ok(!invalidUser.ok);
2173 |
2174 | assert.strictEqual(invalidUser.errors[0].message, 'Missing required property: email');
2175 | });
2176 |
2177 | it('should evaluate conditions on array items with multiple validation rules', async () => {
2178 | const schema: JSONSchema = {
2179 | type: 'array',
2180 | items: {
2181 | type: 'object',
2182 | if: {
2183 | properties: {
2184 | role: { const: 'admin' }
2185 | }
2186 | },
2187 | then: {
2188 | properties: {
2189 | accessLevel: {
2190 | type: 'number',
2191 | minimum: 5
2192 | }
2193 | },
2194 | required: ['accessLevel']
2195 | },
2196 | else: {
2197 | properties: {
2198 | accessLevel: {
2199 | type: 'number',
2200 | maximum: 4
2201 | }
2202 | }
2203 | }
2204 | }
2205 | };
2206 |
2207 | // Valid admin with high access level
2208 | const validAdmin = await resolveValues(schema, [{ role: 'admin', accessLevel: 7 }]);
2209 | assert.ok(validAdmin.ok);
2210 |
2211 | // Valid user with low access level
2212 | const validUser = await resolveValues(schema, [{ role: 'user', accessLevel: 2 }]);
2213 | assert.ok(validUser.ok);
2214 |
2215 | // Invalid: admin with low access level
2216 | const invalidAdmin = await resolveValues(schema, [{ role: 'admin', accessLevel: 3 }]);
2217 | assert.ok(!invalidAdmin.ok);
2218 | assert.strictEqual(invalidAdmin.errors[0].message, 'Value must be >= 5');
2219 |
2220 | // Invalid: user with high access level
2221 | const invalidUser = await resolveValues(schema, [{ role: 'user', accessLevel: 6 }]);
2222 | assert.ok(!invalidUser.ok);
2223 |
2224 | assert.strictEqual(invalidUser.errors[0].message, 'Value must be <= 4');
2225 | });
2226 | });
2227 | });
2228 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "allowJs": true,
5 | "allowSyntheticDefaultImports": true,
6 | "checkJs": false,
7 | "esModuleInterop": true,
8 | "isolatedModules": true,
9 | "moduleResolution": "NodeNext",
10 | "module": "NodeNext",
11 | "noEmit": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "lib": ["ES2022"],
14 | "resolveJsonModule": true,
15 | "strict": true,
16 | "target": "ES2022",
17 | "types": ["node"],
18 | "paths": { "~/*": ["src/*"] }
19 | },
20 | "ts-node": {
21 | "transpileOnly": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup';
2 |
3 | export default defineConfig({
4 | clean: true,
5 | entry: {
6 | index: 'src/index.ts',
7 | merge: 'src/merge.ts',
8 | resolve: 'src/resolve.ts'
9 | },
10 | cjsInterop: true,
11 | format: ['cjs', 'esm'],
12 | keepNames: true,
13 | minify: false,
14 | shims: true,
15 | splitting: false,
16 | sourcemap: true,
17 | target: 'node18'
18 | });
19 |
--------------------------------------------------------------------------------