schema-dsl 2.0.0 → 2.0.1
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 +130 -113
- package/LICENSE +21 -21
- package/README.md +628 -628
- package/dist/{DslBuilder-DkLaOo9Q.d.ts → DslBuilder-BIgQOAXp.d.ts} +2 -0
- package/dist/{DslBuilder-DQDN0ZxZ.d.cts → DslBuilder-CjHTucNQ.d.cts} +2 -0
- package/dist/{Validator-hFWKGxir.d.ts → Validator-CllRdrY0.d.ts} +1 -1
- package/dist/{Validator-C7GsVQOH.d.cts → Validator-D6okG9tr.d.cts} +1 -1
- package/dist/index.cjs +75 -29
- package/dist/index.d.cts +10 -4
- package/dist/index.d.ts +10 -4
- package/dist/index.js +75 -29
- package/dist/plugins/custom-format.cjs +33 -17
- package/dist/plugins/custom-format.d.cts +1 -1
- package/dist/plugins/custom-format.d.ts +1 -1
- package/dist/plugins/custom-format.js +33 -17
- package/dist/plugins/custom-type-example.cjs +33 -17
- package/dist/plugins/custom-type-example.d.cts +1 -1
- package/dist/plugins/custom-type-example.d.ts +1 -1
- package/dist/plugins/custom-type-example.js +33 -17
- package/dist/plugins/custom-validator.cjs +0 -2
- package/dist/plugins/custom-validator.d.cts +1 -1
- package/dist/plugins/custom-validator.d.ts +1 -1
- package/dist/plugins/custom-validator.js +0 -2
- package/docs/FEATURE-INDEX.md +553 -553
- package/docs/add-custom-locale.md +496 -496
- package/docs/add-keyword.md +24 -24
- package/docs/api-reference.md +1047 -1047
- package/docs/api.md +13 -13
- package/docs/best-practices-project-structure.md +417 -417
- package/docs/best-practices.md +712 -712
- package/docs/cache-manager.md +344 -344
- package/docs/compile.md +45 -45
- package/docs/conditional-api.md +1307 -1307
- package/docs/custom-extensions-guide.md +339 -339
- package/docs/design-philosophy.md +606 -606
- package/docs/doc-index.md +324 -324
- package/docs/dsl-syntax.md +714 -714
- package/docs/dynamic-locale.md +608 -608
- package/docs/enum.md +482 -482
- package/docs/error-handling.md +1975 -1975
- package/docs/export-guide.md +501 -501
- package/docs/export-limitations.md +567 -567
- package/docs/faq.md +596 -596
- package/docs/frontend-i18n-guide.md +307 -307
- package/docs/i18n-user-guide.md +487 -487
- package/docs/i18n.md +476 -476
- package/docs/index.md +48 -48
- package/docs/json-schema-basics.md +40 -40
- package/docs/label-vs-description.md +271 -271
- package/docs/markdown-exporter.md +406 -406
- package/docs/mongodb-exporter.md +302 -302
- package/docs/multi-language.md +26 -26
- package/docs/multi-type-support.md +322 -322
- package/docs/mysql-exporter.md +280 -280
- package/docs/number-operators.md +449 -449
- package/docs/optional-marker-guide.md +326 -326
- package/docs/performance-guide.md +49 -49
- package/docs/plugin-system.md +381 -381
- package/docs/plugin-type-registration.md +34 -34
- package/docs/postgresql-exporter.md +311 -311
- package/docs/public/favicon.svg +4 -4
- package/docs/quick-start.md +435 -435
- package/docs/runtime-locale-support.md +532 -532
- package/docs/schema-helper.md +345 -345
- package/docs/schema-utils-advanced-issues.md +23 -23
- package/docs/schema-utils-best-practices.md +20 -20
- package/docs/schema-utils-chaining.md +150 -150
- package/docs/schema-utils.md +524 -524
- package/docs/security-checklist.md +20 -20
- package/docs/string-extensions.md +488 -488
- package/docs/troubleshooting.md +486 -486
- package/docs/type-converter.md +310 -310
- package/docs/type-reference.md +242 -242
- package/docs/typescript-guide.md +584 -584
- package/docs/union-type-guide.md +157 -157
- package/docs/union-types.md +284 -284
- package/docs/validate-async.md +491 -491
- package/docs/validate-batch.md +49 -49
- package/docs/validate-dsl-object-support.md +578 -578
- package/docs/validate.md +506 -506
- package/docs/validation-guide.md +502 -502
- package/docs/validator.md +39 -39
- package/package.json +131 -131
- package/plugins/custom-format.cjs +8 -8
- package/plugins/custom-type-example.cjs +8 -8
- package/plugins/custom-validator.cjs +8 -8
- package/src/adapters/DslAdapter.ts +111 -111
- package/src/adapters/index.ts +1 -1
- package/src/config/constants.ts +83 -83
- package/src/config/index.ts +2 -2
- package/src/config/patterns.ts +77 -77
- package/src/core/CacheManager.ts +169 -159
- package/src/core/ConditionalBuilder.ts +382 -382
- package/src/core/ConditionalRuntime.ts +27 -27
- package/src/core/ConditionalValidator.ts +254 -254
- package/src/core/DslBuilder.ts +687 -677
- package/src/core/ErrorCodes.ts +38 -38
- package/src/core/ErrorFormatter.ts +271 -271
- package/src/core/JSONSchemaCore.ts +65 -65
- package/src/core/Locale.ts +187 -187
- package/src/core/MessageTemplate.ts +42 -42
- package/src/core/ObjectDslBuilder.ts +64 -64
- package/src/core/PluginManager.ts +326 -326
- package/src/core/StringExtensions.ts +140 -140
- package/src/core/TemplateEngine.ts +44 -44
- package/src/core/Validator.ts +448 -448
- package/src/errors/I18nError.ts +159 -159
- package/src/errors/ValidationError.ts +105 -105
- package/src/exporters/BaseExporter.ts +60 -60
- package/src/exporters/MarkdownExporter.ts +305 -305
- package/src/exporters/MongoDBExporter.ts +126 -126
- package/src/exporters/MySQLExporter.ts +156 -155
- package/src/exporters/PostgreSQLExporter.ts +222 -222
- package/src/exporters/index.ts +18 -18
- package/src/index.ts +651 -633
- package/src/locales/en-US.ts +160 -160
- package/src/locales/es-ES.ts +160 -160
- package/src/locales/fr-FR.ts +160 -160
- package/src/locales/index.ts +103 -103
- package/src/locales/ja-JP.ts +160 -160
- package/src/locales/types.ts +156 -156
- package/src/locales/zh-CN.ts +160 -160
- package/src/parser/ConstraintParser.ts +101 -101
- package/src/parser/DslParser.ts +470 -470
- package/src/parser/SchemaCompiler.ts +66 -66
- package/src/parser/TypeRegistry.ts +250 -250
- package/src/parser/index.ts +6 -6
- package/src/plugins/custom-format.ts +124 -126
- package/src/plugins/custom-type-example.ts +106 -108
- package/src/plugins/custom-validator.ts +138 -140
- package/src/types/conditional.ts +28 -28
- package/src/types/config.ts +59 -59
- package/src/types/dsl.ts +131 -131
- package/src/types/error.ts +60 -60
- package/src/types/index.ts +17 -17
- package/src/types/infer.ts +127 -127
- package/src/types/plugin.ts +58 -58
- package/src/types/safe-regex.d.ts +9 -9
- package/src/types/schema.ts +66 -66
- package/src/types/validate.ts +71 -71
- package/src/utils/SchemaHelper.ts +196 -196
- package/src/utils/SchemaUtils.ts +365 -346
- package/src/utils/TypeConverter.ts +215 -215
- package/src/utils/index.ts +10 -10
- package/src/validators/CustomKeywords.ts +477 -477
|
@@ -1,477 +1,477 @@
|
|
|
1
|
-
import type { Ajv, ErrorObject } from 'ajv'
|
|
2
|
-
import safeRegex from 'safe-regex'
|
|
3
|
-
import { Locale } from '../core/Locale.js'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// AJV DataValidateFunction compatible type
|
|
7
|
-
type ValidateFnWithErrors = ((schema: unknown, data: unknown, parentSchema?: unknown) => boolean) & {
|
|
8
|
-
errors?: Partial<ErrorObject>[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* CustomKeywords — AJV custom keyword registrar
|
|
13
|
-
*
|
|
14
|
-
* Fixes:
|
|
15
|
-
* CK-01: internally uses getMessageText() to obtain strings, avoiding v1 compat objects
|
|
16
|
-
* that serialized as "[object Object]"
|
|
17
|
-
* CK-02: regex keyword error messages use locale keys instead of concatenating raw messages
|
|
18
|
-
* CK-Y04: exactLength uses Unicode code-point counting ([...str].length) instead of
|
|
19
|
-
* str.length, correctly handling emoji and multi-byte characters
|
|
20
|
-
*/
|
|
21
|
-
export class CustomKeywords {
|
|
22
|
-
/**
|
|
23
|
-
* Register all custom keywords on an AJV instance
|
|
24
|
-
*/
|
|
25
|
-
static registerAll(ajv: Ajv): void {
|
|
26
|
-
CustomKeywords.registerRegexKeyword(ajv)
|
|
27
|
-
CustomKeywords.registerFunctionKeyword(ajv)
|
|
28
|
-
CustomKeywords.registerCustomValidatorsKeyword(ajv)
|
|
29
|
-
CustomKeywords.registerMetadataKeywords(ajv)
|
|
30
|
-
CustomKeywords.registerStringValidators(ajv)
|
|
31
|
-
CustomKeywords.registerNumberValidators(ajv)
|
|
32
|
-
CustomKeywords.registerObjectValidators(ajv)
|
|
33
|
-
CustomKeywords.registerArrayValidators(ajv)
|
|
34
|
-
CustomKeywords.registerDateValidators(ajv)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ─── Metadata keywords ──────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
static registerMetadataKeywords(ajv: Ajv): void {
|
|
40
|
-
ajv.addKeyword({ keyword: '_label', metaSchema: { type: 'string' } })
|
|
41
|
-
ajv.addKeyword({ keyword: '_customMessages', metaSchema: { type: 'object' } })
|
|
42
|
-
ajv.addKeyword({ keyword: '_description', metaSchema: { type: 'string' } })
|
|
43
|
-
ajv.addKeyword({ keyword: '_whenConditions', metaSchema: { type: 'array' } })
|
|
44
|
-
ajv.addKeyword({ keyword: '_required', metaSchema: { type: 'boolean' } })
|
|
45
|
-
// Conditional schema marker: prevents AJV strict mode from throwing an unknown-keyword error
|
|
46
|
-
ajv.addKeyword({ keyword: '_isConditional', metaSchema: { type: 'boolean' } })
|
|
47
|
-
ajv.addKeyword({ keyword: '_runtimeOnlyConditional', metaSchema: { type: 'boolean' } })
|
|
48
|
-
ajv.addKeyword({ keyword: 'conditions' })
|
|
49
|
-
ajv.addKeyword({ keyword: '_evaluateCondition' })
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ─── _customValidators ──────────────────────────────────────────────────
|
|
53
|
-
|
|
54
|
-
static registerCustomValidatorsKeyword(ajv: Ajv): void {
|
|
55
|
-
const validate: ValidateFnWithErrors = (validators: unknown, data: unknown): boolean => {
|
|
56
|
-
if (!Array.isArray(validators)) return true
|
|
57
|
-
|
|
58
|
-
for (const validator of validators as unknown[]) {
|
|
59
|
-
if (typeof validator !== 'function') continue
|
|
60
|
-
try {
|
|
61
|
-
const result = (validator as (d: unknown) => unknown)(data)
|
|
62
|
-
|
|
63
|
-
if (result instanceof Promise) {
|
|
64
|
-
// BC-6: async validators are not supported in the synchronous AJV validate() path.
|
|
65
|
-
// Return an explicit error so callers know to use validateAsync() instead.
|
|
66
|
-
validate.errors = [{
|
|
67
|
-
keyword: '_customValidators',
|
|
68
|
-
message: 'Async validation not supported in sync validate(). Use validateAsync() instead.',
|
|
69
|
-
params: {},
|
|
70
|
-
}]
|
|
71
|
-
return false
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (result === false) {
|
|
75
|
-
const msg = Locale.getMessageText('CUSTOM_VALIDATION_FAILED')
|
|
76
|
-
validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
|
|
77
|
-
return false
|
|
78
|
-
}
|
|
79
|
-
if (typeof result === 'string') {
|
|
80
|
-
validate.errors = [{ keyword: '_customValidators', message: result, params: {} }]
|
|
81
|
-
return false
|
|
82
|
-
}
|
|
83
|
-
if (result !== null && typeof result === 'object' && (result as Record<string, unknown>)['error']) {
|
|
84
|
-
const msg = String((result as Record<string, unknown>)['message'] ?? Locale.getMessageText('CUSTOM_VALIDATION_FAILED'))
|
|
85
|
-
validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
|
|
86
|
-
return false
|
|
87
|
-
}
|
|
88
|
-
} catch (error) {
|
|
89
|
-
const msg = error instanceof Error ? error.message : String(error)
|
|
90
|
-
validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
|
|
91
|
-
return false
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return true
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
ajv.addKeyword({ keyword: '_customValidators', validate, errors: true })
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ─── regex ──────────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
// Detect potentially catastrophic patterns via a dedicated regex safety analyzer
|
|
103
|
-
private static _isUnsafePattern(pattern: string | RegExp): boolean {
|
|
104
|
-
return !safeRegex(pattern)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
static registerRegexKeyword(ajv: Ajv): void {
|
|
108
|
-
const validate: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
109
|
-
const patternStr = String(schema)
|
|
110
|
-
try {
|
|
111
|
-
const regex = new RegExp(patternStr)
|
|
112
|
-
if (CustomKeywords._isUnsafePattern(regex)) {
|
|
113
|
-
validate.errors = [{
|
|
114
|
-
keyword: 'regex',
|
|
115
|
-
message: Locale.getMessageText('string.pattern'),
|
|
116
|
-
params: { pattern: patternStr, reason: 'unsafe regex pattern' },
|
|
117
|
-
}]
|
|
118
|
-
return false
|
|
119
|
-
}
|
|
120
|
-
if (regex.test(String(data))) return true
|
|
121
|
-
// CK-02 fix: use locale key instead of concatenating raw error message
|
|
122
|
-
validate.errors = [{
|
|
123
|
-
keyword: 'regex',
|
|
124
|
-
message: Locale.getMessageText('string.pattern'),
|
|
125
|
-
params: { pattern: schema },
|
|
126
|
-
}]
|
|
127
|
-
return false
|
|
128
|
-
} catch (error) {
|
|
129
|
-
// CK-02 fix: invalid regex also uses locale key
|
|
130
|
-
validate.errors = [{
|
|
131
|
-
keyword: 'regex',
|
|
132
|
-
message: Locale.getMessageText('string.pattern'),
|
|
133
|
-
params: { error: error instanceof Error ? error.message : String(error) },
|
|
134
|
-
}]
|
|
135
|
-
return false
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
ajv.addKeyword({ keyword: 'regex', type: 'string', schemaType: 'string', validate, errors: true })
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ─── validate (function validator) ──────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
static registerFunctionKeyword(ajv: Ajv): void {
|
|
145
|
-
const validate: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
146
|
-
if (typeof schema !== 'function') {
|
|
147
|
-
validate.errors = [{
|
|
148
|
-
keyword: 'validate',
|
|
149
|
-
message: Locale.getMessageText('VALIDATE_MUST_BE_FUNCTION'),
|
|
150
|
-
params: {},
|
|
151
|
-
}]
|
|
152
|
-
return false
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
const result = (schema as (d: unknown) => unknown)(data)
|
|
157
|
-
if (typeof result === 'boolean') return result
|
|
158
|
-
if (result !== null && typeof result === 'object') {
|
|
159
|
-
const res = result as Record<string, unknown>
|
|
160
|
-
if (typeof res['valid'] === 'boolean') {
|
|
161
|
-
if (!res['valid'] && res['message']) {
|
|
162
|
-
validate.errors = [{
|
|
163
|
-
keyword: 'validate',
|
|
164
|
-
message: String(res['message']),
|
|
165
|
-
params: {},
|
|
166
|
-
}]
|
|
167
|
-
}
|
|
168
|
-
return res['valid'] as boolean
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return true
|
|
172
|
-
} catch (error) {
|
|
173
|
-
validate.errors = [{
|
|
174
|
-
keyword: 'validate',
|
|
175
|
-
message: error instanceof Error ? error.message : String(error),
|
|
176
|
-
params: {},
|
|
177
|
-
}]
|
|
178
|
-
return false
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
ajv.addKeyword({ keyword: 'validate', validate, errors: true })
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ─── String validators ───────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
static registerStringValidators(ajv: Ajv): void {
|
|
188
|
-
// exactLength — exact string length (CK-Y04 fix: Unicode code-point counting)
|
|
189
|
-
const exactLength: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
190
|
-
// CK-Y04: use spread iterator for counting — correctly handles emoji / multi-byte Unicode
|
|
191
|
-
const codePointLength = [...String(data)].length
|
|
192
|
-
if (codePointLength !== Number(schema)) {
|
|
193
|
-
exactLength.errors = [{
|
|
194
|
-
keyword: 'exactLength',
|
|
195
|
-
message: Locale.getMessageText('string.length'),
|
|
196
|
-
params: { limit: schema },
|
|
197
|
-
}]
|
|
198
|
-
return false
|
|
199
|
-
}
|
|
200
|
-
return true
|
|
201
|
-
}
|
|
202
|
-
ajv.addKeyword({ keyword: 'exactLength', type: 'string', schemaType: 'number', validate: exactLength, errors: true })
|
|
203
|
-
|
|
204
|
-
// alphanum
|
|
205
|
-
const alphanum: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
206
|
-
if (schema && !/^[a-zA-Z0-9]*$/.test(String(data))) {
|
|
207
|
-
alphanum.errors = [{ keyword: 'alphanum', message: Locale.getMessageText('string.alphanum'), params: {} }]
|
|
208
|
-
return false
|
|
209
|
-
}
|
|
210
|
-
return true
|
|
211
|
-
}
|
|
212
|
-
ajv.addKeyword({ keyword: 'alphanum', type: 'string', schemaType: 'boolean', validate: alphanum, errors: true })
|
|
213
|
-
|
|
214
|
-
// trim
|
|
215
|
-
const trim: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
216
|
-
const str = String(data)
|
|
217
|
-
if (schema && str !== str.trim()) {
|
|
218
|
-
trim.errors = [{ keyword: 'trim', message: Locale.getMessageText('string.trim'), params: {} }]
|
|
219
|
-
return false
|
|
220
|
-
}
|
|
221
|
-
return true
|
|
222
|
-
}
|
|
223
|
-
ajv.addKeyword({ keyword: 'trim', type: 'string', schemaType: 'boolean', validate: trim, errors: true })
|
|
224
|
-
|
|
225
|
-
// lowercase
|
|
226
|
-
const lowercase: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
227
|
-
const str = String(data)
|
|
228
|
-
if (schema && str !== str.toLowerCase()) {
|
|
229
|
-
lowercase.errors = [{ keyword: 'lowercase', message: Locale.getMessageText('string.lowercase'), params: {} }]
|
|
230
|
-
return false
|
|
231
|
-
}
|
|
232
|
-
return true
|
|
233
|
-
}
|
|
234
|
-
ajv.addKeyword({ keyword: 'lowercase', type: 'string', schemaType: 'boolean', validate: lowercase, errors: true })
|
|
235
|
-
|
|
236
|
-
// uppercase
|
|
237
|
-
const uppercase: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
238
|
-
const str = String(data)
|
|
239
|
-
if (schema && str !== str.toUpperCase()) {
|
|
240
|
-
uppercase.errors = [{ keyword: 'uppercase', message: Locale.getMessageText('string.uppercase'), params: {} }]
|
|
241
|
-
return false
|
|
242
|
-
}
|
|
243
|
-
return true
|
|
244
|
-
}
|
|
245
|
-
ajv.addKeyword({ keyword: 'uppercase', type: 'string', schemaType: 'boolean', validate: uppercase, errors: true })
|
|
246
|
-
|
|
247
|
-
// jsonString
|
|
248
|
-
const jsonString: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
249
|
-
if (schema) {
|
|
250
|
-
try {
|
|
251
|
-
JSON.parse(String(data))
|
|
252
|
-
} catch {
|
|
253
|
-
jsonString.errors = [{ keyword: 'jsonString', message: Locale.getMessageText('pattern.json'), params: {} }]
|
|
254
|
-
return false
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return true
|
|
258
|
-
}
|
|
259
|
-
ajv.addKeyword({ keyword: 'jsonString', type: 'string', schemaType: 'boolean', validate: jsonString, errors: true })
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ─── Number validators ───────────────────────────────────────────────────
|
|
263
|
-
|
|
264
|
-
static registerNumberValidators(ajv: Ajv): void {
|
|
265
|
-
// precision — decimal place limit
|
|
266
|
-
const precision: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
267
|
-
const n = data as number
|
|
268
|
-
const limit = Number(schema)
|
|
269
|
-
const factor = Math.pow(10, limit)
|
|
270
|
-
const shifted = n * factor
|
|
271
|
-
// Epsilon-based check handles floating-point artifacts (e.g. 0.1+0.2 = 0.30000000000000004)
|
|
272
|
-
if (Math.abs(shifted - Math.round(shifted)) > 1e-10) {
|
|
273
|
-
precision.errors = [{ keyword: 'precision', message: Locale.getMessageText('number.precision'), params: { limit: schema } }]
|
|
274
|
-
return false
|
|
275
|
-
}
|
|
276
|
-
return true
|
|
277
|
-
}
|
|
278
|
-
ajv.addKeyword({ keyword: 'precision', type: 'number', schemaType: 'number', validate: precision, errors: true })
|
|
279
|
-
|
|
280
|
-
// port — port number validation (1-65535)
|
|
281
|
-
const port: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
282
|
-
const num = data as number
|
|
283
|
-
if (schema && (!Number.isInteger(num) || num < 1 || num > 65535)) {
|
|
284
|
-
port.errors = [{ keyword: 'port', message: Locale.getMessageText('number.port'), params: {} }]
|
|
285
|
-
return false
|
|
286
|
-
}
|
|
287
|
-
return true
|
|
288
|
-
}
|
|
289
|
-
ajv.addKeyword({ keyword: 'port', type: ['integer', 'number'], schemaType: 'boolean', validate: port, errors: true })
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ─── Object validators ──────────────────────────────────────────────────
|
|
293
|
-
|
|
294
|
-
static registerObjectValidators(ajv: Ajv): void {
|
|
295
|
-
// requiredAll — require all defined properties to be present
|
|
296
|
-
const requiredAll: ValidateFnWithErrors = (schema: unknown, data: unknown, parentSchema?: unknown): boolean => {
|
|
297
|
-
if (!schema) return true
|
|
298
|
-
const props = ((parentSchema as Record<string, unknown>)?.['properties'] as Record<string, unknown>) ?? {}
|
|
299
|
-
const missingKeys = Object.keys(props).filter(k => !(k in (data as Record<string, unknown>)))
|
|
300
|
-
if (missingKeys.length > 0) {
|
|
301
|
-
requiredAll.errors = [{
|
|
302
|
-
keyword: 'requiredAll',
|
|
303
|
-
message: Locale.getMessageText('object.missing'),
|
|
304
|
-
params: { missing: missingKeys },
|
|
305
|
-
}]
|
|
306
|
-
return false
|
|
307
|
-
}
|
|
308
|
-
return true
|
|
309
|
-
}
|
|
310
|
-
ajv.addKeyword({ keyword: 'requiredAll', type: 'object', schemaType: 'boolean', validate: requiredAll, errors: true })
|
|
311
|
-
|
|
312
|
-
// strictSchema — disallow extra properties
|
|
313
|
-
const strictSchema: ValidateFnWithErrors = (schema: unknown, data: unknown, parentSchema?: unknown): boolean => {
|
|
314
|
-
if (!schema) return true
|
|
315
|
-
const props = ((parentSchema as Record<string, unknown>)?.['properties'] as Record<string, unknown>) ?? {}
|
|
316
|
-
const allowedKeys = Object.keys(props)
|
|
317
|
-
const extraKeys = Object.keys(data as Record<string, unknown>).filter(k => !allowedKeys.includes(k))
|
|
318
|
-
if (extraKeys.length > 0) {
|
|
319
|
-
strictSchema.errors = [{
|
|
320
|
-
keyword: 'strictSchema',
|
|
321
|
-
message: Locale.getMessageText('object.schema'),
|
|
322
|
-
params: { extra: extraKeys },
|
|
323
|
-
}]
|
|
324
|
-
return false
|
|
325
|
-
}
|
|
326
|
-
return true
|
|
327
|
-
}
|
|
328
|
-
ajv.addKeyword({ keyword: 'strictSchema', type: 'object', schemaType: 'boolean', validate: strictSchema, errors: true })
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// ─── Array validators ───────────────────────────────────────────────────
|
|
332
|
-
|
|
333
|
-
private static _deepEqual(a: unknown, b: unknown): boolean {
|
|
334
|
-
if (a === b) return true
|
|
335
|
-
if (a === null || b === null || typeof a !== typeof b) return false
|
|
336
|
-
if (typeof a !== 'object') return false
|
|
337
|
-
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
338
|
-
if (Array.isArray(a)) {
|
|
339
|
-
if ((a as unknown[]).length !== (b as unknown[]).length) return false
|
|
340
|
-
return (a as unknown[]).every((item, i) => CustomKeywords._deepEqual(item, (b as unknown[])[i]))
|
|
341
|
-
}
|
|
342
|
-
const aKeys = Object.keys(a as object).sort()
|
|
343
|
-
const bKeys = Object.keys(b as object).sort()
|
|
344
|
-
if (aKeys.length !== bKeys.length) return false
|
|
345
|
-
return aKeys.every(k =>
|
|
346
|
-
CustomKeywords._deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
|
|
347
|
-
)
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
static registerArrayValidators(ajv: Ajv): void {
|
|
351
|
-
// noSparse — disallow sparse arrays
|
|
352
|
-
const noSparse: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
353
|
-
const arr = data as unknown[]
|
|
354
|
-
if (schema) {
|
|
355
|
-
for (let i = 0; i < arr.length; i++) {
|
|
356
|
-
if (!(i in arr)) {
|
|
357
|
-
noSparse.errors = [{
|
|
358
|
-
keyword: 'noSparse',
|
|
359
|
-
message: Locale.getMessageText('array.sparse'),
|
|
360
|
-
params: { index: i },
|
|
361
|
-
}]
|
|
362
|
-
return false
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
return true
|
|
367
|
-
}
|
|
368
|
-
ajv.addKeyword({ keyword: 'noSparse', type: 'array', schemaType: 'boolean', validate: noSparse, errors: true })
|
|
369
|
-
|
|
370
|
-
// includesRequired — must include specified elements
|
|
371
|
-
const includesRequired: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
372
|
-
if (!Array.isArray(schema) || schema.length === 0) return true
|
|
373
|
-
const arr = data as unknown[]
|
|
374
|
-
const missing = (schema as unknown[]).filter(required => {
|
|
375
|
-
return !arr.some(item => {
|
|
376
|
-
if (typeof required === 'object' && required !== null) {
|
|
377
|
-
return CustomKeywords._deepEqual(item, required)
|
|
378
|
-
}
|
|
379
|
-
return item === required
|
|
380
|
-
})
|
|
381
|
-
})
|
|
382
|
-
if (missing.length > 0) {
|
|
383
|
-
includesRequired.errors = [{
|
|
384
|
-
keyword: 'includesRequired',
|
|
385
|
-
message: Locale.getMessageText('array.includesRequired'),
|
|
386
|
-
params: { missing },
|
|
387
|
-
}]
|
|
388
|
-
return false
|
|
389
|
-
}
|
|
390
|
-
return true
|
|
391
|
-
}
|
|
392
|
-
ajv.addKeyword({ keyword: 'includesRequired', type: 'array', schemaType: 'array', validate: includesRequired, errors: true })
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// ─── Date validators ────────────────────────────────────────────────────
|
|
396
|
-
|
|
397
|
-
static registerDateValidators(ajv: Ajv): void {
|
|
398
|
-
const DATE_FORMATS: Record<string, RegExp> = {
|
|
399
|
-
'YYYY-MM-DD': /^\d{4}-\d{2}-\d{2}$/,
|
|
400
|
-
'YYYY/MM/DD': /^\d{4}\/\d{2}\/\d{2}$/,
|
|
401
|
-
'DD-MM-YYYY': /^\d{2}-\d{2}-\d{4}$/,
|
|
402
|
-
'DD/MM/YYYY': /^\d{2}\/\d{2}\/\d{4}$/,
|
|
403
|
-
'ISO8601': /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/,
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// dateFormat
|
|
407
|
-
const dateFormat: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
408
|
-
const fmt = String(schema)
|
|
409
|
-
const pattern = DATE_FORMATS[fmt]
|
|
410
|
-
const str = String(data)
|
|
411
|
-
if (!pattern || !pattern.test(str)) {
|
|
412
|
-
dateFormat.errors = [{
|
|
413
|
-
keyword: 'dateFormat',
|
|
414
|
-
message: Locale.getMessageText('date.format'),
|
|
415
|
-
params: { format: schema },
|
|
416
|
-
}]
|
|
417
|
-
return false
|
|
418
|
-
}
|
|
419
|
-
// Calendar validity: extract components based on format and verify via Date
|
|
420
|
-
const sep = /[-/]/.exec(str)?.[0] ?? '-'
|
|
421
|
-
const parts = str.split(sep)
|
|
422
|
-
let y: number, m: number, dd: number
|
|
423
|
-
if (fmt === 'DD-MM-YYYY' || fmt === 'DD/MM/YYYY') {
|
|
424
|
-
[dd, m, y] = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
|
|
425
|
-
} else if (fmt === 'ISO8601') {
|
|
426
|
-
const d2 = new Date(str)
|
|
427
|
-
if (isNaN(d2.getTime())) {
|
|
428
|
-
dateFormat.errors = [{ keyword: 'dateFormat', message: Locale.getMessageText('date.format'), params: { format: schema } }]
|
|
429
|
-
return false
|
|
430
|
-
}
|
|
431
|
-
return true
|
|
432
|
-
} else {
|
|
433
|
-
[y, m, dd] = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
|
|
434
|
-
}
|
|
435
|
-
// Verify the date exists (e.g., reject 2024-13-99, 2024-02-31)
|
|
436
|
-
const probe = new Date(y, m - 1, dd)
|
|
437
|
-
if (probe.getFullYear() !== y || probe.getMonth() !== m - 1 || probe.getDate() !== dd) {
|
|
438
|
-
dateFormat.errors = [{ keyword: 'dateFormat', message: Locale.getMessageText('date.format'), params: { format: schema } }]
|
|
439
|
-
return false
|
|
440
|
-
}
|
|
441
|
-
return true
|
|
442
|
-
}
|
|
443
|
-
ajv.addKeyword({ keyword: 'dateFormat', type: 'string', schemaType: 'string', validate: dateFormat, errors: true })
|
|
444
|
-
|
|
445
|
-
// dateGreater
|
|
446
|
-
const dateGreater: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
447
|
-
const dataDate = new Date(String(data))
|
|
448
|
-
const compareDate = new Date(String(schema))
|
|
449
|
-
if (isNaN(dataDate.getTime()) || isNaN(compareDate.getTime()) || dataDate <= compareDate) {
|
|
450
|
-
dateGreater.errors = [{
|
|
451
|
-
keyword: 'dateGreater',
|
|
452
|
-
message: Locale.getMessageText('date.greater'),
|
|
453
|
-
params: { limit: schema },
|
|
454
|
-
}]
|
|
455
|
-
return false
|
|
456
|
-
}
|
|
457
|
-
return true
|
|
458
|
-
}
|
|
459
|
-
ajv.addKeyword({ keyword: 'dateGreater', type: 'string', schemaType: 'string', validate: dateGreater, errors: true })
|
|
460
|
-
|
|
461
|
-
// dateLess
|
|
462
|
-
const dateLess: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
463
|
-
const dataDate = new Date(String(data))
|
|
464
|
-
const compareDate = new Date(String(schema))
|
|
465
|
-
if (isNaN(dataDate.getTime()) || isNaN(compareDate.getTime()) || dataDate >= compareDate) {
|
|
466
|
-
dateLess.errors = [{
|
|
467
|
-
keyword: 'dateLess',
|
|
468
|
-
message: Locale.getMessageText('date.less'),
|
|
469
|
-
params: { limit: schema },
|
|
470
|
-
}]
|
|
471
|
-
return false
|
|
472
|
-
}
|
|
473
|
-
return true
|
|
474
|
-
}
|
|
475
|
-
ajv.addKeyword({ keyword: 'dateLess', type: 'string', schemaType: 'string', validate: dateLess, errors: true })
|
|
476
|
-
}
|
|
477
|
-
}
|
|
1
|
+
import type { Ajv, ErrorObject } from 'ajv'
|
|
2
|
+
import safeRegex from 'safe-regex'
|
|
3
|
+
import { Locale } from '../core/Locale.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// AJV DataValidateFunction compatible type
|
|
7
|
+
type ValidateFnWithErrors = ((schema: unknown, data: unknown, parentSchema?: unknown) => boolean) & {
|
|
8
|
+
errors?: Partial<ErrorObject>[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* CustomKeywords — AJV custom keyword registrar
|
|
13
|
+
*
|
|
14
|
+
* Fixes:
|
|
15
|
+
* CK-01: internally uses getMessageText() to obtain strings, avoiding v1 compat objects
|
|
16
|
+
* that serialized as "[object Object]"
|
|
17
|
+
* CK-02: regex keyword error messages use locale keys instead of concatenating raw messages
|
|
18
|
+
* CK-Y04: exactLength uses Unicode code-point counting ([...str].length) instead of
|
|
19
|
+
* str.length, correctly handling emoji and multi-byte characters
|
|
20
|
+
*/
|
|
21
|
+
export class CustomKeywords {
|
|
22
|
+
/**
|
|
23
|
+
* Register all custom keywords on an AJV instance
|
|
24
|
+
*/
|
|
25
|
+
static registerAll(ajv: Ajv): void {
|
|
26
|
+
CustomKeywords.registerRegexKeyword(ajv)
|
|
27
|
+
CustomKeywords.registerFunctionKeyword(ajv)
|
|
28
|
+
CustomKeywords.registerCustomValidatorsKeyword(ajv)
|
|
29
|
+
CustomKeywords.registerMetadataKeywords(ajv)
|
|
30
|
+
CustomKeywords.registerStringValidators(ajv)
|
|
31
|
+
CustomKeywords.registerNumberValidators(ajv)
|
|
32
|
+
CustomKeywords.registerObjectValidators(ajv)
|
|
33
|
+
CustomKeywords.registerArrayValidators(ajv)
|
|
34
|
+
CustomKeywords.registerDateValidators(ajv)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Metadata keywords ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
static registerMetadataKeywords(ajv: Ajv): void {
|
|
40
|
+
ajv.addKeyword({ keyword: '_label', metaSchema: { type: 'string' } })
|
|
41
|
+
ajv.addKeyword({ keyword: '_customMessages', metaSchema: { type: 'object' } })
|
|
42
|
+
ajv.addKeyword({ keyword: '_description', metaSchema: { type: 'string' } })
|
|
43
|
+
ajv.addKeyword({ keyword: '_whenConditions', metaSchema: { type: 'array' } })
|
|
44
|
+
ajv.addKeyword({ keyword: '_required', metaSchema: { type: 'boolean' } })
|
|
45
|
+
// Conditional schema marker: prevents AJV strict mode from throwing an unknown-keyword error
|
|
46
|
+
ajv.addKeyword({ keyword: '_isConditional', metaSchema: { type: 'boolean' } })
|
|
47
|
+
ajv.addKeyword({ keyword: '_runtimeOnlyConditional', metaSchema: { type: 'boolean' } })
|
|
48
|
+
ajv.addKeyword({ keyword: 'conditions' })
|
|
49
|
+
ajv.addKeyword({ keyword: '_evaluateCondition' })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── _customValidators ──────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
static registerCustomValidatorsKeyword(ajv: Ajv): void {
|
|
55
|
+
const validate: ValidateFnWithErrors = (validators: unknown, data: unknown): boolean => {
|
|
56
|
+
if (!Array.isArray(validators)) return true
|
|
57
|
+
|
|
58
|
+
for (const validator of validators as unknown[]) {
|
|
59
|
+
if (typeof validator !== 'function') continue
|
|
60
|
+
try {
|
|
61
|
+
const result = (validator as (d: unknown) => unknown)(data)
|
|
62
|
+
|
|
63
|
+
if (result instanceof Promise) {
|
|
64
|
+
// BC-6: async validators are not supported in the synchronous AJV validate() path.
|
|
65
|
+
// Return an explicit error so callers know to use validateAsync() instead.
|
|
66
|
+
validate.errors = [{
|
|
67
|
+
keyword: '_customValidators',
|
|
68
|
+
message: 'Async validation not supported in sync validate(). Use validateAsync() instead.',
|
|
69
|
+
params: {},
|
|
70
|
+
}]
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (result === false) {
|
|
75
|
+
const msg = Locale.getMessageText('CUSTOM_VALIDATION_FAILED')
|
|
76
|
+
validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
if (typeof result === 'string') {
|
|
80
|
+
validate.errors = [{ keyword: '_customValidators', message: result, params: {} }]
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
if (result !== null && typeof result === 'object' && (result as Record<string, unknown>)['error']) {
|
|
84
|
+
const msg = String((result as Record<string, unknown>)['message'] ?? Locale.getMessageText('CUSTOM_VALIDATION_FAILED'))
|
|
85
|
+
validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
90
|
+
validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
ajv.addKeyword({ keyword: '_customValidators', validate, errors: true })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── regex ──────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
// Detect potentially catastrophic patterns via a dedicated regex safety analyzer
|
|
103
|
+
private static _isUnsafePattern(pattern: string | RegExp): boolean {
|
|
104
|
+
return !safeRegex(pattern)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static registerRegexKeyword(ajv: Ajv): void {
|
|
108
|
+
const validate: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
109
|
+
const patternStr = String(schema)
|
|
110
|
+
try {
|
|
111
|
+
const regex = new RegExp(patternStr)
|
|
112
|
+
if (CustomKeywords._isUnsafePattern(regex)) {
|
|
113
|
+
validate.errors = [{
|
|
114
|
+
keyword: 'regex',
|
|
115
|
+
message: Locale.getMessageText('string.pattern'),
|
|
116
|
+
params: { pattern: patternStr, reason: 'unsafe regex pattern' },
|
|
117
|
+
}]
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
if (regex.test(String(data))) return true
|
|
121
|
+
// CK-02 fix: use locale key instead of concatenating raw error message
|
|
122
|
+
validate.errors = [{
|
|
123
|
+
keyword: 'regex',
|
|
124
|
+
message: Locale.getMessageText('string.pattern'),
|
|
125
|
+
params: { pattern: schema },
|
|
126
|
+
}]
|
|
127
|
+
return false
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// CK-02 fix: invalid regex also uses locale key
|
|
130
|
+
validate.errors = [{
|
|
131
|
+
keyword: 'regex',
|
|
132
|
+
message: Locale.getMessageText('string.pattern'),
|
|
133
|
+
params: { error: error instanceof Error ? error.message : String(error) },
|
|
134
|
+
}]
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ajv.addKeyword({ keyword: 'regex', type: 'string', schemaType: 'string', validate, errors: true })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── validate (function validator) ──────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
static registerFunctionKeyword(ajv: Ajv): void {
|
|
145
|
+
const validate: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
146
|
+
if (typeof schema !== 'function') {
|
|
147
|
+
validate.errors = [{
|
|
148
|
+
keyword: 'validate',
|
|
149
|
+
message: Locale.getMessageText('VALIDATE_MUST_BE_FUNCTION'),
|
|
150
|
+
params: {},
|
|
151
|
+
}]
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const result = (schema as (d: unknown) => unknown)(data)
|
|
157
|
+
if (typeof result === 'boolean') return result
|
|
158
|
+
if (result !== null && typeof result === 'object') {
|
|
159
|
+
const res = result as Record<string, unknown>
|
|
160
|
+
if (typeof res['valid'] === 'boolean') {
|
|
161
|
+
if (!res['valid'] && res['message']) {
|
|
162
|
+
validate.errors = [{
|
|
163
|
+
keyword: 'validate',
|
|
164
|
+
message: String(res['message']),
|
|
165
|
+
params: {},
|
|
166
|
+
}]
|
|
167
|
+
}
|
|
168
|
+
return res['valid'] as boolean
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return true
|
|
172
|
+
} catch (error) {
|
|
173
|
+
validate.errors = [{
|
|
174
|
+
keyword: 'validate',
|
|
175
|
+
message: error instanceof Error ? error.message : String(error),
|
|
176
|
+
params: {},
|
|
177
|
+
}]
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ajv.addKeyword({ keyword: 'validate', validate, errors: true })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── String validators ───────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
static registerStringValidators(ajv: Ajv): void {
|
|
188
|
+
// exactLength — exact string length (CK-Y04 fix: Unicode code-point counting)
|
|
189
|
+
const exactLength: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
190
|
+
// CK-Y04: use spread iterator for counting — correctly handles emoji / multi-byte Unicode
|
|
191
|
+
const codePointLength = [...String(data)].length
|
|
192
|
+
if (codePointLength !== Number(schema)) {
|
|
193
|
+
exactLength.errors = [{
|
|
194
|
+
keyword: 'exactLength',
|
|
195
|
+
message: Locale.getMessageText('string.length'),
|
|
196
|
+
params: { limit: schema },
|
|
197
|
+
}]
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
ajv.addKeyword({ keyword: 'exactLength', type: 'string', schemaType: 'number', validate: exactLength, errors: true })
|
|
203
|
+
|
|
204
|
+
// alphanum
|
|
205
|
+
const alphanum: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
206
|
+
if (schema && !/^[a-zA-Z0-9]*$/.test(String(data))) {
|
|
207
|
+
alphanum.errors = [{ keyword: 'alphanum', message: Locale.getMessageText('string.alphanum'), params: {} }]
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
ajv.addKeyword({ keyword: 'alphanum', type: 'string', schemaType: 'boolean', validate: alphanum, errors: true })
|
|
213
|
+
|
|
214
|
+
// trim
|
|
215
|
+
const trim: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
216
|
+
const str = String(data)
|
|
217
|
+
if (schema && str !== str.trim()) {
|
|
218
|
+
trim.errors = [{ keyword: 'trim', message: Locale.getMessageText('string.trim'), params: {} }]
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
ajv.addKeyword({ keyword: 'trim', type: 'string', schemaType: 'boolean', validate: trim, errors: true })
|
|
224
|
+
|
|
225
|
+
// lowercase
|
|
226
|
+
const lowercase: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
227
|
+
const str = String(data)
|
|
228
|
+
if (schema && str !== str.toLowerCase()) {
|
|
229
|
+
lowercase.errors = [{ keyword: 'lowercase', message: Locale.getMessageText('string.lowercase'), params: {} }]
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
return true
|
|
233
|
+
}
|
|
234
|
+
ajv.addKeyword({ keyword: 'lowercase', type: 'string', schemaType: 'boolean', validate: lowercase, errors: true })
|
|
235
|
+
|
|
236
|
+
// uppercase
|
|
237
|
+
const uppercase: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
238
|
+
const str = String(data)
|
|
239
|
+
if (schema && str !== str.toUpperCase()) {
|
|
240
|
+
uppercase.errors = [{ keyword: 'uppercase', message: Locale.getMessageText('string.uppercase'), params: {} }]
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
245
|
+
ajv.addKeyword({ keyword: 'uppercase', type: 'string', schemaType: 'boolean', validate: uppercase, errors: true })
|
|
246
|
+
|
|
247
|
+
// jsonString
|
|
248
|
+
const jsonString: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
249
|
+
if (schema) {
|
|
250
|
+
try {
|
|
251
|
+
JSON.parse(String(data))
|
|
252
|
+
} catch {
|
|
253
|
+
jsonString.errors = [{ keyword: 'jsonString', message: Locale.getMessageText('pattern.json'), params: {} }]
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
ajv.addKeyword({ keyword: 'jsonString', type: 'string', schemaType: 'boolean', validate: jsonString, errors: true })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Number validators ───────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
static registerNumberValidators(ajv: Ajv): void {
|
|
265
|
+
// precision — decimal place limit
|
|
266
|
+
const precision: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
267
|
+
const n = data as number
|
|
268
|
+
const limit = Number(schema)
|
|
269
|
+
const factor = Math.pow(10, limit)
|
|
270
|
+
const shifted = n * factor
|
|
271
|
+
// Epsilon-based check handles floating-point artifacts (e.g. 0.1+0.2 = 0.30000000000000004)
|
|
272
|
+
if (Math.abs(shifted - Math.round(shifted)) > 1e-10) {
|
|
273
|
+
precision.errors = [{ keyword: 'precision', message: Locale.getMessageText('number.precision'), params: { limit: schema } }]
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
ajv.addKeyword({ keyword: 'precision', type: 'number', schemaType: 'number', validate: precision, errors: true })
|
|
279
|
+
|
|
280
|
+
// port — port number validation (1-65535)
|
|
281
|
+
const port: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
282
|
+
const num = data as number
|
|
283
|
+
if (schema && (!Number.isInteger(num) || num < 1 || num > 65535)) {
|
|
284
|
+
port.errors = [{ keyword: 'port', message: Locale.getMessageText('number.port'), params: {} }]
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
return true
|
|
288
|
+
}
|
|
289
|
+
ajv.addKeyword({ keyword: 'port', type: ['integer', 'number'], schemaType: 'boolean', validate: port, errors: true })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── Object validators ──────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
static registerObjectValidators(ajv: Ajv): void {
|
|
295
|
+
// requiredAll — require all defined properties to be present
|
|
296
|
+
const requiredAll: ValidateFnWithErrors = (schema: unknown, data: unknown, parentSchema?: unknown): boolean => {
|
|
297
|
+
if (!schema) return true
|
|
298
|
+
const props = ((parentSchema as Record<string, unknown>)?.['properties'] as Record<string, unknown>) ?? {}
|
|
299
|
+
const missingKeys = Object.keys(props).filter(k => !(k in (data as Record<string, unknown>)))
|
|
300
|
+
if (missingKeys.length > 0) {
|
|
301
|
+
requiredAll.errors = [{
|
|
302
|
+
keyword: 'requiredAll',
|
|
303
|
+
message: Locale.getMessageText('object.missing'),
|
|
304
|
+
params: { missing: missingKeys },
|
|
305
|
+
}]
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
return true
|
|
309
|
+
}
|
|
310
|
+
ajv.addKeyword({ keyword: 'requiredAll', type: 'object', schemaType: 'boolean', validate: requiredAll, errors: true })
|
|
311
|
+
|
|
312
|
+
// strictSchema — disallow extra properties
|
|
313
|
+
const strictSchema: ValidateFnWithErrors = (schema: unknown, data: unknown, parentSchema?: unknown): boolean => {
|
|
314
|
+
if (!schema) return true
|
|
315
|
+
const props = ((parentSchema as Record<string, unknown>)?.['properties'] as Record<string, unknown>) ?? {}
|
|
316
|
+
const allowedKeys = Object.keys(props)
|
|
317
|
+
const extraKeys = Object.keys(data as Record<string, unknown>).filter(k => !allowedKeys.includes(k))
|
|
318
|
+
if (extraKeys.length > 0) {
|
|
319
|
+
strictSchema.errors = [{
|
|
320
|
+
keyword: 'strictSchema',
|
|
321
|
+
message: Locale.getMessageText('object.schema'),
|
|
322
|
+
params: { extra: extraKeys },
|
|
323
|
+
}]
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
return true
|
|
327
|
+
}
|
|
328
|
+
ajv.addKeyword({ keyword: 'strictSchema', type: 'object', schemaType: 'boolean', validate: strictSchema, errors: true })
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── Array validators ───────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
private static _deepEqual(a: unknown, b: unknown): boolean {
|
|
334
|
+
if (a === b) return true
|
|
335
|
+
if (a === null || b === null || typeof a !== typeof b) return false
|
|
336
|
+
if (typeof a !== 'object') return false
|
|
337
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
338
|
+
if (Array.isArray(a)) {
|
|
339
|
+
if ((a as unknown[]).length !== (b as unknown[]).length) return false
|
|
340
|
+
return (a as unknown[]).every((item, i) => CustomKeywords._deepEqual(item, (b as unknown[])[i]))
|
|
341
|
+
}
|
|
342
|
+
const aKeys = Object.keys(a as object).sort()
|
|
343
|
+
const bKeys = Object.keys(b as object).sort()
|
|
344
|
+
if (aKeys.length !== bKeys.length) return false
|
|
345
|
+
return aKeys.every(k =>
|
|
346
|
+
CustomKeywords._deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
static registerArrayValidators(ajv: Ajv): void {
|
|
351
|
+
// noSparse — disallow sparse arrays
|
|
352
|
+
const noSparse: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
353
|
+
const arr = data as unknown[]
|
|
354
|
+
if (schema) {
|
|
355
|
+
for (let i = 0; i < arr.length; i++) {
|
|
356
|
+
if (!(i in arr)) {
|
|
357
|
+
noSparse.errors = [{
|
|
358
|
+
keyword: 'noSparse',
|
|
359
|
+
message: Locale.getMessageText('array.sparse'),
|
|
360
|
+
params: { index: i },
|
|
361
|
+
}]
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return true
|
|
367
|
+
}
|
|
368
|
+
ajv.addKeyword({ keyword: 'noSparse', type: 'array', schemaType: 'boolean', validate: noSparse, errors: true })
|
|
369
|
+
|
|
370
|
+
// includesRequired — must include specified elements
|
|
371
|
+
const includesRequired: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
372
|
+
if (!Array.isArray(schema) || schema.length === 0) return true
|
|
373
|
+
const arr = data as unknown[]
|
|
374
|
+
const missing = (schema as unknown[]).filter(required => {
|
|
375
|
+
return !arr.some(item => {
|
|
376
|
+
if (typeof required === 'object' && required !== null) {
|
|
377
|
+
return CustomKeywords._deepEqual(item, required)
|
|
378
|
+
}
|
|
379
|
+
return item === required
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
if (missing.length > 0) {
|
|
383
|
+
includesRequired.errors = [{
|
|
384
|
+
keyword: 'includesRequired',
|
|
385
|
+
message: Locale.getMessageText('array.includesRequired'),
|
|
386
|
+
params: { missing },
|
|
387
|
+
}]
|
|
388
|
+
return false
|
|
389
|
+
}
|
|
390
|
+
return true
|
|
391
|
+
}
|
|
392
|
+
ajv.addKeyword({ keyword: 'includesRequired', type: 'array', schemaType: 'array', validate: includesRequired, errors: true })
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── Date validators ────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
static registerDateValidators(ajv: Ajv): void {
|
|
398
|
+
const DATE_FORMATS: Record<string, RegExp> = {
|
|
399
|
+
'YYYY-MM-DD': /^\d{4}-\d{2}-\d{2}$/,
|
|
400
|
+
'YYYY/MM/DD': /^\d{4}\/\d{2}\/\d{2}$/,
|
|
401
|
+
'DD-MM-YYYY': /^\d{2}-\d{2}-\d{4}$/,
|
|
402
|
+
'DD/MM/YYYY': /^\d{2}\/\d{2}\/\d{4}$/,
|
|
403
|
+
'ISO8601': /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// dateFormat
|
|
407
|
+
const dateFormat: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
408
|
+
const fmt = String(schema)
|
|
409
|
+
const pattern = DATE_FORMATS[fmt]
|
|
410
|
+
const str = String(data)
|
|
411
|
+
if (!pattern || !pattern.test(str)) {
|
|
412
|
+
dateFormat.errors = [{
|
|
413
|
+
keyword: 'dateFormat',
|
|
414
|
+
message: Locale.getMessageText('date.format'),
|
|
415
|
+
params: { format: schema },
|
|
416
|
+
}]
|
|
417
|
+
return false
|
|
418
|
+
}
|
|
419
|
+
// Calendar validity: extract components based on format and verify via Date
|
|
420
|
+
const sep = /[-/]/.exec(str)?.[0] ?? '-'
|
|
421
|
+
const parts = str.split(sep)
|
|
422
|
+
let y: number, m: number, dd: number
|
|
423
|
+
if (fmt === 'DD-MM-YYYY' || fmt === 'DD/MM/YYYY') {
|
|
424
|
+
[dd, m, y] = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
|
|
425
|
+
} else if (fmt === 'ISO8601') {
|
|
426
|
+
const d2 = new Date(str)
|
|
427
|
+
if (isNaN(d2.getTime())) {
|
|
428
|
+
dateFormat.errors = [{ keyword: 'dateFormat', message: Locale.getMessageText('date.format'), params: { format: schema } }]
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
431
|
+
return true
|
|
432
|
+
} else {
|
|
433
|
+
[y, m, dd] = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
|
|
434
|
+
}
|
|
435
|
+
// Verify the date exists (e.g., reject 2024-13-99, 2024-02-31)
|
|
436
|
+
const probe = new Date(y, m - 1, dd)
|
|
437
|
+
if (probe.getFullYear() !== y || probe.getMonth() !== m - 1 || probe.getDate() !== dd) {
|
|
438
|
+
dateFormat.errors = [{ keyword: 'dateFormat', message: Locale.getMessageText('date.format'), params: { format: schema } }]
|
|
439
|
+
return false
|
|
440
|
+
}
|
|
441
|
+
return true
|
|
442
|
+
}
|
|
443
|
+
ajv.addKeyword({ keyword: 'dateFormat', type: 'string', schemaType: 'string', validate: dateFormat, errors: true })
|
|
444
|
+
|
|
445
|
+
// dateGreater
|
|
446
|
+
const dateGreater: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
447
|
+
const dataDate = new Date(String(data))
|
|
448
|
+
const compareDate = new Date(String(schema))
|
|
449
|
+
if (isNaN(dataDate.getTime()) || isNaN(compareDate.getTime()) || dataDate <= compareDate) {
|
|
450
|
+
dateGreater.errors = [{
|
|
451
|
+
keyword: 'dateGreater',
|
|
452
|
+
message: Locale.getMessageText('date.greater'),
|
|
453
|
+
params: { limit: schema },
|
|
454
|
+
}]
|
|
455
|
+
return false
|
|
456
|
+
}
|
|
457
|
+
return true
|
|
458
|
+
}
|
|
459
|
+
ajv.addKeyword({ keyword: 'dateGreater', type: 'string', schemaType: 'string', validate: dateGreater, errors: true })
|
|
460
|
+
|
|
461
|
+
// dateLess
|
|
462
|
+
const dateLess: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
|
|
463
|
+
const dataDate = new Date(String(data))
|
|
464
|
+
const compareDate = new Date(String(schema))
|
|
465
|
+
if (isNaN(dataDate.getTime()) || isNaN(compareDate.getTime()) || dataDate >= compareDate) {
|
|
466
|
+
dateLess.errors = [{
|
|
467
|
+
keyword: 'dateLess',
|
|
468
|
+
message: Locale.getMessageText('date.less'),
|
|
469
|
+
params: { limit: schema },
|
|
470
|
+
}]
|
|
471
|
+
return false
|
|
472
|
+
}
|
|
473
|
+
return true
|
|
474
|
+
}
|
|
475
|
+
ajv.addKeyword({ keyword: 'dateLess', type: 'string', schemaType: 'string', validate: dateLess, errors: true })
|
|
476
|
+
}
|
|
477
|
+
}
|