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
package/src/core/ErrorCodes.ts
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import type { ErrorCodeMap } from '../types/error.js'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Error code constants.
|
|
5
|
-
* Defines all built-in error codes used by ErrorFormatter and Locale.
|
|
6
|
-
*/
|
|
7
|
-
export const ErrorCodes: ErrorCodeMap = {
|
|
8
|
-
// Validation errors
|
|
9
|
-
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
10
|
-
INVALID_SCHEMA: 'INVALID_SCHEMA',
|
|
11
|
-
// Configuration errors
|
|
12
|
-
INVALID_CONFIG: 'INVALID_CONFIG',
|
|
13
|
-
INVALID_LOCALE: 'INVALID_LOCALE',
|
|
14
|
-
// Plugin errors
|
|
15
|
-
PLUGIN_INSTALL_ERROR: 'PLUGIN_INSTALL_ERROR',
|
|
16
|
-
PLUGIN_NOT_FOUND: 'PLUGIN_NOT_FOUND',
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Error type → short code mapping (maps AJV keywords to schema-dsl shorthand).
|
|
21
|
-
*/
|
|
22
|
-
export const KEYWORD_MAP: Record<string, string> = {
|
|
23
|
-
minLength: 'min',
|
|
24
|
-
maxLength: 'max',
|
|
25
|
-
minimum: 'min',
|
|
26
|
-
maximum: 'max',
|
|
27
|
-
minItems: 'min',
|
|
28
|
-
maxItems: 'max',
|
|
29
|
-
exclusiveMinimum: 'min',
|
|
30
|
-
exclusiveMaximum: 'max',
|
|
31
|
-
pattern: 'pattern',
|
|
32
|
-
format: 'format',
|
|
33
|
-
required: 'required',
|
|
34
|
-
enum: 'enum',
|
|
35
|
-
type: 'type',
|
|
36
|
-
uniqueItems: 'uniqueItems',
|
|
37
|
-
additionalProperties: 'additionalProperties',
|
|
38
|
-
}
|
|
1
|
+
import type { ErrorCodeMap } from '../types/error.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error code constants.
|
|
5
|
+
* Defines all built-in error codes used by ErrorFormatter and Locale.
|
|
6
|
+
*/
|
|
7
|
+
export const ErrorCodes: ErrorCodeMap = {
|
|
8
|
+
// Validation errors
|
|
9
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
10
|
+
INVALID_SCHEMA: 'INVALID_SCHEMA',
|
|
11
|
+
// Configuration errors
|
|
12
|
+
INVALID_CONFIG: 'INVALID_CONFIG',
|
|
13
|
+
INVALID_LOCALE: 'INVALID_LOCALE',
|
|
14
|
+
// Plugin errors
|
|
15
|
+
PLUGIN_INSTALL_ERROR: 'PLUGIN_INSTALL_ERROR',
|
|
16
|
+
PLUGIN_NOT_FOUND: 'PLUGIN_NOT_FOUND',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Error type → short code mapping (maps AJV keywords to schema-dsl shorthand).
|
|
21
|
+
*/
|
|
22
|
+
export const KEYWORD_MAP: Record<string, string> = {
|
|
23
|
+
minLength: 'min',
|
|
24
|
+
maxLength: 'max',
|
|
25
|
+
minimum: 'min',
|
|
26
|
+
maximum: 'max',
|
|
27
|
+
minItems: 'min',
|
|
28
|
+
maxItems: 'max',
|
|
29
|
+
exclusiveMinimum: 'min',
|
|
30
|
+
exclusiveMaximum: 'max',
|
|
31
|
+
pattern: 'pattern',
|
|
32
|
+
format: 'format',
|
|
33
|
+
required: 'required',
|
|
34
|
+
enum: 'enum',
|
|
35
|
+
type: 'type',
|
|
36
|
+
uniqueItems: 'uniqueItems',
|
|
37
|
+
additionalProperties: 'additionalProperties',
|
|
38
|
+
}
|
|
@@ -1,271 +1,271 @@
|
|
|
1
|
-
import type { ValidationErrorItem } from '../types/validate.js'
|
|
2
|
-
import type { ErrorMessages } from '../types/error.js'
|
|
3
|
-
import { renderTemplate } from './TemplateEngine.js'
|
|
4
|
-
import { KEYWORD_MAP } from './ErrorCodes.js'
|
|
5
|
-
import { getMessages } from '../locales/index.js'
|
|
6
|
-
import type { LocaleMessage } from '../locales/types.js'
|
|
7
|
-
import { DEFAULT_LOCALE } from './Locale.js'
|
|
8
|
-
|
|
9
|
-
type AjvRawError = {
|
|
10
|
-
keyword: string
|
|
11
|
-
instancePath: string
|
|
12
|
-
schemaPath?: string
|
|
13
|
-
params: Record<string, unknown>
|
|
14
|
-
message?: string
|
|
15
|
-
data?: unknown
|
|
16
|
-
parentSchema?: Record<string, unknown>
|
|
17
|
-
schema?: unknown
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Error formatter.
|
|
22
|
-
* Delegates template interpolation to TemplateEngine.renderTemplate() (fix CORE-03).
|
|
23
|
-
* Maintains full v1 API compatibility.
|
|
24
|
-
*/
|
|
25
|
-
export class ErrorFormatter {
|
|
26
|
-
private messages: ErrorMessages
|
|
27
|
-
private _locale: string
|
|
28
|
-
private readonly _constructorCustomMessages: ErrorMessages
|
|
29
|
-
|
|
30
|
-
constructor(locale = DEFAULT_LOCALE, messages: ErrorMessages | Record<string, LocaleMessage | string | undefined> = {}) {
|
|
31
|
-
this._locale = locale
|
|
32
|
-
// Load locale messages as defaults; constructor-level custom messages override them
|
|
33
|
-
const rawLocaleMessages = getMessages(locale)
|
|
34
|
-
const localeMessages: ErrorMessages = Object.fromEntries(
|
|
35
|
-
Object.entries(rawLocaleMessages).map(([k, v]: [string, LocaleMessage]) => [
|
|
36
|
-
k,
|
|
37
|
-
typeof v === 'string' ? v : (v as { message: string }).message,
|
|
38
|
-
])
|
|
39
|
-
)
|
|
40
|
-
// Normalise caller-supplied messages: LocaleMessage objects → plain string
|
|
41
|
-
const normMessages: ErrorMessages = Object.fromEntries(
|
|
42
|
-
Object.entries(messages).map(([k, v]) => [
|
|
43
|
-
k,
|
|
44
|
-
v == null ? undefined : typeof v === 'string' ? v : (v as { message: string }).message,
|
|
45
|
-
])
|
|
46
|
-
)
|
|
47
|
-
this._constructorCustomMessages = normMessages
|
|
48
|
-
this.messages = { ...localeMessages, ...normMessages }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
get locale(): string {
|
|
52
|
-
return this._locale
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Format a single error object → message string (v1 API).
|
|
57
|
-
*/
|
|
58
|
-
format(error: AjvRawError | Record<string, unknown>, locale?: string): string {
|
|
59
|
-
// If locale differs, reload messages for that locale
|
|
60
|
-
let msgs = this.messages
|
|
61
|
-
if (locale && locale !== this._locale) {
|
|
62
|
-
const rawLocaleMessages = getMessages(locale)
|
|
63
|
-
const localeMessages: ErrorMessages = Object.fromEntries(
|
|
64
|
-
Object.entries(rawLocaleMessages).map(([k, v]: [string, LocaleMessage]) => [
|
|
65
|
-
k,
|
|
66
|
-
typeof v === 'string' ? v : (v as { message: string }).message,
|
|
67
|
-
])
|
|
68
|
-
)
|
|
69
|
-
// Preserve constructor-level custom messages across locale switches (R-01 fix)
|
|
70
|
-
msgs = { ...localeMessages, ...this._constructorCustomMessages }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Convert simple { type, path } format to AJV-like error
|
|
74
|
-
const raw = error as Record<string, unknown>
|
|
75
|
-
const ajvError = {
|
|
76
|
-
keyword: (raw['keyword'] as string) ?? (raw['type'] as string) ?? 'validation',
|
|
77
|
-
instancePath: (raw['instancePath'] as string) ?? ('/' + (raw['path'] ?? '')),
|
|
78
|
-
params: (raw['params'] as Record<string, unknown>) ?? {},
|
|
79
|
-
parentSchema: raw['parentSchema'] as Record<string, unknown> | undefined,
|
|
80
|
-
} as AjvRawError
|
|
81
|
-
const item = this._formatOne(ajvError, msgs, locale)
|
|
82
|
-
return item.message
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Format an AJV raw error array → ValidationErrorItem[].
|
|
87
|
-
*
|
|
88
|
-
* @param alreadyMerged - when true, customMessages is already a fully merged locale+custom result;
|
|
89
|
-
* skip `{ ...this.messages, ...customMessages }` spread (avoids 100+ key cold-spread overhead).
|
|
90
|
-
*/
|
|
91
|
-
formatDetailed(
|
|
92
|
-
errors: AjvRawError[],
|
|
93
|
-
locale?: string,
|
|
94
|
-
customMessages?: ErrorMessages,
|
|
95
|
-
alreadyMerged = false
|
|
96
|
-
): ValidationErrorItem[] {
|
|
97
|
-
const msgs = customMessages
|
|
98
|
-
? (alreadyMerged ? customMessages : { ...this.messages, ...customMessages })
|
|
99
|
-
: this.messages
|
|
100
|
-
|
|
101
|
-
// Filter wrapper errors (if/anyOf/oneOf) when concrete field errors are present
|
|
102
|
-
const hasConcreteErrors = errors.some(
|
|
103
|
-
e => e.keyword !== 'if' && e.keyword !== 'anyOf' && e.keyword !== 'oneOf' && e.keyword !== 'error'
|
|
104
|
-
)
|
|
105
|
-
const filtered = hasConcreteErrors
|
|
106
|
-
? errors.filter(e => e.keyword !== 'if' && e.keyword !== 'anyOf' && e.keyword !== 'oneOf')
|
|
107
|
-
: errors
|
|
108
|
-
|
|
109
|
-
return filtered.map(err => this._formatOne(err, msgs, locale))
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Format a single error entry into a ValidationErrorItem.
|
|
114
|
-
*/
|
|
115
|
-
private _formatOne(
|
|
116
|
-
err: AjvRawError,
|
|
117
|
-
messages: ErrorMessages,
|
|
118
|
-
_locale?: string
|
|
119
|
-
): ValidationErrorItem {
|
|
120
|
-
const keyword = err.keyword ?? 'validation'
|
|
121
|
-
const instancePath = err.instancePath ?? ''
|
|
122
|
-
const params = err.params ?? {} as Record<string, unknown>
|
|
123
|
-
|
|
124
|
-
// Field path calculation (required errors get special handling)
|
|
125
|
-
let fieldName: string
|
|
126
|
-
if (keyword === 'required' && params['missingProperty']) {
|
|
127
|
-
const parentPath = instancePath.replace(/^\//, '')
|
|
128
|
-
const missing = String(params['missingProperty'])
|
|
129
|
-
fieldName = parentPath ? `${parentPath}/${missing}` : missing
|
|
130
|
-
} else {
|
|
131
|
-
fieldName = instancePath.replace(/^\//, '') || 'value'
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Label resolution
|
|
135
|
-
const schema = (err.parentSchema ?? {}) as Record<string, unknown>
|
|
136
|
-
let label: string | undefined
|
|
137
|
-
|
|
138
|
-
// For required errors, get label from the specific property schema
|
|
139
|
-
if (keyword === 'required' && params['missingProperty']) {
|
|
140
|
-
const missingProp = String(params['missingProperty'])
|
|
141
|
-
const properties = schema['properties'] as Record<string, Record<string, unknown>> | undefined
|
|
142
|
-
if (properties && properties[missingProp]) {
|
|
143
|
-
label = properties[missingProp]['_label'] as string | undefined
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Fallback to parent schema label
|
|
148
|
-
if (!label) {
|
|
149
|
-
label = schema['_label'] as string | undefined
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// If _label is set, try to translate it as a locale key reference
|
|
153
|
-
if (label) {
|
|
154
|
-
label = (messages[label] != null ? String(messages[label]) : undefined) ?? label
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (!label) {
|
|
158
|
-
let labelKey: string
|
|
159
|
-
if (keyword === 'required' && params['missingProperty']) {
|
|
160
|
-
labelKey = String(params['missingProperty'])
|
|
161
|
-
} else {
|
|
162
|
-
const parts = fieldName.split('/')
|
|
163
|
-
labelKey = parts[parts.length - 1] ?? fieldName
|
|
164
|
-
}
|
|
165
|
-
const autoKey = `label.${labelKey.replace(/\//g, '.')}`
|
|
166
|
-
label = messages[autoKey] ?? labelKey
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Schema-level custom messages
|
|
170
|
-
let schemaCustomMessages = (schema['_customMessages'] ?? {}) as ErrorMessages
|
|
171
|
-
|
|
172
|
-
// For required errors, also check field-level custom messages
|
|
173
|
-
if (keyword === 'required' && params['missingProperty']) {
|
|
174
|
-
const missingProp = String(params['missingProperty'])
|
|
175
|
-
const properties = schema['properties'] as Record<string, Record<string, unknown>> | undefined
|
|
176
|
-
if (properties && properties[missingProp] && properties[missingProp]['_customMessages']) {
|
|
177
|
-
schemaCustomMessages = { ...schemaCustomMessages, ...(properties[missingProp]['_customMessages'] as ErrorMessages) }
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Performance: reuse `messages` directly when schemaCustomMessages is empty (99 % of calls)
|
|
182
|
-
const hasCustomMessages = Object.keys(schemaCustomMessages).length > 0
|
|
183
|
-
const mergedMessages = hasCustomMessages ? { ...messages, ...schemaCustomMessages } : messages
|
|
184
|
-
const mappedKeyword = KEYWORD_MAP[keyword] ?? keyword
|
|
185
|
-
const schemaType = typeof schema['type'] === 'string' ? schema['type'] : 'string'
|
|
186
|
-
|
|
187
|
-
// Message lookup order: schema custom > type+keyword > keyword > fallback
|
|
188
|
-
let message: string | undefined = hasCustomMessages
|
|
189
|
-
? (schemaCustomMessages[keyword] ?? schemaCustomMessages[mappedKeyword])
|
|
190
|
-
: undefined
|
|
191
|
-
|
|
192
|
-
if (message) {
|
|
193
|
-
// May be a key reference — try to resolve from mergedMessages
|
|
194
|
-
message = mergedMessages[message] ?? message
|
|
195
|
-
} else {
|
|
196
|
-
// Special handling for format.email etc.
|
|
197
|
-
if (mappedKeyword === 'format' && params['format']) {
|
|
198
|
-
let fmt = String(params['format'])
|
|
199
|
-
if (fmt === 'uri') fmt = 'url'
|
|
200
|
-
message = mergedMessages[`format.${fmt}`]
|
|
201
|
-
}
|
|
202
|
-
message ??=
|
|
203
|
-
mergedMessages[`${schemaType}.${keyword}`] ??
|
|
204
|
-
mergedMessages[`${schemaType}.${mappedKeyword}`] ??
|
|
205
|
-
mergedMessages[mappedKeyword] ??
|
|
206
|
-
mergedMessages[keyword] ??
|
|
207
|
-
mergedMessages['default'] ??
|
|
208
|
-
err.message ??
|
|
209
|
-
'Validation error'
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Interpolation params: spread AJV params first, then override fixed keys
|
|
213
|
-
const limit = params['limit'] ?? params['limitLength'] ?? params['comparison'] ?? ''
|
|
214
|
-
const allowedVals = Array.isArray(params['allowedValues'])
|
|
215
|
-
? (params['allowedValues'] as unknown[]).join(', ')
|
|
216
|
-
: undefined
|
|
217
|
-
const interpolateData: Record<string, unknown> = {
|
|
218
|
-
...params,
|
|
219
|
-
path: label,
|
|
220
|
-
label,
|
|
221
|
-
value: err.data !== undefined ? err.data : '',
|
|
222
|
-
limit,
|
|
223
|
-
min: limit,
|
|
224
|
-
max: limit,
|
|
225
|
-
expected: params['type'],
|
|
226
|
-
actual:
|
|
227
|
-
err.data === null
|
|
228
|
-
? 'null'
|
|
229
|
-
: err.data === undefined
|
|
230
|
-
? 'undefined'
|
|
231
|
-
: Array.isArray(err.data)
|
|
232
|
-
? 'array'
|
|
233
|
-
: typeof err.data,
|
|
234
|
-
valids: allowedVals,
|
|
235
|
-
allowed: allowedVals,
|
|
236
|
-
key: params['additionalProperty'],
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const rendered = renderTemplate(message, interpolateData)
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
path: fieldName,
|
|
243
|
-
message: rendered,
|
|
244
|
-
keyword,
|
|
245
|
-
params,
|
|
246
|
-
field: fieldName,
|
|
247
|
-
type: keyword,
|
|
248
|
-
expected: params['type'] !== undefined ? String(params['type']) : undefined,
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
setLocale(locale: string): void {
|
|
253
|
-
this._locale = locale
|
|
254
|
-
const rawLocaleMessages = getMessages(locale)
|
|
255
|
-
const localeMessages: ErrorMessages = Object.fromEntries(
|
|
256
|
-
Object.entries(rawLocaleMessages).map(([k, v]: [string, LocaleMessage]) => [
|
|
257
|
-
k,
|
|
258
|
-
typeof v === 'string' ? v : (v as { message: string }).message,
|
|
259
|
-
])
|
|
260
|
-
)
|
|
261
|
-
this.messages = { ...localeMessages, ...this._constructorCustomMessages }
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
addMessage(type: string, template: string): void {
|
|
265
|
-
this.messages[type] = template
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
addMessages(messages: ErrorMessages): void {
|
|
269
|
-
Object.assign(this.messages, messages)
|
|
270
|
-
}
|
|
271
|
-
}
|
|
1
|
+
import type { ValidationErrorItem } from '../types/validate.js'
|
|
2
|
+
import type { ErrorMessages } from '../types/error.js'
|
|
3
|
+
import { renderTemplate } from './TemplateEngine.js'
|
|
4
|
+
import { KEYWORD_MAP } from './ErrorCodes.js'
|
|
5
|
+
import { getMessages } from '../locales/index.js'
|
|
6
|
+
import type { LocaleMessage } from '../locales/types.js'
|
|
7
|
+
import { DEFAULT_LOCALE } from './Locale.js'
|
|
8
|
+
|
|
9
|
+
type AjvRawError = {
|
|
10
|
+
keyword: string
|
|
11
|
+
instancePath: string
|
|
12
|
+
schemaPath?: string
|
|
13
|
+
params: Record<string, unknown>
|
|
14
|
+
message?: string
|
|
15
|
+
data?: unknown
|
|
16
|
+
parentSchema?: Record<string, unknown>
|
|
17
|
+
schema?: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Error formatter.
|
|
22
|
+
* Delegates template interpolation to TemplateEngine.renderTemplate() (fix CORE-03).
|
|
23
|
+
* Maintains full v1 API compatibility.
|
|
24
|
+
*/
|
|
25
|
+
export class ErrorFormatter {
|
|
26
|
+
private messages: ErrorMessages
|
|
27
|
+
private _locale: string
|
|
28
|
+
private readonly _constructorCustomMessages: ErrorMessages
|
|
29
|
+
|
|
30
|
+
constructor(locale = DEFAULT_LOCALE, messages: ErrorMessages | Record<string, LocaleMessage | string | undefined> = {}) {
|
|
31
|
+
this._locale = locale
|
|
32
|
+
// Load locale messages as defaults; constructor-level custom messages override them
|
|
33
|
+
const rawLocaleMessages = getMessages(locale)
|
|
34
|
+
const localeMessages: ErrorMessages = Object.fromEntries(
|
|
35
|
+
Object.entries(rawLocaleMessages).map(([k, v]: [string, LocaleMessage]) => [
|
|
36
|
+
k,
|
|
37
|
+
typeof v === 'string' ? v : (v as { message: string }).message,
|
|
38
|
+
])
|
|
39
|
+
)
|
|
40
|
+
// Normalise caller-supplied messages: LocaleMessage objects → plain string
|
|
41
|
+
const normMessages: ErrorMessages = Object.fromEntries(
|
|
42
|
+
Object.entries(messages).map(([k, v]) => [
|
|
43
|
+
k,
|
|
44
|
+
v == null ? undefined : typeof v === 'string' ? v : (v as { message: string }).message,
|
|
45
|
+
])
|
|
46
|
+
)
|
|
47
|
+
this._constructorCustomMessages = normMessages
|
|
48
|
+
this.messages = { ...localeMessages, ...normMessages }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get locale(): string {
|
|
52
|
+
return this._locale
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format a single error object → message string (v1 API).
|
|
57
|
+
*/
|
|
58
|
+
format(error: AjvRawError | Record<string, unknown>, locale?: string): string {
|
|
59
|
+
// If locale differs, reload messages for that locale
|
|
60
|
+
let msgs = this.messages
|
|
61
|
+
if (locale && locale !== this._locale) {
|
|
62
|
+
const rawLocaleMessages = getMessages(locale)
|
|
63
|
+
const localeMessages: ErrorMessages = Object.fromEntries(
|
|
64
|
+
Object.entries(rawLocaleMessages).map(([k, v]: [string, LocaleMessage]) => [
|
|
65
|
+
k,
|
|
66
|
+
typeof v === 'string' ? v : (v as { message: string }).message,
|
|
67
|
+
])
|
|
68
|
+
)
|
|
69
|
+
// Preserve constructor-level custom messages across locale switches (R-01 fix)
|
|
70
|
+
msgs = { ...localeMessages, ...this._constructorCustomMessages }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Convert simple { type, path } format to AJV-like error
|
|
74
|
+
const raw = error as Record<string, unknown>
|
|
75
|
+
const ajvError = {
|
|
76
|
+
keyword: (raw['keyword'] as string) ?? (raw['type'] as string) ?? 'validation',
|
|
77
|
+
instancePath: (raw['instancePath'] as string) ?? ('/' + (raw['path'] ?? '')),
|
|
78
|
+
params: (raw['params'] as Record<string, unknown>) ?? {},
|
|
79
|
+
parentSchema: raw['parentSchema'] as Record<string, unknown> | undefined,
|
|
80
|
+
} as AjvRawError
|
|
81
|
+
const item = this._formatOne(ajvError, msgs, locale)
|
|
82
|
+
return item.message
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format an AJV raw error array → ValidationErrorItem[].
|
|
87
|
+
*
|
|
88
|
+
* @param alreadyMerged - when true, customMessages is already a fully merged locale+custom result;
|
|
89
|
+
* skip `{ ...this.messages, ...customMessages }` spread (avoids 100+ key cold-spread overhead).
|
|
90
|
+
*/
|
|
91
|
+
formatDetailed(
|
|
92
|
+
errors: AjvRawError[],
|
|
93
|
+
locale?: string,
|
|
94
|
+
customMessages?: ErrorMessages,
|
|
95
|
+
alreadyMerged = false
|
|
96
|
+
): ValidationErrorItem[] {
|
|
97
|
+
const msgs = customMessages
|
|
98
|
+
? (alreadyMerged ? customMessages : { ...this.messages, ...customMessages })
|
|
99
|
+
: this.messages
|
|
100
|
+
|
|
101
|
+
// Filter wrapper errors (if/anyOf/oneOf) when concrete field errors are present
|
|
102
|
+
const hasConcreteErrors = errors.some(
|
|
103
|
+
e => e.keyword !== 'if' && e.keyword !== 'anyOf' && e.keyword !== 'oneOf' && e.keyword !== 'error'
|
|
104
|
+
)
|
|
105
|
+
const filtered = hasConcreteErrors
|
|
106
|
+
? errors.filter(e => e.keyword !== 'if' && e.keyword !== 'anyOf' && e.keyword !== 'oneOf')
|
|
107
|
+
: errors
|
|
108
|
+
|
|
109
|
+
return filtered.map(err => this._formatOne(err, msgs, locale))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Format a single error entry into a ValidationErrorItem.
|
|
114
|
+
*/
|
|
115
|
+
private _formatOne(
|
|
116
|
+
err: AjvRawError,
|
|
117
|
+
messages: ErrorMessages,
|
|
118
|
+
_locale?: string
|
|
119
|
+
): ValidationErrorItem {
|
|
120
|
+
const keyword = err.keyword ?? 'validation'
|
|
121
|
+
const instancePath = err.instancePath ?? ''
|
|
122
|
+
const params = err.params ?? {} as Record<string, unknown>
|
|
123
|
+
|
|
124
|
+
// Field path calculation (required errors get special handling)
|
|
125
|
+
let fieldName: string
|
|
126
|
+
if (keyword === 'required' && params['missingProperty']) {
|
|
127
|
+
const parentPath = instancePath.replace(/^\//, '')
|
|
128
|
+
const missing = String(params['missingProperty'])
|
|
129
|
+
fieldName = parentPath ? `${parentPath}/${missing}` : missing
|
|
130
|
+
} else {
|
|
131
|
+
fieldName = instancePath.replace(/^\//, '') || 'value'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Label resolution
|
|
135
|
+
const schema = (err.parentSchema ?? {}) as Record<string, unknown>
|
|
136
|
+
let label: string | undefined
|
|
137
|
+
|
|
138
|
+
// For required errors, get label from the specific property schema
|
|
139
|
+
if (keyword === 'required' && params['missingProperty']) {
|
|
140
|
+
const missingProp = String(params['missingProperty'])
|
|
141
|
+
const properties = schema['properties'] as Record<string, Record<string, unknown>> | undefined
|
|
142
|
+
if (properties && properties[missingProp]) {
|
|
143
|
+
label = properties[missingProp]['_label'] as string | undefined
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fallback to parent schema label
|
|
148
|
+
if (!label) {
|
|
149
|
+
label = schema['_label'] as string | undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// If _label is set, try to translate it as a locale key reference
|
|
153
|
+
if (label) {
|
|
154
|
+
label = (messages[label] != null ? String(messages[label]) : undefined) ?? label
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!label) {
|
|
158
|
+
let labelKey: string
|
|
159
|
+
if (keyword === 'required' && params['missingProperty']) {
|
|
160
|
+
labelKey = String(params['missingProperty'])
|
|
161
|
+
} else {
|
|
162
|
+
const parts = fieldName.split('/')
|
|
163
|
+
labelKey = parts[parts.length - 1] ?? fieldName
|
|
164
|
+
}
|
|
165
|
+
const autoKey = `label.${labelKey.replace(/\//g, '.')}`
|
|
166
|
+
label = messages[autoKey] ?? labelKey
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Schema-level custom messages
|
|
170
|
+
let schemaCustomMessages = (schema['_customMessages'] ?? {}) as ErrorMessages
|
|
171
|
+
|
|
172
|
+
// For required errors, also check field-level custom messages
|
|
173
|
+
if (keyword === 'required' && params['missingProperty']) {
|
|
174
|
+
const missingProp = String(params['missingProperty'])
|
|
175
|
+
const properties = schema['properties'] as Record<string, Record<string, unknown>> | undefined
|
|
176
|
+
if (properties && properties[missingProp] && properties[missingProp]['_customMessages']) {
|
|
177
|
+
schemaCustomMessages = { ...schemaCustomMessages, ...(properties[missingProp]['_customMessages'] as ErrorMessages) }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Performance: reuse `messages` directly when schemaCustomMessages is empty (99 % of calls)
|
|
182
|
+
const hasCustomMessages = Object.keys(schemaCustomMessages).length > 0
|
|
183
|
+
const mergedMessages = hasCustomMessages ? { ...messages, ...schemaCustomMessages } : messages
|
|
184
|
+
const mappedKeyword = KEYWORD_MAP[keyword] ?? keyword
|
|
185
|
+
const schemaType = typeof schema['type'] === 'string' ? schema['type'] : 'string'
|
|
186
|
+
|
|
187
|
+
// Message lookup order: schema custom > type+keyword > keyword > fallback
|
|
188
|
+
let message: string | undefined = hasCustomMessages
|
|
189
|
+
? (schemaCustomMessages[keyword] ?? schemaCustomMessages[mappedKeyword])
|
|
190
|
+
: undefined
|
|
191
|
+
|
|
192
|
+
if (message) {
|
|
193
|
+
// May be a key reference — try to resolve from mergedMessages
|
|
194
|
+
message = mergedMessages[message] ?? message
|
|
195
|
+
} else {
|
|
196
|
+
// Special handling for format.email etc.
|
|
197
|
+
if (mappedKeyword === 'format' && params['format']) {
|
|
198
|
+
let fmt = String(params['format'])
|
|
199
|
+
if (fmt === 'uri') fmt = 'url'
|
|
200
|
+
message = mergedMessages[`format.${fmt}`]
|
|
201
|
+
}
|
|
202
|
+
message ??=
|
|
203
|
+
mergedMessages[`${schemaType}.${keyword}`] ??
|
|
204
|
+
mergedMessages[`${schemaType}.${mappedKeyword}`] ??
|
|
205
|
+
mergedMessages[mappedKeyword] ??
|
|
206
|
+
mergedMessages[keyword] ??
|
|
207
|
+
mergedMessages['default'] ??
|
|
208
|
+
err.message ??
|
|
209
|
+
'Validation error'
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Interpolation params: spread AJV params first, then override fixed keys
|
|
213
|
+
const limit = params['limit'] ?? params['limitLength'] ?? params['comparison'] ?? ''
|
|
214
|
+
const allowedVals = Array.isArray(params['allowedValues'])
|
|
215
|
+
? (params['allowedValues'] as unknown[]).join(', ')
|
|
216
|
+
: undefined
|
|
217
|
+
const interpolateData: Record<string, unknown> = {
|
|
218
|
+
...params,
|
|
219
|
+
path: label,
|
|
220
|
+
label,
|
|
221
|
+
value: err.data !== undefined ? err.data : '',
|
|
222
|
+
limit,
|
|
223
|
+
min: limit,
|
|
224
|
+
max: limit,
|
|
225
|
+
expected: params['type'],
|
|
226
|
+
actual:
|
|
227
|
+
err.data === null
|
|
228
|
+
? 'null'
|
|
229
|
+
: err.data === undefined
|
|
230
|
+
? 'undefined'
|
|
231
|
+
: Array.isArray(err.data)
|
|
232
|
+
? 'array'
|
|
233
|
+
: typeof err.data,
|
|
234
|
+
valids: allowedVals,
|
|
235
|
+
allowed: allowedVals,
|
|
236
|
+
key: params['additionalProperty'],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const rendered = renderTemplate(message, interpolateData)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
path: fieldName,
|
|
243
|
+
message: rendered,
|
|
244
|
+
keyword,
|
|
245
|
+
params,
|
|
246
|
+
field: fieldName,
|
|
247
|
+
type: keyword,
|
|
248
|
+
expected: params['type'] !== undefined ? String(params['type']) : undefined,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
setLocale(locale: string): void {
|
|
253
|
+
this._locale = locale
|
|
254
|
+
const rawLocaleMessages = getMessages(locale)
|
|
255
|
+
const localeMessages: ErrorMessages = Object.fromEntries(
|
|
256
|
+
Object.entries(rawLocaleMessages).map(([k, v]: [string, LocaleMessage]) => [
|
|
257
|
+
k,
|
|
258
|
+
typeof v === 'string' ? v : (v as { message: string }).message,
|
|
259
|
+
])
|
|
260
|
+
)
|
|
261
|
+
this.messages = { ...localeMessages, ...this._constructorCustomMessages }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
addMessage(type: string, template: string): void {
|
|
265
|
+
this.messages[type] = template
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
addMessages(messages: ErrorMessages): void {
|
|
269
|
+
Object.assign(this.messages, messages)
|
|
270
|
+
}
|
|
271
|
+
}
|