@amritk/generate-validators 0.5.1 → 0.6.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.
@@ -1 +1 @@
1
- {"version":3,"file":"generate-validator-function.d.ts","sourceRoot":"","sources":["../../src/generators/generate-validator-function.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAqajE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,yBAAyB,WAAY,UAAU,YAAY,MAAM,sBAAgB,MAM7F,CAAA"}
1
+ {"version":3,"file":"generate-validator-function.d.ts","sourceRoot":"","sources":["../../src/generators/generate-validator-function.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AA+ejE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,yBAAyB,WAAY,UAAU,YAAY,MAAM,sBAAgB,MAM7F,CAAA"}
@@ -1,6 +1,7 @@
1
+ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
1
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
2
3
  import { refToName } from '@amritk/helpers/ref-to-name';
3
- import { hasAdditionalProperties, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
4
+ import { hasAdditionalProperties, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
4
5
  /**
5
6
  * Derives the validator function name from a type name.
6
7
  * e.g. "InfoObject" → "validateInfoObject"
@@ -14,6 +15,18 @@ const typeofString = (type) => {
14
15
  return 'number';
15
16
  return type;
16
17
  };
18
+ /**
19
+ * Generates the inline condition that is TRUE when `accessor` does NOT equal the
20
+ * `const` value. Primitives compare with `!==`; objects/arrays compare by their
21
+ * canonical JSON serialization (sufficient for the literal, fixed shapes `const`
22
+ * is used for).
23
+ */
24
+ const constMismatchCondition = (accessor, value) => {
25
+ if (value === null || typeof value !== 'object') {
26
+ return `${accessor} !== ${JSON.stringify(value)}`;
27
+ }
28
+ return `JSON.stringify(${accessor}) !== ${JSON.stringify(JSON.stringify(value))}`;
29
+ };
17
30
  /**
18
31
  * Generates the inline condition that is TRUE when a value is the wrong type.
19
32
  */
@@ -98,6 +111,24 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
98
111
  }
99
112
  return lines;
100
113
  }
114
+ // const — value must equal the fixed value exactly
115
+ if (hasConst(propSchema)) {
116
+ const mismatch = constMismatchCondition(raw, propSchema.const);
117
+ const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
118
+ if (isRequired) {
119
+ lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
120
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
121
+ lines.push(` } else if (${mismatch}) {`);
122
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
123
+ lines.push(` }`);
124
+ }
125
+ else {
126
+ lines.push(` if (${raw} !== undefined && ${mismatch}) {`);
127
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
128
+ lines.push(` }`);
129
+ }
130
+ return lines;
131
+ }
101
132
  // enum
102
133
  if (hasEnum(propSchema)) {
103
134
  const allowed = JSON.stringify(propSchema.enum);
@@ -138,8 +169,10 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
138
169
  // String constraints
139
170
  if (t === 'string') {
140
171
  if (hasPattern(propSchema)) {
141
- lines.push(` if (typeof ${raw} === 'string' && !/${propSchema.pattern}/.test(${raw})) {`);
142
- lines.push(` errors.push({ message: 'must match pattern ${propSchema.pattern}', path: ${path} })`);
172
+ const re = escapeRegexPattern(propSchema.pattern);
173
+ const msg = JSON.stringify(`must match pattern ${propSchema.pattern}`);
174
+ lines.push(` if (typeof ${raw} === 'string' && !/${re}/.test(${raw})) {`);
175
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
143
176
  lines.push(` }`);
144
177
  }
145
178
  if (hasMinLength(propSchema)) {
@@ -236,6 +269,30 @@ const generateObjectValidator = (schema, typeName, suffix) => {
236
269
  propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
237
270
  propertyLines.push(` }`);
238
271
  }
272
+ // dependentRequired — when a trigger property is present, its dependencies must be too.
273
+ if (hasDependentRequired(schema)) {
274
+ for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
275
+ if (!Array.isArray(deps))
276
+ continue;
277
+ for (const dep of deps) {
278
+ const msg = JSON.stringify(`must have property '${dep}' when '${trigger}' is present`);
279
+ propertyLines.push(` if (${JSON.stringify(trigger)} in obj && !(${JSON.stringify(dep)} in obj)) {`);
280
+ propertyLines.push(` errors.push({ message: ${msg}, path: _path })`);
281
+ propertyLines.push(` }`);
282
+ }
283
+ }
284
+ }
285
+ // propertyNames with a pattern — every key must match. (Only the pattern form
286
+ // is emitted; a $ref/complex propertyNames schema is left to runtime validation.)
287
+ if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames) && hasPattern(schema.propertyNames)) {
288
+ const re = escapeRegexPattern(schema.propertyNames.pattern);
289
+ const msg = JSON.stringify(`property name must match pattern ${schema.propertyNames.pattern}`);
290
+ propertyLines.push(` for (const _name of Object.keys(obj)) {`);
291
+ propertyLines.push(` if (!/${re}/.test(_name)) {`);
292
+ propertyLines.push(` errors.push({ message: ${msg}, path: \`\${_path}/\${_name}\` })`);
293
+ propertyLines.push(` }`);
294
+ propertyLines.push(` }`);
295
+ }
239
296
  const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '';
240
297
  return [
241
298
  `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
@@ -291,6 +348,19 @@ const generateScalarValidator = (schema, typeName, suffix) => {
291
348
  `}`,
292
349
  ].join('\n');
293
350
  }
351
+ // Top-level const
352
+ if (hasConst(schema)) {
353
+ const mismatch = constMismatchCondition('input', schema.const);
354
+ const msg = JSON.stringify(`must be ${JSON.stringify(schema.const)}`);
355
+ return [
356
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
357
+ ` if (${mismatch}) {`,
358
+ ` return { valid: false, errors: [{ message: ${msg}, path: _path }] }`,
359
+ ` }`,
360
+ ` return true`,
361
+ `}`,
362
+ ].join('\n');
363
+ }
294
364
  // Top-level enum
295
365
  if (hasEnum(schema)) {
296
366
  const allowed = JSON.stringify(schema.enum);
@@ -330,8 +400,10 @@ const generateScalarValidator = (schema, typeName, suffix) => {
330
400
  const constraintLines = [];
331
401
  if (t === 'string') {
332
402
  if (hasPattern(schema)) {
333
- constraintLines.push(` if (typeof input === 'string' && !/${schema.pattern}/.test(input)) {`);
334
- constraintLines.push(` errors.push({ message: 'must match pattern ${schema.pattern}', path: _path })`);
403
+ const re = escapeRegexPattern(schema.pattern);
404
+ const msg = JSON.stringify(`must match pattern ${schema.pattern}`);
405
+ constraintLines.push(` if (typeof input === 'string' && !/${re}/.test(input)) {`);
406
+ constraintLines.push(` errors.push({ message: ${msg}, path: _path })`);
335
407
  constraintLines.push(` }`);
336
408
  }
337
409
  if (hasMinLength(schema)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "json-schema-typed": "^8.0.1",
50
- "@amritk/helpers": "0.7.1"
50
+ "@amritk/helpers": "0.8.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@scalar/openapi-parser": "^0.26.1"
@@ -104,6 +104,77 @@ describe('generate-validator-function', () => {
104
104
  expect(code).toContain('.test(')
105
105
  })
106
106
 
107
+ it('escapes forward slashes in a pattern so the emitted regex literal compiles', () => {
108
+ const schema = {
109
+ type: 'object' as const,
110
+ properties: { date: { type: 'string' as const, pattern: '^\\d{4}/\\d{2}/\\d{2}$' } },
111
+ required: ['date'],
112
+ }
113
+ const code = generateValidatorFunction(schema, 'Event')
114
+
115
+ // The bare slashes are escaped (\/) and the digit classes keep their single
116
+ // backslash, so the literal is valid and means what the pattern says.
117
+ expect(code).toContain('!/^\\d{4}\\/\\d{2}\\/\\d{2}$/.test(')
118
+ // Sanity check: the emitted regex source actually parses and matches.
119
+ const emitted = /!\/(.+)\/\.test\(/.exec(code)?.[1]
120
+ expect(emitted).toBeDefined()
121
+ expect(new RegExp(emitted as string).test('2024/01/02')).toBe(true)
122
+ })
123
+
124
+ it('checks a const property for an exact value', () => {
125
+ const schema = {
126
+ type: 'object' as const,
127
+ properties: { kind: { const: 'user' }, version: { const: 2 } },
128
+ required: ['kind'],
129
+ }
130
+ const code = generateValidatorFunction(schema, 'Record')
131
+
132
+ expect(code).toContain('obj["kind"] !== "user"')
133
+ expect(code).toContain('obj["version"] !== 2')
134
+ expect(code).toContain('must be \\"user\\"')
135
+ })
136
+
137
+ it('checks a const object property by canonical JSON', () => {
138
+ const schema = {
139
+ type: 'object' as const,
140
+ properties: { meta: { const: { a: 1 } } },
141
+ }
142
+ const code = generateValidatorFunction(schema, 'Record')
143
+
144
+ expect(code).toContain('JSON.stringify(obj["meta"]) !== ')
145
+ })
146
+
147
+ it('generates a top-level const validator', () => {
148
+ const code = generateValidatorFunction({ const: 'fixed' }, 'Tag')
149
+
150
+ expect(code).toContain('input !== "fixed"')
151
+ expect(code).toContain('must be \\"fixed\\"')
152
+ })
153
+
154
+ it('generates dependentRequired presence checks', () => {
155
+ const schema = {
156
+ type: 'object' as const,
157
+ properties: { creditCard: { type: 'number' as const }, billingAddress: { type: 'string' as const } },
158
+ dependentRequired: { creditCard: ['billingAddress'] },
159
+ }
160
+ const code = generateValidatorFunction(schema, 'Payment')
161
+
162
+ expect(code).toContain('"creditCard" in obj && !("billingAddress" in obj)')
163
+ expect(code).toContain("must have property 'billingAddress' when 'creditCard' is present")
164
+ })
165
+
166
+ it('generates a propertyNames pattern check over every key', () => {
167
+ const schema = {
168
+ type: 'object' as const,
169
+ propertyNames: { pattern: '^[a-z]+$' },
170
+ }
171
+ const code = generateValidatorFunction(schema, 'Dict')
172
+
173
+ expect(code).toContain('for (const _name of Object.keys(obj))')
174
+ expect(code).toContain('!/^[a-z]+$/.test(_name)')
175
+ expect(code).toContain('property name must match pattern')
176
+ })
177
+
107
178
  it('generates min/maxLength checks', () => {
108
179
  const schema = {
109
180
  type: 'object' as const,
@@ -153,7 +224,9 @@ describe('generate-validator-function', () => {
153
224
 
154
225
  // All three constraints push onto a shared errors array instead of returning early
155
226
  expect(code).toContain('const errors: ValidationError[] = []')
156
- expect(code).toContain("errors.push({ message: 'must match pattern")
227
+ expect(code).toContain('errors.push({ message: "must match pattern')
228
+ // The pattern body keeps its backslash (\d), so the emitted literal is a digit class.
229
+ expect(code).toContain('!/^\\d+$/.test(input)')
157
230
  expect(code).toContain("errors.push({ message: 'must have at least 2 characters'")
158
231
  expect(code).toContain("errors.push({ message: 'must have at most 4 characters'")
159
232
  expect(code).toContain('return errors.length > 0 ? { valid: false, errors } : true')
@@ -1,7 +1,10 @@
1
+ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern'
1
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension'
2
3
  import { refToName } from '@amritk/helpers/ref-to-name'
3
4
  import {
4
5
  hasAdditionalProperties,
6
+ hasConst,
7
+ hasDependentRequired,
5
8
  hasEnum,
6
9
  hasExclusiveMaximum,
7
10
  hasExclusiveMinimum,
@@ -14,6 +17,7 @@ import {
14
17
  hasOneOf,
15
18
  hasPattern,
16
19
  hasProperties,
20
+ hasPropertyNames,
17
21
  hasRef,
18
22
  hasRequired,
19
23
  hasType,
@@ -36,6 +40,19 @@ const typeofString = (type: string): string => {
36
40
  return type
37
41
  }
38
42
 
43
+ /**
44
+ * Generates the inline condition that is TRUE when `accessor` does NOT equal the
45
+ * `const` value. Primitives compare with `!==`; objects/arrays compare by their
46
+ * canonical JSON serialization (sufficient for the literal, fixed shapes `const`
47
+ * is used for).
48
+ */
49
+ const constMismatchCondition = (accessor: string, value: unknown): string => {
50
+ if (value === null || typeof value !== 'object') {
51
+ return `${accessor} !== ${JSON.stringify(value)}`
52
+ }
53
+ return `JSON.stringify(${accessor}) !== ${JSON.stringify(JSON.stringify(value))}`
54
+ }
55
+
39
56
  /**
40
57
  * Generates the inline condition that is TRUE when a value is the wrong type.
41
58
  */
@@ -123,6 +140,24 @@ const generatePropertyChecks = (key: string, propSchema: JSONSchema, isRequired:
123
140
  return lines
124
141
  }
125
142
 
143
+ // const — value must equal the fixed value exactly
144
+ if (hasConst(propSchema)) {
145
+ const mismatch = constMismatchCondition(raw, propSchema.const)
146
+ const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`)
147
+ if (isRequired) {
148
+ lines.push(` if (!(${JSON.stringify(key)} in obj)) {`)
149
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`)
150
+ lines.push(` } else if (${mismatch}) {`)
151
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`)
152
+ lines.push(` }`)
153
+ } else {
154
+ lines.push(` if (${raw} !== undefined && ${mismatch}) {`)
155
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`)
156
+ lines.push(` }`)
157
+ }
158
+ return lines
159
+ }
160
+
126
161
  // enum
127
162
  if (hasEnum(propSchema)) {
128
163
  const allowed = JSON.stringify(propSchema.enum)
@@ -165,8 +200,10 @@ const generatePropertyChecks = (key: string, propSchema: JSONSchema, isRequired:
165
200
  // String constraints
166
201
  if (t === 'string') {
167
202
  if (hasPattern(propSchema)) {
168
- lines.push(` if (typeof ${raw} === 'string' && !/${propSchema.pattern}/.test(${raw})) {`)
169
- lines.push(` errors.push({ message: 'must match pattern ${propSchema.pattern}', path: ${path} })`)
203
+ const re = escapeRegexPattern(propSchema.pattern)
204
+ const msg = JSON.stringify(`must match pattern ${propSchema.pattern}`)
205
+ lines.push(` if (typeof ${raw} === 'string' && !/${re}/.test(${raw})) {`)
206
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`)
170
207
  lines.push(` }`)
171
208
  }
172
209
  if (hasMinLength(propSchema)) {
@@ -278,6 +315,31 @@ const generateObjectValidator = (schema: JSONSchema, typeName: string, suffix: s
278
315
  propertyLines.push(` }`)
279
316
  }
280
317
 
318
+ // dependentRequired — when a trigger property is present, its dependencies must be too.
319
+ if (hasDependentRequired(schema)) {
320
+ for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
321
+ if (!Array.isArray(deps)) continue
322
+ for (const dep of deps) {
323
+ const msg = JSON.stringify(`must have property '${dep}' when '${trigger}' is present`)
324
+ propertyLines.push(` if (${JSON.stringify(trigger)} in obj && !(${JSON.stringify(dep)} in obj)) {`)
325
+ propertyLines.push(` errors.push({ message: ${msg}, path: _path })`)
326
+ propertyLines.push(` }`)
327
+ }
328
+ }
329
+ }
330
+
331
+ // propertyNames with a pattern — every key must match. (Only the pattern form
332
+ // is emitted; a $ref/complex propertyNames schema is left to runtime validation.)
333
+ if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames) && hasPattern(schema.propertyNames)) {
334
+ const re = escapeRegexPattern(schema.propertyNames.pattern)
335
+ const msg = JSON.stringify(`property name must match pattern ${schema.propertyNames.pattern}`)
336
+ propertyLines.push(` for (const _name of Object.keys(obj)) {`)
337
+ propertyLines.push(` if (!/${re}/.test(_name)) {`)
338
+ propertyLines.push(` errors.push({ message: ${msg}, path: \`\${_path}/\${_name}\` })`)
339
+ propertyLines.push(` }`)
340
+ propertyLines.push(` }`)
341
+ }
342
+
281
343
  const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : ''
282
344
 
283
345
  return [
@@ -342,6 +404,20 @@ const generateScalarValidator = (schema: JSONSchema, typeName: string, suffix: s
342
404
  ].join('\n')
343
405
  }
344
406
 
407
+ // Top-level const
408
+ if (hasConst(schema)) {
409
+ const mismatch = constMismatchCondition('input', schema.const)
410
+ const msg = JSON.stringify(`must be ${JSON.stringify(schema.const)}`)
411
+ return [
412
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
413
+ ` if (${mismatch}) {`,
414
+ ` return { valid: false, errors: [{ message: ${msg}, path: _path }] }`,
415
+ ` }`,
416
+ ` return true`,
417
+ `}`,
418
+ ].join('\n')
419
+ }
420
+
345
421
  // Top-level enum
346
422
  if (hasEnum(schema)) {
347
423
  const allowed = JSON.stringify(schema.enum)
@@ -385,8 +461,10 @@ const generateScalarValidator = (schema: JSONSchema, typeName: string, suffix: s
385
461
 
386
462
  if (t === 'string') {
387
463
  if (hasPattern(schema)) {
388
- constraintLines.push(` if (typeof input === 'string' && !/${schema.pattern}/.test(input)) {`)
389
- constraintLines.push(` errors.push({ message: 'must match pattern ${schema.pattern}', path: _path })`)
464
+ const re = escapeRegexPattern(schema.pattern)
465
+ const msg = JSON.stringify(`must match pattern ${schema.pattern}`)
466
+ constraintLines.push(` if (typeof input === 'string' && !/${re}/.test(input)) {`)
467
+ constraintLines.push(` errors.push({ message: ${msg}, path: _path })`)
390
468
  constraintLines.push(` }`)
391
469
  }
392
470
  if (hasMinLength(schema)) {