schema-dsl 1.2.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -210
- package/README.md +391 -2249
- package/dist/DslBuilder-DQDN0ZxZ.d.cts +341 -0
- package/dist/DslBuilder-DkLaOo9Q.d.ts +341 -0
- package/dist/Validator-C7GsVQOH.d.cts +192 -0
- package/dist/Validator-hFWKGxir.d.ts +192 -0
- package/dist/index.cjs +6594 -0
- package/dist/index.d.cts +1145 -0
- package/dist/index.d.ts +1145 -0
- package/dist/index.js +6528 -0
- package/dist/plugin-CIKtTMtS.d.cts +246 -0
- package/dist/plugin-CIKtTMtS.d.ts +246 -0
- package/dist/plugins/custom-format.cjs +3802 -0
- package/dist/plugins/custom-format.d.cts +12 -0
- package/dist/plugins/custom-format.d.ts +12 -0
- package/dist/plugins/custom-format.js +3772 -0
- package/dist/plugins/custom-type-example.cjs +3795 -0
- package/dist/plugins/custom-type-example.d.cts +8 -0
- package/dist/plugins/custom-type-example.d.ts +8 -0
- package/dist/plugins/custom-type-example.js +3765 -0
- package/dist/plugins/custom-validator.cjs +146 -0
- package/dist/plugins/custom-validator.d.cts +10 -0
- package/dist/plugins/custom-validator.d.ts +10 -0
- package/dist/plugins/custom-validator.js +121 -0
- package/docs/FEATURE-INDEX.md +102 -68
- package/docs/add-custom-locale.md +48 -35
- package/docs/add-keyword.md +24 -0
- package/docs/api-reference.md +396 -154
- package/docs/api.md +13 -0
- package/docs/best-practices-project-structure.md +19 -10
- package/docs/best-practices.md +93 -53
- package/docs/cache-manager.md +23 -15
- package/docs/compile.md +45 -0
- package/docs/conditional-api.md +40 -11
- package/docs/custom-extensions-guide.md +80 -152
- package/docs/design-philosophy.md +76 -71
- package/docs/doc-index.md +324 -0
- package/docs/dsl-syntax.md +69 -19
- package/docs/dynamic-locale.md +24 -14
- package/docs/enum.md +12 -5
- package/docs/error-handling.md +53 -44
- package/docs/export-guide.md +47 -8
- package/docs/export-limitations.md +27 -11
- package/docs/faq.md +86 -67
- package/docs/frontend-i18n-guide.md +26 -12
- package/docs/i18n-user-guide.md +60 -47
- package/docs/i18n.md +51 -32
- package/docs/index.md +48 -0
- package/docs/json-schema-basics.md +40 -0
- package/docs/label-vs-description.md +12 -3
- package/docs/markdown-exporter.md +15 -6
- package/docs/mongodb-exporter.md +11 -4
- package/docs/multi-language.md +26 -0
- package/docs/multi-type-support.md +26 -33
- package/docs/mysql-exporter.md +9 -2
- package/docs/number-operators.md +12 -5
- package/docs/optional-marker-guide.md +28 -23
- package/docs/performance-guide.md +49 -0
- package/docs/plugin-system.md +205 -366
- package/docs/plugin-type-registration.md +34 -0
- package/docs/postgresql-exporter.md +9 -2
- package/docs/public/favicon.svg +5 -0
- package/docs/quick-start.md +37 -363
- package/docs/runtime-locale-support.md +20 -9
- package/docs/schema-helper.md +10 -5
- package/docs/schema-utils-advanced-issues.md +23 -0
- package/docs/schema-utils-best-practices.md +20 -0
- package/docs/schema-utils-chaining.md +7 -0
- package/docs/schema-utils.md +76 -42
- package/docs/security-checklist.md +20 -0
- package/docs/string-extensions.md +17 -9
- package/docs/troubleshooting.md +36 -21
- package/docs/type-converter.md +41 -50
- package/docs/type-reference.md +38 -15
- package/docs/typescript-guide.md +53 -42
- package/docs/union-type-guide.md +11 -1
- package/docs/union-types.md +10 -3
- package/docs/validate-async.md +36 -25
- package/docs/validate-batch.md +49 -0
- package/docs/validate-dsl-object-support.md +33 -28
- package/docs/validate.md +36 -16
- package/docs/validation-guide.md +25 -7
- package/docs/validator.md +39 -0
- package/package.json +85 -27
- package/plugins/custom-format.cjs +8 -0
- package/plugins/custom-type-example.cjs +8 -0
- package/plugins/custom-validator.cjs +8 -0
- package/src/adapters/DslAdapter.ts +111 -0
- package/src/adapters/index.ts +1 -0
- package/src/config/constants.ts +83 -0
- package/src/config/index.ts +2 -0
- package/src/config/patterns.ts +77 -0
- package/src/core/CacheManager.ts +159 -0
- package/src/core/ConditionalBuilder.ts +382 -0
- package/src/core/ConditionalRuntime.ts +28 -0
- package/src/core/ConditionalValidator.ts +255 -0
- package/src/core/DslBuilder.ts +677 -0
- package/src/core/ErrorCodes.ts +38 -0
- package/src/core/ErrorFormatter.ts +271 -0
- package/src/core/JSONSchemaCore.ts +65 -0
- package/src/core/Locale.ts +187 -0
- package/src/core/MessageTemplate.ts +42 -0
- package/src/core/ObjectDslBuilder.ts +64 -0
- package/src/core/PluginManager.ts +326 -0
- package/src/core/StringExtensions.ts +140 -0
- package/src/core/TemplateEngine.ts +44 -0
- package/src/core/Validator.ts +448 -0
- package/src/errors/I18nError.ts +159 -0
- package/src/errors/ValidationError.ts +105 -0
- package/src/exporters/BaseExporter.ts +60 -0
- package/src/exporters/MarkdownExporter.ts +305 -0
- package/src/exporters/MongoDBExporter.ts +126 -0
- package/src/exporters/MySQLExporter.ts +155 -0
- package/src/exporters/PostgreSQLExporter.ts +222 -0
- package/src/exporters/index.ts +18 -0
- package/src/index.ts +633 -0
- package/{lib/locales/en-US.js → src/locales/en-US.ts} +21 -37
- package/{lib/locales/es-ES.js → src/locales/es-ES.ts} +63 -16
- package/{lib/locales/fr-FR.js → src/locales/fr-FR.ts} +74 -27
- package/src/locales/index.ts +103 -0
- package/{lib/locales/ja-JP.js → src/locales/ja-JP.ts} +59 -17
- package/src/locales/types.ts +156 -0
- package/{lib/locales/zh-CN.js → src/locales/zh-CN.ts} +21 -38
- package/src/parser/ConstraintParser.ts +101 -0
- package/src/parser/DslParser.ts +470 -0
- package/src/parser/SchemaCompiler.ts +66 -0
- package/src/parser/TypeRegistry.ts +250 -0
- package/src/parser/index.ts +6 -0
- package/src/plugins/custom-format.ts +126 -0
- package/src/plugins/custom-type-example.ts +108 -0
- package/src/plugins/custom-validator.ts +140 -0
- package/src/types/conditional.ts +28 -0
- package/src/types/config.ts +59 -0
- package/src/types/dsl.ts +131 -0
- package/src/types/error.ts +60 -0
- package/src/types/index.ts +17 -0
- package/src/types/infer.ts +128 -0
- package/src/types/plugin.ts +58 -0
- package/src/types/safe-regex.d.ts +9 -0
- package/src/types/schema.ts +66 -0
- package/src/types/validate.ts +71 -0
- package/src/utils/SchemaHelper.ts +196 -0
- package/src/utils/SchemaUtils.ts +346 -0
- package/src/utils/TypeConverter.ts +215 -0
- package/src/utils/index.ts +10 -0
- package/src/validators/CustomKeywords.ts +477 -0
- package/.eslintignore +0 -11
- package/.eslintrc.json +0 -27
- package/CONTRIBUTING.md +0 -368
- package/STATUS.md +0 -491
- package/changelogs/v1.0.0.md +0 -328
- package/changelogs/v1.0.9.md +0 -367
- package/changelogs/v1.1.0.md +0 -389
- package/changelogs/v1.1.1.md +0 -308
- package/changelogs/v1.1.2.md +0 -183
- package/changelogs/v1.1.3.md +0 -161
- package/changelogs/v1.1.4.md +0 -432
- package/changelogs/v1.1.5.md +0 -493
- package/changelogs/v1.1.6.md +0 -211
- package/changelogs/v1.1.8.md +0 -376
- package/changelogs/v1.2.3.md +0 -124
- package/docs/INDEX.md +0 -252
- package/docs/issues-resolved-summary.md +0 -196
- package/docs/performance-benchmark-report.md +0 -179
- package/docs/performance-quick-reference.md +0 -123
- package/docs/user-questions-answered.md +0 -353
- package/docs/validation-rules-v1.0.2.md +0 -1608
- package/examples/README.md +0 -81
- package/examples/array-dsl-example.js +0 -227
- package/examples/conditional-example.js +0 -288
- package/examples/conditional-non-object.js +0 -129
- package/examples/conditional-validate-example.js +0 -321
- package/examples/custom-extension.js +0 -85
- package/examples/dsl-match-example.js +0 -74
- package/examples/dsl-style.js +0 -118
- package/examples/dynamic-locale-configuration.js +0 -348
- package/examples/dynamic-locale-example.js +0 -287
- package/examples/enum.examples.js +0 -324
- package/examples/export-demo.js +0 -130
- package/examples/express-integration.js +0 -376
- package/examples/i18n-error-handling-complete.js +0 -381
- package/examples/i18n-error-handling-quickstart.md +0 -0
- package/examples/i18n-error.examples.js +0 -181
- package/examples/i18n-full-demo.js +0 -301
- package/examples/i18n-memory-safety.examples.js +0 -268
- package/examples/markdown-export.js +0 -71
- package/examples/middleware-usage.js +0 -93
- package/examples/new-features-comparison.js +0 -315
- package/examples/password-reset/README.md +0 -153
- package/examples/password-reset/schema.js +0 -26
- package/examples/password-reset/test.js +0 -101
- package/examples/plugin-system.examples.js +0 -205
- package/examples/schema-utils-chaining.examples.js +0 -250
- package/examples/simple-example.js +0 -122
- package/examples/slug.examples.js +0 -179
- package/examples/string-extensions.js +0 -297
- package/examples/union-type-example.js +0 -127
- package/examples/union-types-example.js +0 -77
- package/examples/user-registration/README.md +0 -156
- package/examples/user-registration/routes.js +0 -92
- package/examples/user-registration/schema.js +0 -150
- package/examples/user-registration/server.js +0 -74
- package/index.d.ts +0 -3540
- package/index.js +0 -457
- package/index.mjs +0 -60
- package/lib/adapters/DslAdapter.js +0 -871
- package/lib/adapters/index.js +0 -20
- package/lib/config/constants.js +0 -286
- package/lib/config/patterns/common.js +0 -47
- package/lib/config/patterns/creditCard.js +0 -9
- package/lib/config/patterns/idCard.js +0 -9
- package/lib/config/patterns/index.js +0 -9
- package/lib/config/patterns/licensePlate.js +0 -4
- package/lib/config/patterns/passport.js +0 -4
- package/lib/config/patterns/phone.js +0 -9
- package/lib/config/patterns/postalCode.js +0 -5
- package/lib/core/CacheManager.js +0 -376
- package/lib/core/ConditionalBuilder.js +0 -503
- package/lib/core/DslBuilder.js +0 -1400
- package/lib/core/ErrorCodes.js +0 -233
- package/lib/core/ErrorFormatter.js +0 -445
- package/lib/core/JSONSchemaCore.js +0 -347
- package/lib/core/Locale.js +0 -130
- package/lib/core/MessageTemplate.js +0 -98
- package/lib/core/PluginManager.js +0 -448
- package/lib/core/StringExtensions.js +0 -240
- package/lib/core/Validator.js +0 -654
- package/lib/errors/I18nError.js +0 -328
- package/lib/errors/ValidationError.js +0 -191
- package/lib/exporters/MarkdownExporter.js +0 -420
- package/lib/exporters/MongoDBExporter.js +0 -162
- package/lib/exporters/MySQLExporter.js +0 -212
- package/lib/exporters/PostgreSQLExporter.js +0 -289
- package/lib/exporters/index.js +0 -24
- package/lib/locales/index.js +0 -8
- package/lib/utils/LRUCache.js +0 -174
- package/lib/utils/SchemaHelper.js +0 -240
- package/lib/utils/SchemaUtils.js +0 -445
- package/lib/utils/TypeConverter.js +0 -245
- package/lib/utils/index.js +0 -13
- package/lib/validators/CustomKeywords.js +0 -616
- package/lib/validators/index.js +0 -11
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { Ajv } from 'ajv'
|
|
2
|
+
import type { ValidateFunction, KeywordDefinition, Format } from 'ajv'
|
|
3
|
+
import addFormats from 'ajv-formats'
|
|
4
|
+
import type { JSONSchema } from '../types/schema.js'
|
|
5
|
+
import type { ValidateOptions, ValidationResult, ValidationErrorItem } from '../types/validate.js'
|
|
6
|
+
import type { ErrorMessages } from '../types/error.js'
|
|
7
|
+
import { CacheManager } from './CacheManager.js'
|
|
8
|
+
import type { CacheStats } from './CacheManager.js'
|
|
9
|
+
import { ErrorFormatter } from './ErrorFormatter.js'
|
|
10
|
+
import { CustomKeywords } from '../validators/CustomKeywords.js'
|
|
11
|
+
import { Locale } from './Locale.js'
|
|
12
|
+
import { ConditionalValidator, type ConditionalInternalSchema } from './ConditionalValidator.js'
|
|
13
|
+
|
|
14
|
+
// Non-AJV custom option keys (V-Y01 fix: filter before passing to new Ajv())
|
|
15
|
+
const NON_AJV_KEYS = new Set([
|
|
16
|
+
'cache', 'smartCoerce', 'locale', 'messages', 'format',
|
|
17
|
+
'strict', // v2 redefines this as strictSchema; do not forward to AJV
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
// AJV ValidateFunction type
|
|
21
|
+
type AjvValidateFn = ValidateFunction
|
|
22
|
+
type KeywordDefinitionInput = KeywordDefinition | ({ keyword?: string;[key: string]: unknown })
|
|
23
|
+
|
|
24
|
+
// Schema with _removeAdditional or _isConditional internal markers
|
|
25
|
+
type InternalSchema = JSONSchema & {
|
|
26
|
+
_removeAdditional?: boolean
|
|
27
|
+
_isConditional?: boolean
|
|
28
|
+
_runtimeOnlyConditional?: boolean
|
|
29
|
+
} & ConditionalInternalSchema
|
|
30
|
+
|
|
31
|
+
// Performance: share empty array on valid path to avoid `{ errors: [] }` allocation every time
|
|
32
|
+
const EMPTY_ERRORS: ValidationErrorItem[] = []
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ValidatorOptions — constructor options for Validator (extends AJV base options).
|
|
36
|
+
*/
|
|
37
|
+
export interface ValidatorOptions {
|
|
38
|
+
allErrors?: boolean
|
|
39
|
+
useDefaults?: boolean
|
|
40
|
+
coerceTypes?: boolean | 'array'
|
|
41
|
+
removeAdditional?: boolean | 'all' | 'failing'
|
|
42
|
+
verbose?: boolean
|
|
43
|
+
cache?: boolean | {
|
|
44
|
+
maxSize?: number
|
|
45
|
+
ttl?: number
|
|
46
|
+
enabled?: boolean
|
|
47
|
+
statsEnabled?: boolean
|
|
48
|
+
}
|
|
49
|
+
[key: string]: unknown
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validator — AJV-backed validator (v2).
|
|
54
|
+
*
|
|
55
|
+
* Fixes:
|
|
56
|
+
* V-Y01: filter non-AJV options before new Ajv() to prevent unknown-option warnings
|
|
57
|
+
* V-02: sync cleanSchema.required when conditional fields are removed (v1 missed this)
|
|
58
|
+
* V-Y03: _removeAdditional mode reuses cached internal Ajv instance (v1 created new Validator each time)
|
|
59
|
+
* V-Y07: static quickValidate reuses a singleton Ajv (v1 created new Ajv each time)
|
|
60
|
+
*/
|
|
61
|
+
export class Validator {
|
|
62
|
+
private readonly _ajvOptions: Record<string, unknown>
|
|
63
|
+
private readonly _ajv: InstanceType<typeof Ajv>
|
|
64
|
+
private readonly _cache: CacheManager
|
|
65
|
+
private readonly _errorFormatter: ErrorFormatter
|
|
66
|
+
|
|
67
|
+
// WeakMap: schema object → unique cacheKey (avoids JSON.stringify)
|
|
68
|
+
private readonly _schemaMap = new WeakMap<object, string>()
|
|
69
|
+
private _schemaKeyCounter = 0
|
|
70
|
+
|
|
71
|
+
// Performance: cache whether a schema has any conditional fields (avoids traversing properties on every validation)
|
|
72
|
+
private readonly _conditionalFlagCache = new WeakMap<object, boolean>()
|
|
73
|
+
private readonly _conditionalValidator = new ConditionalValidator({
|
|
74
|
+
validateSchema: <T>(schema: JSONSchema, data: T, options: ValidateOptions): ValidationResult<T> => this._validateInternal(schema, data, options),
|
|
75
|
+
internalError: <T>(error: unknown, data: T): ValidationResult<T> => this._internalError(error, data),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// V-Y03 fix: cached removeAdditional Ajv instance (no longer new Validator each time)
|
|
79
|
+
private _removeAdditionalAjv: InstanceType<typeof Ajv> | null = null
|
|
80
|
+
|
|
81
|
+
// V-Y07 fix: static singleton Ajv
|
|
82
|
+
private static _quickValidateAjv: InstanceType<typeof Ajv> | null = null
|
|
83
|
+
|
|
84
|
+
constructor(options: ValidatorOptions = {}) {
|
|
85
|
+
// V-Y01 fix: filter non-AJV options
|
|
86
|
+
const ajvOptions: Record<string, unknown> = {
|
|
87
|
+
allErrors: options.allErrors !== false,
|
|
88
|
+
useDefaults: options.useDefaults !== false,
|
|
89
|
+
coerceTypes: options.coerceTypes ?? false,
|
|
90
|
+
removeAdditional: options.removeAdditional ?? false,
|
|
91
|
+
verbose: true, // verbose mode: enables parentSchema access on error objects
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Forward remaining valid AJV options
|
|
95
|
+
for (const [k, v] of Object.entries(options)) {
|
|
96
|
+
if (!NON_AJV_KEYS.has(k) && !(k in ajvOptions)) {
|
|
97
|
+
ajvOptions[k] = v
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this._ajvOptions = ajvOptions
|
|
102
|
+
this._ajv = new Ajv(ajvOptions)
|
|
103
|
+
; (addFormats as unknown as (a: InstanceType<typeof Ajv>) => void)(this._ajv)
|
|
104
|
+
CustomKeywords.registerAll(this._ajv)
|
|
105
|
+
|
|
106
|
+
const cacheOpts = options.cache === false
|
|
107
|
+
? { enabled: false }
|
|
108
|
+
: options.cache === true || options.cache == null
|
|
109
|
+
? {}
|
|
110
|
+
: options.cache
|
|
111
|
+
|
|
112
|
+
this._cache = new CacheManager({
|
|
113
|
+
...(cacheOpts.maxSize !== undefined ? { maxSize: cacheOpts.maxSize } : {}),
|
|
114
|
+
...(cacheOpts.ttl !== undefined ? { ttl: cacheOpts.ttl } : {}),
|
|
115
|
+
...(cacheOpts.enabled !== undefined ? { enabled: cacheOpts.enabled } : {}),
|
|
116
|
+
...(cacheOpts.statsEnabled !== undefined ? { statsEnabled: cacheOpts.statsEnabled } : {}),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
this._errorFormatter = new ErrorFormatter()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get ajvOptions(): Record<string, unknown> {
|
|
123
|
+
return this._ajvOptions
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Public API ────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compile a schema → AJV validate function (with cache).
|
|
130
|
+
*/
|
|
131
|
+
compile(schema: JSONSchema, cacheKey?: string | null): AjvValidateFn {
|
|
132
|
+
const key = cacheKey ?? null
|
|
133
|
+
|
|
134
|
+
if (key) {
|
|
135
|
+
const cached = this._cache.get(key) as AjvValidateFn | null
|
|
136
|
+
if (cached !== null) return cached
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const validate = this._ajv.compile(schema)
|
|
141
|
+
if (key) this._cache.set(key, validate as unknown as object)
|
|
142
|
+
return validate
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new Error(`Schema compilation failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Synchronous validation.
|
|
150
|
+
*/
|
|
151
|
+
validate<T = unknown>(schema: JSONSchema | AjvValidateFn, data: T, options: ValidateOptions = {}): ValidationResult<T> {
|
|
152
|
+
return this._validateInternal(schema, data, options)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Async validation (throws ValidationError on failure).
|
|
157
|
+
* V-Y02 fix: v1 validateAsync lacked smartCoerceTypes; v2 routes through _validateInternal uniformly.
|
|
158
|
+
* BC-6 fix: validateAsync runs async custom validators (sync AJV pass skips async fn; this method runs the full set).
|
|
159
|
+
*/
|
|
160
|
+
async validateAsync<T = unknown>(schema: JSONSchema | AjvValidateFn, data: T, options: ValidateOptions = {}): Promise<T> {
|
|
161
|
+
// Resolve DslBuilder/ObjectDslBuilder duck type to raw schema (mirrors _validateInternal logic)
|
|
162
|
+
// so _runCustomValidators can access schema._customValidators
|
|
163
|
+
let resolvedSchema = schema as JSONSchema
|
|
164
|
+
if (typeof (schema as Record<string, unknown>)['toSchema'] === 'function') {
|
|
165
|
+
const obj = schema as Record<string, unknown>
|
|
166
|
+
resolvedSchema = (obj['toSchema'] as () => JSONSchema)()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const result = this._validateInternal(schema, data, options)
|
|
170
|
+
if (!result.valid) {
|
|
171
|
+
const { ValidationError } = await import('../errors/ValidationError.js')
|
|
172
|
+
throw new ValidationError(result.errors ?? [], data)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// BC-6: run async custom validators (sync AJV pass skips Promise-returning validators)
|
|
176
|
+
const customErr = await this._runCustomValidators(resolvedSchema, data)
|
|
177
|
+
if (customErr) {
|
|
178
|
+
const { ValidationError } = await import('../errors/ValidationError.js')
|
|
179
|
+
throw new ValidationError([customErr], data)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result.data as T
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* BC-6: run all validators in schema._customValidators (including async).
|
|
187
|
+
* AJV's sync keyword skips Promise-returning validators; this method runs the complete set in validateAsync.
|
|
188
|
+
* Returns the first failing ValidationErrorItem, or null if all pass.
|
|
189
|
+
*/
|
|
190
|
+
private async _runCustomValidators(schema: JSONSchema, data: unknown): Promise<ValidationErrorItem | null> {
|
|
191
|
+
const validators = (schema as Record<string, unknown>)['_customValidators'] as Array<(v: unknown) => unknown> | undefined
|
|
192
|
+
if (!validators?.length) return null
|
|
193
|
+
|
|
194
|
+
for (const fn of validators) {
|
|
195
|
+
try {
|
|
196
|
+
const result = await Promise.resolve(fn(data))
|
|
197
|
+
if (result === false) {
|
|
198
|
+
return { message: Locale.getMessageText('CUSTOM_VALIDATION_FAILED'), path: '', keyword: '_customValidators', params: {} }
|
|
199
|
+
}
|
|
200
|
+
if (typeof result === 'string') {
|
|
201
|
+
return { message: result, path: '', keyword: '_customValidators', params: {} }
|
|
202
|
+
}
|
|
203
|
+
if (result !== null && typeof result === 'object' && (result as Record<string, unknown>)['error']) {
|
|
204
|
+
const r = result as { error: unknown; message?: string }
|
|
205
|
+
return {
|
|
206
|
+
message: r.message ?? Locale.getMessageText('CUSTOM_VALIDATION_FAILED'),
|
|
207
|
+
path: '',
|
|
208
|
+
keyword: '_customValidators',
|
|
209
|
+
params: {},
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return {
|
|
214
|
+
message: err instanceof Error ? err.message : String(err),
|
|
215
|
+
path: '',
|
|
216
|
+
keyword: '_customValidators',
|
|
217
|
+
params: {},
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Batch validation (compile once, reuse for each item).
|
|
226
|
+
*/
|
|
227
|
+
validateBatch<T = unknown>(schema: JSONSchema, dataArray: T[]): ValidationResult<T>[] {
|
|
228
|
+
if (!Array.isArray(dataArray)) throw new Error('Data must be an array')
|
|
229
|
+
const cacheKey = this._generateCacheKey(schema)
|
|
230
|
+
const validate = this.compile(schema, cacheKey)
|
|
231
|
+
return dataArray.map(data => this.validate(validate, data))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Add a custom keyword.
|
|
236
|
+
*/
|
|
237
|
+
addKeyword(keyword: string, definition: KeywordDefinitionInput): this {
|
|
238
|
+
try {
|
|
239
|
+
this._ajv.addKeyword({
|
|
240
|
+
...definition,
|
|
241
|
+
keyword,
|
|
242
|
+
})
|
|
243
|
+
return this
|
|
244
|
+
} catch (error) {
|
|
245
|
+
throw new Error(`Failed to add keyword '${keyword}': ${error instanceof Error ? error.message : String(error)}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Add a custom format.
|
|
251
|
+
*/
|
|
252
|
+
addFormat(name: string, validator: Format): this {
|
|
253
|
+
this._ajv.addFormat(name, validator)
|
|
254
|
+
return this
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Add a schema reference.
|
|
259
|
+
*/
|
|
260
|
+
addSchema(uri: string, schema: JSONSchema): this {
|
|
261
|
+
this._ajv.addSchema(schema, uri)
|
|
262
|
+
return this
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Remove a schema reference.
|
|
267
|
+
*/
|
|
268
|
+
removeSchema(uri: string): this {
|
|
269
|
+
this._ajv.removeSchema(uri)
|
|
270
|
+
return this
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
getAjv(): InstanceType<typeof Ajv> { return this._ajv }
|
|
274
|
+
get cache(): CacheManager { return this._cache }
|
|
275
|
+
clearCache(): void { this._cache.clear() }
|
|
276
|
+
getCacheStats(): CacheStats { return this._cache.getStats() }
|
|
277
|
+
|
|
278
|
+
// ─── Static Factory ────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
static create(options?: ValidatorOptions): Validator {
|
|
281
|
+
return new Validator(options)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Quick validate (V-Y07 fix: reuses singleton Ajv instead of creating new Ajv each time).
|
|
286
|
+
*/
|
|
287
|
+
static quickValidate(schema: JSONSchema, data: unknown): boolean {
|
|
288
|
+
if (!Validator._quickValidateAjv) {
|
|
289
|
+
Validator._quickValidateAjv = new Ajv()
|
|
290
|
+
; (addFormats as unknown as (a: InstanceType<typeof Ajv>) => void)(Validator._quickValidateAjv)
|
|
291
|
+
CustomKeywords.registerAll(Validator._quickValidateAjv)
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
return Validator._quickValidateAjv.validate(schema, data) as boolean
|
|
295
|
+
} catch {
|
|
296
|
+
return false
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─── Internal Implementation ───────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
private _validateInternal<T>(
|
|
303
|
+
schema: JSONSchema | AjvValidateFn,
|
|
304
|
+
data: T,
|
|
305
|
+
options: ValidateOptions = {}
|
|
306
|
+
): ValidationResult<T> {
|
|
307
|
+
const shouldFormat = options.format !== false
|
|
308
|
+
const locale = options.locale ?? Locale.getLocale()
|
|
309
|
+
const messages = (options.messages ?? {}) as ErrorMessages
|
|
310
|
+
|
|
311
|
+
// DslBuilder/ObjectDslBuilder/ConditionalBuilder duck type.
|
|
312
|
+
// Builders are mutable, so their toSchema() result must be re-materialized on every call.
|
|
313
|
+
if (typeof (schema as Record<string, unknown>)['toSchema'] === 'function') {
|
|
314
|
+
const obj = schema as Record<string, unknown>
|
|
315
|
+
schema = (obj['toSchema'] as () => JSONSchema)()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const internalSchema = schema as InternalSchema
|
|
319
|
+
|
|
320
|
+
// ConditionalBuilder (top-level)
|
|
321
|
+
if (internalSchema._isConditional) {
|
|
322
|
+
return this._conditionalValidator.validateConditional(internalSchema, data as Record<string, unknown>, null, data, options)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Object schema containing ConditionalBuilder properties (including arbitrary nesting depth)
|
|
326
|
+
if (internalSchema.properties) {
|
|
327
|
+
// Performance: cache conditional detection result to avoid traversing properties on every validation
|
|
328
|
+
let hasConditionals = this._conditionalFlagCache.get(internalSchema as object)
|
|
329
|
+
if (hasConditionals === undefined) {
|
|
330
|
+
hasConditionals = this._conditionalValidator.hasAnyConditional(internalSchema)
|
|
331
|
+
this._conditionalFlagCache.set(internalSchema as object, hasConditionals)
|
|
332
|
+
}
|
|
333
|
+
if (hasConditionals) {
|
|
334
|
+
return this._conditionalValidator.validateWithConditionals(internalSchema, data, options)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// V-Y03 fix: _removeAdditional reuses internal Ajv instance
|
|
339
|
+
if (internalSchema._removeAdditional) {
|
|
340
|
+
if (!this._removeAdditionalAjv) {
|
|
341
|
+
this._removeAdditionalAjv = new Ajv({ ...this._ajvOptions, removeAdditional: true })
|
|
342
|
+
; (addFormats as unknown as (a: InstanceType<typeof Ajv>) => void)(this._removeAdditionalAjv)
|
|
343
|
+
CustomKeywords.registerAll(this._removeAdditionalAjv)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const cleanSchema: JSONSchema = JSON.parse(JSON.stringify(schema)) as JSONSchema
|
|
347
|
+
delete (cleanSchema as InternalSchema)._removeAdditional
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const validate = this._removeAdditionalAjv.compile(cleanSchema)
|
|
351
|
+
const valid = validate(data) as boolean
|
|
352
|
+
if (valid) return { valid: true, data, errors: EMPTY_ERRORS }
|
|
353
|
+
const fmtErrors = this._formatErrors(validate.errors ?? [], messages, locale, shouldFormat)
|
|
354
|
+
return { valid: false, data, errors: fmtErrors, errorMessage: fmtErrors[0]?.message }
|
|
355
|
+
} catch (error) {
|
|
356
|
+
return this._internalError(error, data)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
let validate: AjvValidateFn
|
|
362
|
+
if (typeof schema === 'function') {
|
|
363
|
+
validate = schema as AjvValidateFn
|
|
364
|
+
} else {
|
|
365
|
+
// Performance: merge _generateCacheKey + compile() into a single WeakMap lookup
|
|
366
|
+
const schemaObj = schema as object
|
|
367
|
+
let cacheKey = this._schemaMap.get(schemaObj)
|
|
368
|
+
if (!cacheKey) {
|
|
369
|
+
cacheKey = `s${++this._schemaKeyCounter}`
|
|
370
|
+
this._schemaMap.set(schemaObj, cacheKey)
|
|
371
|
+
}
|
|
372
|
+
const cached = this._cache.get(cacheKey) as AjvValidateFn | null
|
|
373
|
+
if (cached !== null) {
|
|
374
|
+
validate = cached
|
|
375
|
+
} else {
|
|
376
|
+
try {
|
|
377
|
+
validate = this._ajv.compile(schema as JSONSchema)
|
|
378
|
+
this._cache.set(cacheKey, validate as unknown as object)
|
|
379
|
+
} catch (error) {
|
|
380
|
+
throw new Error(`Schema compilation failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const valid = validate(data) as boolean
|
|
386
|
+
if (valid) return { valid: true, data, errors: EMPTY_ERRORS }
|
|
387
|
+
const fmtErrors2 = this._formatErrors(validate.errors ?? [], messages, locale, shouldFormat)
|
|
388
|
+
return { valid: false, data, errors: fmtErrors2, errorMessage: fmtErrors2[0]?.message }
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return this._internalError(error, data)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ─── Helper methods ─────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
private _generateCacheKey(schema: object): string {
|
|
397
|
+
if (!this._schemaMap.has(schema)) {
|
|
398
|
+
this._schemaMap.set(schema, `schema_${++this._schemaKeyCounter}`)
|
|
399
|
+
}
|
|
400
|
+
return this._schemaMap.get(schema)!
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Performance: cache flattened locale messages (key = locale, value = flat ErrorMessages)
|
|
404
|
+
// to avoid re-running Locale.getMessages + Object.entries.map on every validation failure
|
|
405
|
+
private readonly _flatLocaleCache = new Map<string, ErrorMessages>()
|
|
406
|
+
|
|
407
|
+
private _getFlatLocaleMessages(locale: string): ErrorMessages {
|
|
408
|
+
let flat = this._flatLocaleCache.get(locale)
|
|
409
|
+
if (!flat) {
|
|
410
|
+
const raw = Locale.getMessages(locale)
|
|
411
|
+
flat = Object.fromEntries(
|
|
412
|
+
Object.entries(raw).map(([k, v]) => [
|
|
413
|
+
k,
|
|
414
|
+
typeof v === 'string' ? v : (v as { message: string }).message,
|
|
415
|
+
])
|
|
416
|
+
) as ErrorMessages
|
|
417
|
+
this._flatLocaleCache.set(locale, flat)
|
|
418
|
+
}
|
|
419
|
+
return flat
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private _formatErrors(
|
|
423
|
+
rawErrors: unknown[],
|
|
424
|
+
messages: ErrorMessages,
|
|
425
|
+
locale: string,
|
|
426
|
+
shouldFormat: boolean
|
|
427
|
+
): ValidationErrorItem[] {
|
|
428
|
+
if (!shouldFormat) return rawErrors as ValidationErrorItem[]
|
|
429
|
+
const localeMessages = this._getFlatLocaleMessages(locale)
|
|
430
|
+
// Only merge when there are custom messages (avoid unnecessary object spread)
|
|
431
|
+
const mergedMessages: ErrorMessages =
|
|
432
|
+
Object.keys(messages).length === 0
|
|
433
|
+
? localeMessages
|
|
434
|
+
: { ...localeMessages, ...messages }
|
|
435
|
+
// alreadyMerged=true: mergedMessages already contains locale+custom, skip re-expansion inside formatDetailed
|
|
436
|
+
return this._errorFormatter.formatDetailed(rawErrors as Parameters<ErrorFormatter['formatDetailed']>[0], locale, mergedMessages, true)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private _internalError<T>(error: unknown, data: T): ValidationResult<T> {
|
|
440
|
+
const message = `Validation error: ${error instanceof Error ? error.message : String(error)}`
|
|
441
|
+
return {
|
|
442
|
+
valid: false,
|
|
443
|
+
data,
|
|
444
|
+
errors: [{ message, path: '', keyword: 'error', params: {} }],
|
|
445
|
+
errorMessage: message,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { renderTemplate } from '../core/TemplateEngine.js'
|
|
2
|
+
import { Locale } from '../core/Locale.js'
|
|
3
|
+
|
|
4
|
+
// V8/Node.js extension
|
|
5
|
+
type ErrorWithCaptureStackTrace = typeof Error & {
|
|
6
|
+
captureStackTrace?: (target: object, ctor: unknown) => void
|
|
7
|
+
}
|
|
8
|
+
const ErrorCtor = Error as ErrorWithCaptureStackTrace
|
|
9
|
+
|
|
10
|
+
type ParamsOrLocale = Record<string, unknown> | string | null | undefined
|
|
11
|
+
|
|
12
|
+
function normalizeParams(
|
|
13
|
+
paramsOrLocale: ParamsOrLocale,
|
|
14
|
+
statusCode?: unknown,
|
|
15
|
+
locale?: unknown
|
|
16
|
+
): { params: Record<string, unknown>; statusCode: number; locale: string | null } {
|
|
17
|
+
let params: Record<string, unknown> = {}
|
|
18
|
+
let actualStatusCode = 400
|
|
19
|
+
let actualLocale: string | null = null
|
|
20
|
+
|
|
21
|
+
if (typeof paramsOrLocale === 'string') {
|
|
22
|
+
actualLocale = paramsOrLocale
|
|
23
|
+
actualStatusCode = typeof statusCode === 'number' ? statusCode : 400
|
|
24
|
+
} else if (paramsOrLocale && typeof paramsOrLocale === 'object' && !Array.isArray(paramsOrLocale)) {
|
|
25
|
+
params = paramsOrLocale
|
|
26
|
+
actualStatusCode = typeof statusCode === 'number' ? statusCode : 400
|
|
27
|
+
actualLocale = typeof locale === 'string' ? locale : null
|
|
28
|
+
} else {
|
|
29
|
+
actualStatusCode = typeof statusCode === 'number' ? statusCode : 400
|
|
30
|
+
actualLocale = typeof locale === 'string' ? locale : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { params, statusCode: actualStatusCode, locale: actualLocale }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Internationalised error class.
|
|
38
|
+
* Maintains full v1 API compatibility: create / throw / assert / is / toJSON / toString
|
|
39
|
+
*/
|
|
40
|
+
export class I18nError extends Error {
|
|
41
|
+
readonly name = 'I18nError' as const
|
|
42
|
+
readonly originalKey: string
|
|
43
|
+
readonly code: string | number
|
|
44
|
+
readonly params: Record<string, unknown>
|
|
45
|
+
readonly statusCode: number
|
|
46
|
+
readonly locale: string | null
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
key: string,
|
|
50
|
+
params: Record<string, unknown> = {},
|
|
51
|
+
statusCode = 400,
|
|
52
|
+
locale: string | null = null,
|
|
53
|
+
/** Internal: pre-resolved message template, bypasses Locale lookup (used to decouple init ordering). */
|
|
54
|
+
_resolvedTemplate?: string,
|
|
55
|
+
_resolvedCode?: string | number
|
|
56
|
+
) {
|
|
57
|
+
const targetLocale = locale ?? Locale.getLocale()
|
|
58
|
+
const normalizedParams: Record<string, unknown> = (params !== null && params !== undefined) ? params : {}
|
|
59
|
+
|
|
60
|
+
// Look up locale message config if not pre-resolved
|
|
61
|
+
let template: string
|
|
62
|
+
let resolvedCode: string | number
|
|
63
|
+
if (_resolvedTemplate !== undefined) {
|
|
64
|
+
template = _resolvedTemplate
|
|
65
|
+
resolvedCode = _resolvedCode ?? key
|
|
66
|
+
} else {
|
|
67
|
+
const msgConfig = Locale.getMessageConfig(key, {}, targetLocale)
|
|
68
|
+
if (typeof msgConfig === 'object' && msgConfig !== null && 'message' in msgConfig) {
|
|
69
|
+
template = (msgConfig as { message: string }).message
|
|
70
|
+
resolvedCode = (msgConfig as { code?: string | number }).code ?? key
|
|
71
|
+
} else if (typeof msgConfig === 'string') {
|
|
72
|
+
template = msgConfig
|
|
73
|
+
resolvedCode = key
|
|
74
|
+
} else {
|
|
75
|
+
template = key
|
|
76
|
+
resolvedCode = key
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const message = renderTemplate(template, normalizedParams)
|
|
81
|
+
|
|
82
|
+
super(message)
|
|
83
|
+
|
|
84
|
+
this.originalKey = key
|
|
85
|
+
this.code = resolvedCode
|
|
86
|
+
this.params = normalizedParams
|
|
87
|
+
this.statusCode = statusCode
|
|
88
|
+
this.locale = targetLocale
|
|
89
|
+
|
|
90
|
+
if (ErrorCtor.captureStackTrace) {
|
|
91
|
+
ErrorCtor.captureStackTrace(this, I18nError)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Factory method — create an error instance. */
|
|
96
|
+
static create(
|
|
97
|
+
code: string,
|
|
98
|
+
paramsOrLocale?: ParamsOrLocale,
|
|
99
|
+
statusCode?: number,
|
|
100
|
+
locale?: string
|
|
101
|
+
): I18nError {
|
|
102
|
+
const normalized = normalizeParams(paramsOrLocale, statusCode, locale)
|
|
103
|
+
return new I18nError(code, normalized.params, normalized.statusCode, normalized.locale)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Factory method — create and throw an error. */
|
|
107
|
+
static throw(
|
|
108
|
+
code: string,
|
|
109
|
+
paramsOrLocale?: ParamsOrLocale,
|
|
110
|
+
statusCode?: number,
|
|
111
|
+
locale?: string
|
|
112
|
+
): never {
|
|
113
|
+
const normalized = normalizeParams(paramsOrLocale, statusCode, locale)
|
|
114
|
+
throw new I18nError(code, normalized.params, normalized.statusCode, normalized.locale)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Assert — throw when condition is falsy. */
|
|
118
|
+
static assert(
|
|
119
|
+
condition: unknown,
|
|
120
|
+
code: string,
|
|
121
|
+
paramsOrLocale?: ParamsOrLocale,
|
|
122
|
+
statusCode?: number,
|
|
123
|
+
locale?: string
|
|
124
|
+
): asserts condition {
|
|
125
|
+
if (!condition) {
|
|
126
|
+
const normalized = normalizeParams(paramsOrLocale, statusCode, locale)
|
|
127
|
+
throw new I18nError(code, normalized.params, normalized.statusCode, normalized.locale)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Check whether the error matches the given code or original key. */
|
|
132
|
+
is(codeOrKey: string | number): boolean {
|
|
133
|
+
return this.code === codeOrKey || this.originalKey === codeOrKey
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
toJSON(): {
|
|
137
|
+
error: string
|
|
138
|
+
originalKey: string
|
|
139
|
+
code: string | number
|
|
140
|
+
message: string
|
|
141
|
+
params: Record<string, unknown>
|
|
142
|
+
statusCode: number
|
|
143
|
+
locale: string | null
|
|
144
|
+
} {
|
|
145
|
+
return {
|
|
146
|
+
error: this.name,
|
|
147
|
+
originalKey: this.originalKey,
|
|
148
|
+
code: this.code,
|
|
149
|
+
message: this.message,
|
|
150
|
+
params: this.params,
|
|
151
|
+
statusCode: this.statusCode,
|
|
152
|
+
locale: this.locale,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
toString(): string {
|
|
157
|
+
return `${this.name} [${this.code}]: ${this.message}`
|
|
158
|
+
}
|
|
159
|
+
}
|