├── .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 [![NPM version](https://img.shields.io/npm/v/resolve-schema-values.svg?style=flat)](https://www.npmjs.com/package/resolve-schema-values) [![NPM monthly downloads](https://img.shields.io/npm/dm/resolve-schema-values.svg?style=flat)](https://npmjs.org/package/resolve-schema-values) [![NPM total downloads](https://img.shields.io/npm/dt/resolve-schema-values.svg?style=flat)](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 | --------------------------------------------------------------------------------