@amritk/generate-validators 0.2.1 → 0.2.3

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/dist/index.js CHANGED
@@ -773,6 +773,9 @@ var generateTypeDefinition = (schema, typeName) => {
773
773
  };
774
774
 
775
775
  // ../helpers/dist/schema-guards.js
776
+ var hasRef = (value) => {
777
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "$ref" in value && typeof value.$ref === "string";
778
+ };
776
779
  var isSchemaObject3 = (schema) => {
777
780
  return typeof schema === "object" && schema !== null && typeof schema !== "boolean";
778
781
  };
@@ -830,9 +833,6 @@ var hasExclusiveMaximum = (schema) => {
830
833
  var hasMultipleOf = (schema) => {
831
834
  return isSchemaObject3(schema) && "multipleOf" in schema && typeof schema.multipleOf === "number";
832
835
  };
833
- var hasRef = (schema) => {
834
- return isSchemaObject3(schema) && "$ref" in schema && typeof schema.$ref === "string";
835
- };
836
836
 
837
837
  // src/generators/collect-validator-imports.ts
838
838
  var buildImport = (ref) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -24,7 +24,8 @@
24
24
  "url": "https://github.com/amritk/mjst/issues"
25
25
  },
26
26
  "files": [
27
- "dist"
27
+ "dist",
28
+ "src"
28
29
  ],
29
30
  "publishConfig": {
30
31
  "access": "public"
@@ -0,0 +1,163 @@
1
+ import { validate } from '@scalar/openapi-parser'
2
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { buildValidatorSchema } from './build-schema'
6
+
7
+ describe('build-schema', () => {
8
+ it('generates a validator file for the root schema', async () => {
9
+ const schema: JSONSchema = {
10
+ type: 'object',
11
+ properties: { title: { type: 'string' } },
12
+ required: ['title'],
13
+ }
14
+
15
+ const files = await buildValidatorSchema(schema, 'Document')
16
+ const filenames = files.map((f) => f.filename)
17
+
18
+ expect(filenames).toContain('document.ts')
19
+ expect(filenames).toContain('validation-result.ts')
20
+ expect(filenames).toContain('index.ts')
21
+ })
22
+
23
+ it('generates a file per $ref definition', async () => {
24
+ const schema: JSONSchema = {
25
+ type: 'object',
26
+ properties: {
27
+ info: { $ref: '#/$defs/info' },
28
+ },
29
+ $defs: {
30
+ info: {
31
+ type: 'object',
32
+ properties: { title: { type: 'string' } },
33
+ required: ['title'],
34
+ },
35
+ },
36
+ }
37
+
38
+ const files = await buildValidatorSchema(schema, 'Document')
39
+ const filenames = files.map((f) => f.filename)
40
+
41
+ expect(filenames).toContain('document.ts')
42
+ expect(filenames).toContain('info.ts')
43
+ })
44
+
45
+ it('generated document.ts exports a validateDocument function', async () => {
46
+ const schema: JSONSchema = {
47
+ type: 'object',
48
+ properties: { title: { type: 'string' } },
49
+ required: ['title'],
50
+ }
51
+
52
+ const files = await buildValidatorSchema(schema, 'Document')
53
+ const documentFile = files.find((f) => f.filename === 'document.ts')
54
+
55
+ expect(documentFile?.content).toContain('export const validateDocument')
56
+ expect(documentFile?.content).toContain('ValidationResult')
57
+ })
58
+
59
+ it('generated file imports the ref validator for $ref properties', async () => {
60
+ const schema: JSONSchema = {
61
+ type: 'object',
62
+ properties: {
63
+ info: { $ref: '#/$defs/info' },
64
+ },
65
+ $defs: {
66
+ info: {
67
+ type: 'object',
68
+ properties: { title: { type: 'string' } },
69
+ },
70
+ },
71
+ }
72
+
73
+ const files = await buildValidatorSchema(schema, 'Document')
74
+ const documentFile = files.find((f) => f.filename === 'document.ts')
75
+
76
+ expect(documentFile?.content).toContain("from './info'")
77
+ expect(documentFile?.content).toContain('validateInfoObject')
78
+ })
79
+
80
+ it('generates a valid index.ts with re-exports', async () => {
81
+ const schema: JSONSchema = {
82
+ type: 'object',
83
+ properties: { title: { type: 'string' } },
84
+ }
85
+
86
+ const files = await buildValidatorSchema(schema, 'Document')
87
+ const indexFile = files.find((f) => f.filename === 'index.ts')
88
+
89
+ expect(indexFile?.content).toContain("from './document'")
90
+ expect(indexFile?.content).toContain('validateDocument')
91
+ })
92
+
93
+ it('does not generate a file named validation-result for a schema ref', async () => {
94
+ const schema: JSONSchema = {
95
+ type: 'object',
96
+ properties: {
97
+ result: { $ref: '#/$defs/validation-result' },
98
+ },
99
+ $defs: {
100
+ 'validation-result': { type: 'object' },
101
+ },
102
+ }
103
+
104
+ const files = await buildValidatorSchema(schema, 'Document')
105
+ // Should still have exactly one validation-result.ts (the runtime contract)
106
+ const vrFiles = files.filter((f) => f.filename === 'validation-result.ts')
107
+ expect(vrFiles).toHaveLength(1)
108
+ expect(vrFiles[0]?.content).toContain('export type ValidationResult')
109
+ })
110
+
111
+ it('produces generated validators that agree with @scalar/openapi-parser on a valid document', async () => {
112
+ // Build validators for a minimal OpenAPI-like schema
113
+ const schema: JSONSchema = {
114
+ type: 'object',
115
+ properties: {
116
+ openapi: { type: 'string' },
117
+ info: { $ref: '#/$defs/info' },
118
+ },
119
+ required: ['openapi', 'info'],
120
+ $defs: {
121
+ info: {
122
+ type: 'object',
123
+ properties: {
124
+ title: { type: 'string' },
125
+ version: { type: 'string' },
126
+ },
127
+ required: ['title', 'version'],
128
+ },
129
+ },
130
+ }
131
+
132
+ const files = await buildValidatorSchema(schema, 'Document')
133
+
134
+ // Sanity-check generated code shape
135
+ const documentFile = files.find((f) => f.filename === 'document.ts')
136
+ const infoFile = files.find((f) => f.filename === 'info.ts')
137
+
138
+ expect(documentFile?.content).toContain('validateDocument')
139
+ expect(infoFile?.content).toContain('validateInfoObject')
140
+
141
+ // Cross-check: @scalar/openapi-parser says a complete document is valid
142
+ const validDoc = { openapi: '3.1.0', info: { title: 'API', version: '1.0' }, paths: {} }
143
+ const refResult = await validate(validDoc)
144
+ expect(refResult.valid).toBe(true)
145
+
146
+ // Cross-check: @scalar/openapi-parser says a document missing info.title is invalid
147
+ const invalidDoc = { openapi: '3.1.0', info: { version: '1.0' }, paths: {} }
148
+ const refInvalid = await validate(invalidDoc)
149
+ expect(refInvalid.valid).toBe(false)
150
+ expect(refInvalid.errors.some((e) => e.message.includes('title'))).toBe(true)
151
+ })
152
+
153
+ it('emits a validation-result.ts with the runtime ValidationResult/ValidationError types', async () => {
154
+ const schema: JSONSchema = { type: 'object' }
155
+ const files = await buildValidatorSchema(schema, 'Doc')
156
+ const vrFile = files.find((f) => f.filename === 'validation-result.ts')
157
+
158
+ expect(vrFile?.content).toContain('export type ValidationError')
159
+ expect(vrFile?.content).toContain('export type ValidationResult')
160
+ expect(vrFile?.content).toContain('message: string')
161
+ expect(vrFile?.content).toContain('path: string')
162
+ })
163
+ })
@@ -0,0 +1,140 @@
1
+ import { buildDynamicRefMap } from '@amritk/helpers/build-dynamic-ref-map'
2
+ import { extractRefs } from '@amritk/helpers/extract-refs'
3
+ import { refToFilename } from '@amritk/helpers/ref-to-filename'
4
+ import { refToName } from '@amritk/helpers/ref-to-name'
5
+ import { resolveDynamicRefs } from '@amritk/helpers/resolve-dynamic-refs'
6
+ import { resolveRef } from '@amritk/helpers/resolve-ref'
7
+ import { upgradeDraft07Schema } from '@amritk/helpers/upgrade-draft07-schema'
8
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
9
+
10
+ import { generateValidatorFile } from './generate-files'
11
+
12
+ /**
13
+ * Represents a generated TypeScript file with its filename and content.
14
+ */
15
+ export type GeneratedFile = {
16
+ filename: string
17
+ content: string
18
+ }
19
+
20
+ const VALIDATION_RESULT_CONTENT = `/**
21
+ * A single validation error with a human-readable message and a JSON Pointer
22
+ * path indicating where in the document the error occurred.
23
+ */
24
+ export type ValidationError = {
25
+ message: string
26
+ path: string
27
+ }
28
+
29
+ /**
30
+ * The result of a generated validator function.
31
+ * Returns \`true\` when the input is valid, or an object with \`valid: false\`
32
+ * and a list of errors when it is not.
33
+ */
34
+ export type ValidationResult = true | { valid: false; errors: ValidationError[] }
35
+ `
36
+
37
+ /**
38
+ * Builds all TypeScript validator files from a JSON Schema by traversing
39
+ * all $ref references recursively, mirroring the generate-parsers pipeline.
40
+ *
41
+ * Each generated file exports:
42
+ * - A TypeScript type definition
43
+ * - A `validateFoo(input: unknown, _path?: string): ValidationResult` function
44
+ *
45
+ * A `validation-result.ts` file containing the `ValidationResult` and `ValidationError`
46
+ * runtime contract is always emitted. An `index.ts` re-exports everything.
47
+ *
48
+ * @param rootSchema - The root JSON Schema to build from
49
+ * @param rootTypeName - The name for the root type (e.g. "Document")
50
+ * @returns An array of generated TypeScript files
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * const files = await buildValidatorSchema(schema, 'Document')
55
+ * // files → [{ filename: 'document.ts', content: '...' }, { filename: 'info.ts', ... }, ...]
56
+ * ```
57
+ */
58
+ export const buildValidatorSchema = async (rootSchema: JSONSchema, rootTypeName: string): Promise<GeneratedFile[]> => {
59
+ rootSchema = upgradeDraft07Schema(rootSchema as Record<string, unknown>) as JSONSchema
60
+
61
+ const files: GeneratedFile[] = []
62
+ const processedRefs = new Set<string>()
63
+ const processedFilenames = new Set<string>()
64
+ const refsToProcess: string[] = []
65
+
66
+ const dynamicRefMap = buildDynamicRefMap(rootSchema)
67
+
68
+ // Root schema
69
+ const processedRootSchema = resolveDynamicRefs(rootSchema, dynamicRefMap)
70
+ const rootContent = generateValidatorFile(processedRootSchema, rootTypeName, {
71
+ rootSchema: rootSchema as Record<string, unknown>,
72
+ })
73
+ const rootFilename = rootTypeName.toLowerCase()
74
+
75
+ if (rootFilename !== 'validation-result') {
76
+ processedFilenames.add(rootFilename)
77
+ files.push({ filename: `${rootFilename}.ts`, content: rootContent })
78
+ }
79
+
80
+ const rootRefs = extractRefs(rootSchema)
81
+ refsToProcess.push(...rootRefs)
82
+
83
+ while (refsToProcess.length > 0) {
84
+ const ref = refsToProcess.shift()
85
+ if (!ref || processedRefs.has(ref)) continue
86
+ processedRefs.add(ref)
87
+
88
+ const resolvedSchema = resolveRef(ref, rootSchema as Record<string, unknown>)
89
+ if (!resolvedSchema) {
90
+ console.warn(`Warning: Could not resolve ref: ${ref}`)
91
+ continue
92
+ }
93
+
94
+ const typeName = refToName(ref)
95
+ const filename = refToFilename(ref)
96
+ const processedSchema = resolveDynamicRefs(resolvedSchema as JSONSchema, dynamicRefMap)
97
+ const content = generateValidatorFile(processedSchema, typeName, {
98
+ selfRef: ref,
99
+ rootSchema: rootSchema as Record<string, unknown>,
100
+ })
101
+
102
+ if (filename !== 'validation-result' && !processedFilenames.has(filename)) {
103
+ processedFilenames.add(filename)
104
+ files.push({ filename: `${filename}.ts`, content })
105
+ }
106
+
107
+ for (const nestedRef of extractRefs(resolvedSchema as JSONSchema)) {
108
+ if (!processedRefs.has(nestedRef)) refsToProcess.push(nestedRef)
109
+ }
110
+ }
111
+
112
+ // Emit the runtime contract for validators. ValidationResult is mjst-defined
113
+ // (not derived from the input schema), so its content is fixed.
114
+ files.push({ filename: 'validation-result.ts', content: VALIDATION_RESULT_CONTENT })
115
+
116
+ // Generate index.ts
117
+ const TYPE_EXPORT_RE = /^export type (\w+)/gm
118
+ const CONST_EXPORT_RE = /^export const (\w+)/gm
119
+
120
+ const sortedFiles = [...files].sort((a, b) => a.filename.localeCompare(b.filename))
121
+ let indexContent = ''
122
+
123
+ for (const file of sortedFiles) {
124
+ const moduleName = file.filename.replace(/\.ts$/, '')
125
+ const typeNames: string[] = []
126
+ const constNames: string[] = []
127
+
128
+ for (const match of file.content.matchAll(TYPE_EXPORT_RE)) typeNames.push(match[1] as string)
129
+ for (const match of file.content.matchAll(CONST_EXPORT_RE)) constNames.push(match[1] as string)
130
+
131
+ if (typeNames.length === 0 && constNames.length === 0) continue
132
+
133
+ const typeExports = typeNames.map((n) => `type ${n}`)
134
+ indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}';\n`
135
+ }
136
+
137
+ files.push({ filename: 'index.ts', content: indexContent })
138
+
139
+ return files
140
+ }
@@ -0,0 +1,128 @@
1
+ import { refToFilename } from '@amritk/helpers/ref-to-filename'
2
+ import { refToName } from '@amritk/helpers/ref-to-name'
3
+ import { resolveRef } from '@amritk/helpers/resolve-ref'
4
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards'
5
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
6
+
7
+ /**
8
+ * Options for controlling how validator imports are collected.
9
+ */
10
+ type CollectValidatorImportsOptions = {
11
+ /**
12
+ * The $ref path of the schema being generated (e.g. `#/$defs/encoding`).
13
+ * Prevents a file from importing itself.
14
+ */
15
+ readonly selfRef?: string | undefined
16
+ /**
17
+ * The root schema document. URI refs that cannot be resolved within it
18
+ * are excluded from the import list (they were never generated as files).
19
+ */
20
+ readonly rootSchema?: Record<string, unknown> | undefined
21
+ }
22
+
23
+ /**
24
+ * Generates an import statement for a single $ref, importing both the type
25
+ * and the validator function from the ref's generated file.
26
+ */
27
+ const buildImport = (ref: string): string => {
28
+ const filename = refToFilename(ref)
29
+ const typeName = refToName(ref)
30
+ const validatorName = `validate${typeName}`
31
+ return `import { type ${typeName}, ${validatorName} } from './${filename}'`
32
+ }
33
+
34
+ /**
35
+ * Resolves the canonical filename for a ref, stripping `-or-reference` suffixes
36
+ * so that `#/$defs/parameter-or-reference` maps to `parameter`.
37
+ */
38
+ const canonicalFilename = (ref: string): string => {
39
+ const base = ref.endsWith('-or-reference') ? ref.replace('-or-reference', '') : ref
40
+ return refToFilename(base)
41
+ }
42
+
43
+ /**
44
+ * Walks one level of the schema and yields all direct $ref strings that should
45
+ * become imports: properties, additionalProperties, items, and union branches.
46
+ */
47
+ const collectDirectRefs = (schema: JSONSchema): string[] => {
48
+ if (typeof schema === 'boolean' || schema === null) return []
49
+
50
+ const refs: string[] = []
51
+
52
+ if (hasRef(schema)) {
53
+ refs.push(schema.$ref)
54
+ return refs
55
+ }
56
+
57
+ const propSchemas =
58
+ 'properties' in schema && typeof schema.properties === 'object' && schema.properties !== null
59
+ ? Object.values(schema.properties as Record<string, JSONSchema>)
60
+ : []
61
+
62
+ for (const prop of propSchemas) {
63
+ if (hasRef(prop)) refs.push((prop as { $ref: string }).$ref)
64
+ if (hasItems(prop) && hasRef(prop.items)) refs.push((prop.items as { $ref: string }).$ref)
65
+ if (hasAdditionalProperties(prop) && hasRef(prop.additionalProperties as JSONSchema)) {
66
+ refs.push((prop.additionalProperties as { $ref: string }).$ref)
67
+ }
68
+ }
69
+
70
+ if (hasItems(schema) && hasRef(schema.items)) {
71
+ refs.push((schema.items as { $ref: string }).$ref)
72
+ }
73
+
74
+ if (hasAdditionalProperties(schema) && hasRef(schema.additionalProperties as JSONSchema)) {
75
+ refs.push((schema.additionalProperties as { $ref: string }).$ref)
76
+ }
77
+
78
+ for (const branch of [
79
+ ...(hasOneOf(schema) ? schema.oneOf : []),
80
+ ...(hasAnyOf(schema) ? schema.anyOf : []),
81
+ ...(hasAllOf(schema) ? schema.allOf : []),
82
+ ]) {
83
+ if (hasRef(branch)) refs.push((branch as { $ref: string }).$ref)
84
+ }
85
+
86
+ return refs
87
+ }
88
+
89
+ /**
90
+ * Collects import statements for all $ref dependencies of a schema.
91
+ * Each import brings in both the generated TypeScript type and validator function.
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * const schema = { properties: { contact: { $ref: '#/$defs/contact' } } }
96
+ * collectValidatorImports(schema)
97
+ * // ["import { type ContactObject, validateContactObject } from './contact-object'"]
98
+ * ```
99
+ */
100
+ export const collectValidatorImports = (schema: JSONSchema, options?: CollectValidatorImportsOptions): string[] => {
101
+ const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : null
102
+ const rootSchema = options?.rootSchema
103
+
104
+ const refs = collectDirectRefs(schema)
105
+ const seen = new Set<string>()
106
+ const imports: string[] = []
107
+
108
+ for (const ref of refs) {
109
+ const filename = canonicalFilename(ref)
110
+
111
+ if (seen.has(filename)) continue
112
+ if (selfFilename && filename === selfFilename) continue
113
+
114
+ // Skip refs that don't resolve in this schema (external / never generated)
115
+ if (rootSchema) {
116
+ const resolved = resolveRef(ref, rootSchema)
117
+ if (!resolved) continue
118
+ }
119
+
120
+ seen.add(filename)
121
+
122
+ // -or-reference unions import the base type's validator
123
+ const importRef = ref.endsWith('-or-reference') ? ref.replace('-or-reference', '') : ref
124
+ imports.push(buildImport(importRef))
125
+ }
126
+
127
+ return imports
128
+ }
@@ -0,0 +1,72 @@
1
+ import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition'
2
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
3
+
4
+ import { collectValidatorImports } from './collect-validator-imports'
5
+ import { generateValidatorFunction } from './generate-validator-function'
6
+
7
+ /**
8
+ * Options for controlling what gets generated in a validator file.
9
+ */
10
+ type GenerateValidatorFileOptions = {
11
+ /**
12
+ * The $ref path of the schema being generated (e.g. `#/$defs/info`).
13
+ * Prevents the file from importing itself.
14
+ */
15
+ readonly selfRef?: string
16
+ /**
17
+ * The root schema document. Used to filter out unresolvable refs.
18
+ */
19
+ readonly rootSchema?: Record<string, unknown>
20
+ }
21
+
22
+ /**
23
+ * Generates a complete TypeScript validator file from a JSON Schema.
24
+ *
25
+ * The file contains:
26
+ * - Imports for the ValidationResult/ValidationError types
27
+ * - Imports for any $ref types and their validator functions
28
+ * - The exported TypeScript type definition
29
+ * - The exported validator function
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * const schema = {
34
+ * type: 'object',
35
+ * properties: { title: { type: 'string' } },
36
+ * required: ['title'],
37
+ * }
38
+ * generateValidatorFile(schema, 'Info')
39
+ * // import type { ValidationResult, ValidationError } from './validation-result'
40
+ * // export type Info = { title: string }
41
+ * // export const validateInfo = (input: unknown, _path = ''): ValidationResult => { ... }
42
+ * ```
43
+ */
44
+ export const generateValidatorFile = (
45
+ schema: JSONSchema,
46
+ typeName: string,
47
+ options?: GenerateValidatorFileOptions,
48
+ ): string => {
49
+ const refImports = collectValidatorImports(schema, {
50
+ selfRef: options?.selfRef,
51
+ rootSchema: options?.rootSchema,
52
+ })
53
+
54
+ const typeDefinition = generateTypeDefinition(schema, typeName)
55
+ const validatorFunction = generateValidatorFunction(schema, typeName)
56
+
57
+ let result = `import type { ValidationResult, ValidationError } from './validation-result'\n`
58
+
59
+ for (const imp of refImports) {
60
+ result += imp + '\n'
61
+ }
62
+
63
+ if (refImports.length > 0) {
64
+ result += '\n'
65
+ } else {
66
+ result += '\n'
67
+ }
68
+
69
+ result += typeDefinition + '\n\n' + validatorFunction
70
+
71
+ return result
72
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { generateValidatorFunction } from './generate-validator-function'
4
+
5
+ // Eval helper: compiles a generated function string in context of a minimal
6
+ // ValidationResult runtime so we can actually run the generated code.
7
+ const _evalValidator = (code: string): ((input: unknown, path?: string) => unknown) => {
8
+ const _wrapped = `
9
+ const ValidationResult = null // type-only
10
+ ${code}
11
+ `
12
+ // eslint-disable-next-line no-new-func
13
+ return new Function(`
14
+ ${code}
15
+ return validate${code.match(/export const validate(\w+)/)?.[1] ?? ''}
16
+ `)() as (input: unknown, path?: string) => unknown
17
+ }
18
+
19
+ describe('generate-validator-function', () => {
20
+ it('generates a validator for a required string property', () => {
21
+ const schema = {
22
+ type: 'object' as const,
23
+ properties: { name: { type: 'string' as const } },
24
+ required: ['name'],
25
+ }
26
+ const code = generateValidatorFunction(schema, 'Info')
27
+
28
+ expect(code).toContain('export const validateInfo')
29
+ expect(code).toContain('"name" in obj')
30
+ expect(code).toContain("must have required property 'name'")
31
+ expect(code).toContain('typeof obj["name"] !== \'string\'')
32
+ expect(code).toContain('must be string')
33
+ })
34
+
35
+ it('generates a validator for an optional number property', () => {
36
+ const schema = {
37
+ type: 'object' as const,
38
+ properties: { count: { type: 'number' as const } },
39
+ }
40
+ const code = generateValidatorFunction(schema, 'Stats')
41
+
42
+ expect(code).toContain('obj["count"] !== undefined')
43
+ expect(code).toContain('typeof obj["count"] !== \'number\'')
44
+ expect(code).toContain('must be number')
45
+ })
46
+
47
+ it('generates a validator for a boolean property', () => {
48
+ const schema = {
49
+ type: 'object' as const,
50
+ properties: { enabled: { type: 'boolean' as const } },
51
+ required: ['enabled'],
52
+ }
53
+ const code = generateValidatorFunction(schema, 'Config')
54
+
55
+ expect(code).toContain('typeof obj["enabled"] !== \'boolean\'')
56
+ expect(code).toContain('must be boolean')
57
+ })
58
+
59
+ it('generates required-property check at parent path', () => {
60
+ const schema = {
61
+ type: 'object' as const,
62
+ properties: { title: { type: 'string' as const } },
63
+ required: ['title'],
64
+ }
65
+ const code = generateValidatorFunction(schema, 'Doc')
66
+
67
+ // Required errors use _path (parent), not a child path
68
+ expect(code).toContain('path: _path')
69
+ expect(code).toContain("must have required property 'title'")
70
+ })
71
+
72
+ it('generates type error at child path', () => {
73
+ const schema = {
74
+ type: 'object' as const,
75
+ properties: { title: { type: 'string' as const } },
76
+ required: ['title'],
77
+ }
78
+ const code = generateValidatorFunction(schema, 'Doc')
79
+
80
+ // Type errors use the child path
81
+ expect(code).toContain('`${_path}/title`')
82
+ })
83
+
84
+ it('generates an enum validator', () => {
85
+ const schema = {
86
+ enum: ['get', 'post', 'put', 'delete'],
87
+ }
88
+ const code = generateValidatorFunction(schema as Parameters<typeof generateValidatorFunction>[0], 'Method')
89
+
90
+ expect(code).toContain('must be one of')
91
+ expect(code).toContain('"get"')
92
+ expect(code).toContain('"post"')
93
+ })
94
+
95
+ it('generates a string pattern check', () => {
96
+ const schema = {
97
+ type: 'object' as const,
98
+ properties: { version: { type: 'string' as const, pattern: '^\\d+\\.\\d+' } },
99
+ required: ['version'],
100
+ }
101
+ const code = generateValidatorFunction(schema, 'Info')
102
+
103
+ expect(code).toContain('must match pattern')
104
+ expect(code).toContain('.test(')
105
+ })
106
+
107
+ it('generates min/maxLength checks', () => {
108
+ const schema = {
109
+ type: 'object' as const,
110
+ properties: { name: { type: 'string' as const, minLength: 1, maxLength: 100 } },
111
+ required: ['name'],
112
+ }
113
+ const code = generateValidatorFunction(schema, 'Info')
114
+
115
+ expect(code).toContain('must have at least 1 characters')
116
+ expect(code).toContain('must have at most 100 characters')
117
+ })
118
+
119
+ it('generates minimum/maximum checks', () => {
120
+ const schema = {
121
+ type: 'object' as const,
122
+ properties: { port: { type: 'number' as const, minimum: 0, maximum: 65535 } },
123
+ }
124
+ const code = generateValidatorFunction(schema, 'Server')
125
+
126
+ expect(code).toContain('>= 0')
127
+ expect(code).toContain('<= 65535')
128
+ })
129
+
130
+ it('generates a $ref property delegation', () => {
131
+ const schema = {
132
+ type: 'object' as const,
133
+ properties: { info: { $ref: '#/$defs/info' } },
134
+ required: ['info'],
135
+ }
136
+ const code = generateValidatorFunction(schema, 'Document')
137
+
138
+ expect(code).toContain('validateInfoObject(')
139
+ expect(code).toContain('"info" in obj')
140
+ })
141
+
142
+ it('generates a scalar string validator', () => {
143
+ const schema = { type: 'string' as const }
144
+ const code = generateValidatorFunction(schema, 'StringValue')
145
+
146
+ expect(code).toContain("typeof input !== 'string'")
147
+ expect(code).toContain('must be string')
148
+ })
149
+
150
+ it('returns true for empty object schemas', () => {
151
+ const schema = { type: 'object' as const }
152
+ const code = generateValidatorFunction(schema, 'Empty')
153
+
154
+ expect(code).toContain('validateEmpty')
155
+ expect(code).toContain('must be object')
156
+ expect(code).toContain('return errors.length > 0')
157
+ })
158
+
159
+ it('generates object guard at top of object validator', () => {
160
+ const schema = { type: 'object' as const, properties: { x: { type: 'string' as const } } }
161
+ const code = generateValidatorFunction(schema, 'Foo')
162
+
163
+ expect(code).toContain("typeof input !== 'object'")
164
+ expect(code).toContain('Array.isArray(input)')
165
+ expect(code).toContain('must be object')
166
+ })
167
+ })
@@ -0,0 +1,399 @@
1
+ import { refToName } from '@amritk/helpers/ref-to-name'
2
+ import {
3
+ hasAdditionalProperties,
4
+ hasEnum,
5
+ hasExclusiveMaximum,
6
+ hasExclusiveMinimum,
7
+ hasItems,
8
+ hasMaximum,
9
+ hasMaxLength,
10
+ hasMinimum,
11
+ hasMinLength,
12
+ hasMultipleOf,
13
+ hasOneOf,
14
+ hasPattern,
15
+ hasProperties,
16
+ hasRef,
17
+ hasRequired,
18
+ hasType,
19
+ isObjectSchema,
20
+ isSchemaObject,
21
+ } from '@amritk/helpers/schema-guards'
22
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
23
+
24
+ /**
25
+ * Derives the validator function name from a type name.
26
+ * e.g. "InfoObject" → "validateInfoObject"
27
+ */
28
+ const validatorName = (typeName: string): string => `validate${typeName}`
29
+
30
+ /**
31
+ * Returns the TypeScript typeof string for a JSON Schema primitive type.
32
+ */
33
+ const typeofString = (type: string): string => {
34
+ if (type === 'integer') return 'number'
35
+ return type
36
+ }
37
+
38
+ /**
39
+ * Generates the inline condition that is TRUE when a value is the wrong type.
40
+ */
41
+ const wrongTypeCondition = (accessor: string, type: string): string => {
42
+ switch (type) {
43
+ case 'string':
44
+ return `typeof ${accessor} !== 'string'`
45
+ case 'number':
46
+ case 'integer':
47
+ return `typeof ${accessor} !== 'number'`
48
+ case 'boolean':
49
+ return `typeof ${accessor} !== 'boolean'`
50
+ case 'array':
51
+ return `!Array.isArray(${accessor})`
52
+ case 'object':
53
+ return `typeof ${accessor} !== 'object' || ${accessor} === null || Array.isArray(${accessor})`
54
+ default:
55
+ return ''
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Generates validation lines for a single property in an object schema.
61
+ * Handles $ref delegation, enum checks, type checks, and string/number constraints.
62
+ */
63
+ const generatePropertyChecks = (key: string, propSchema: JSONSchema, isRequired: boolean): string[] => {
64
+ if (!isSchemaObject(propSchema)) return []
65
+
66
+ const raw = `obj[${JSON.stringify(key)}]`
67
+ const path = `\`\${_path}/${key}\``
68
+ const lines: string[] = []
69
+
70
+ // $ref — delegate to the imported validator
71
+ if (hasRef(propSchema)) {
72
+ const ref = propSchema.$ref
73
+ const vName = validatorName(refToName(ref))
74
+
75
+ if (isRequired) {
76
+ lines.push(` if (!(${JSON.stringify(key)} in obj)) {`)
77
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`)
78
+ lines.push(` } else {`)
79
+ lines.push(` const _r = ${vName}(${raw}, ${path})`)
80
+ lines.push(` if (_r !== true) errors.push(..._r.errors)`)
81
+ lines.push(` }`)
82
+ } else {
83
+ lines.push(` if (${raw} !== undefined) {`)
84
+ lines.push(` const _r = ${vName}(${raw}, ${path})`)
85
+ lines.push(` if (_r !== true) errors.push(..._r.errors)`)
86
+ lines.push(` }`)
87
+ }
88
+ return lines
89
+ }
90
+
91
+ // enum
92
+ if (hasEnum(propSchema)) {
93
+ const allowed = JSON.stringify(propSchema.enum)
94
+ const label = (propSchema.enum as unknown[]).map((v) => JSON.stringify(v)).join(', ')
95
+
96
+ if (isRequired) {
97
+ lines.push(` if (!(${JSON.stringify(key)} in obj)) {`)
98
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`)
99
+ lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`)
100
+ lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`)
101
+ lines.push(` }`)
102
+ } else {
103
+ lines.push(` if (${raw} !== undefined && !(${allowed} as unknown[]).includes(${raw})) {`)
104
+ lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`)
105
+ lines.push(` }`)
106
+ }
107
+ return lines
108
+ }
109
+
110
+ // typed property
111
+ if (hasType(propSchema)) {
112
+ const t = propSchema.type as string
113
+ const wrongType = wrongTypeCondition(raw, t)
114
+ const typLabel = typeofString(t)
115
+
116
+ if (isRequired) {
117
+ lines.push(` if (!(${JSON.stringify(key)} in obj)) {`)
118
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`)
119
+ if (wrongType) {
120
+ lines.push(` } else if (${wrongType}) {`)
121
+ lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`)
122
+ }
123
+ lines.push(` }`)
124
+ } else if (wrongType) {
125
+ lines.push(` if (${raw} !== undefined && (${wrongType})) {`)
126
+ lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`)
127
+ lines.push(` }`)
128
+ }
129
+
130
+ // String constraints
131
+ if (t === 'string') {
132
+ if (hasPattern(propSchema)) {
133
+ lines.push(` if (typeof ${raw} === 'string' && !/${propSchema.pattern}/.test(${raw})) {`)
134
+ lines.push(` errors.push({ message: 'must match pattern ${propSchema.pattern}', path: ${path} })`)
135
+ lines.push(` }`)
136
+ }
137
+ if (hasMinLength(propSchema)) {
138
+ lines.push(` if (typeof ${raw} === 'string' && ${raw}.length < ${propSchema.minLength}) {`)
139
+ lines.push(
140
+ ` errors.push({ message: 'must have at least ${propSchema.minLength} characters', path: ${path} })`,
141
+ )
142
+ lines.push(` }`)
143
+ }
144
+ if (hasMaxLength(propSchema)) {
145
+ lines.push(` if (typeof ${raw} === 'string' && ${raw}.length > ${propSchema.maxLength}) {`)
146
+ lines.push(
147
+ ` errors.push({ message: 'must have at most ${propSchema.maxLength} characters', path: ${path} })`,
148
+ )
149
+ lines.push(` }`)
150
+ }
151
+ }
152
+
153
+ // Number constraints
154
+ if (t === 'number' || t === 'integer') {
155
+ if (hasMinimum(propSchema)) {
156
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} < ${propSchema.minimum}) {`)
157
+ lines.push(` errors.push({ message: 'must be >= ${propSchema.minimum}', path: ${path} })`)
158
+ lines.push(` }`)
159
+ }
160
+ if (hasMaximum(propSchema)) {
161
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} > ${propSchema.maximum}) {`)
162
+ lines.push(` errors.push({ message: 'must be <= ${propSchema.maximum}', path: ${path} })`)
163
+ lines.push(` }`)
164
+ }
165
+ if (hasExclusiveMinimum(propSchema)) {
166
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} <= ${propSchema.exclusiveMinimum}) {`)
167
+ lines.push(` errors.push({ message: 'must be > ${propSchema.exclusiveMinimum}', path: ${path} })`)
168
+ lines.push(` }`)
169
+ }
170
+ if (hasExclusiveMaximum(propSchema)) {
171
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} >= ${propSchema.exclusiveMaximum}) {`)
172
+ lines.push(` errors.push({ message: 'must be < ${propSchema.exclusiveMaximum}', path: ${path} })`)
173
+ lines.push(` }`)
174
+ }
175
+ if (hasMultipleOf(propSchema)) {
176
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} % ${propSchema.multipleOf} !== 0) {`)
177
+ lines.push(` errors.push({ message: 'must be a multiple of ${propSchema.multipleOf}', path: ${path} })`)
178
+ lines.push(` }`)
179
+ }
180
+ }
181
+
182
+ // Array with typed items
183
+ if (t === 'array' && hasItems(propSchema)) {
184
+ const itemSchema = propSchema.items
185
+ if (hasRef(itemSchema)) {
186
+ const vName = validatorName(refToName(itemSchema.$ref))
187
+ lines.push(` if (Array.isArray(${raw})) {`)
188
+ lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`)
189
+ lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/${key}/\${_i}\`)`)
190
+ lines.push(` if (_ir !== true) errors.push(..._ir.errors)`)
191
+ lines.push(` }`)
192
+ lines.push(` }`)
193
+ } else if (hasType(itemSchema)) {
194
+ const itemType = itemSchema.type as string
195
+ const itemWrong = wrongTypeCondition('_item', itemType)
196
+ const itemLabel = typeofString(itemType)
197
+ if (itemWrong) {
198
+ lines.push(` if (Array.isArray(${raw})) {`)
199
+ lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`)
200
+ lines.push(` const _item = ${raw}[_i]`)
201
+ lines.push(
202
+ ` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/${key}/\${_i}\` })`,
203
+ )
204
+ lines.push(` }`)
205
+ lines.push(` }`)
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ return lines
212
+ }
213
+
214
+ /**
215
+ * Generates a validator function body for an object schema, checking each
216
+ * property's presence and type and collecting all errors.
217
+ */
218
+ const generateObjectValidator = (schema: JSONSchema, typeName: string): string => {
219
+ const vName = validatorName(typeName)
220
+ const required = new Set(hasRequired(schema) ? schema.required : [])
221
+ const properties = hasProperties(schema) ? schema.properties : {}
222
+
223
+ const propertyLines: string[] = []
224
+
225
+ for (const [key, propSchema] of Object.entries(properties)) {
226
+ const checks = generatePropertyChecks(key, propSchema as JSONSchema, required.has(key))
227
+ if (checks.length > 0) {
228
+ propertyLines.push(...checks)
229
+ }
230
+ }
231
+
232
+ // additionalProperties with a $ref schema validates all extra keys
233
+ if (
234
+ hasAdditionalProperties(schema) &&
235
+ isSchemaObject(schema.additionalProperties) &&
236
+ hasRef(schema.additionalProperties)
237
+ ) {
238
+ const vRefName = validatorName(refToName(schema.additionalProperties.$ref))
239
+ propertyLines.push(` for (const _key of Object.keys(obj)) {`)
240
+ propertyLines.push(` if (${JSON.stringify(Object.keys(properties))}.includes(_key)) continue`)
241
+ propertyLines.push(` const _r = ${vRefName}(obj[_key as keyof typeof obj], \`\${_path}/\${_key}\`)`)
242
+ propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`)
243
+ propertyLines.push(` }`)
244
+ }
245
+
246
+ const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : ''
247
+
248
+ return [
249
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
250
+ ` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
251
+ ` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
252
+ ` }`,
253
+ ``,
254
+ ` const errors: ValidationError[] = []`,
255
+ ` const obj = input as Record<string, unknown>`,
256
+ body,
257
+ ` return errors.length > 0 ? { valid: false, errors } : true`,
258
+ `}`,
259
+ ].join('\n')
260
+ }
261
+
262
+ /**
263
+ * Generates a validator function for a non-object schema (primitive, array, enum, $ref).
264
+ */
265
+ const generateScalarValidator = (schema: JSONSchema, typeName: string): string => {
266
+ const vName = validatorName(typeName)
267
+
268
+ if (!isSchemaObject(schema)) {
269
+ return [`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`, ` return true`, `}`].join(
270
+ '\n',
271
+ )
272
+ }
273
+
274
+ // Top-level $ref — delegate entirely
275
+ if (hasRef(schema)) {
276
+ const delegateName = validatorName(refToName(schema.$ref))
277
+ return [
278
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
279
+ ` return ${delegateName}(input, _path)`,
280
+ `}`,
281
+ ].join('\n')
282
+ }
283
+
284
+ // Top-level enum
285
+ if (hasEnum(schema)) {
286
+ const allowed = JSON.stringify(schema.enum)
287
+ const label = (schema.enum as unknown[]).map((v) => JSON.stringify(v)).join(', ')
288
+ return [
289
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
290
+ ` if (!(${allowed} as unknown[]).includes(input)) {`,
291
+ ` return { valid: false, errors: [{ message: \`must be one of: ${label}\`, path: _path }] }`,
292
+ ` }`,
293
+ ` return true`,
294
+ `}`,
295
+ ].join('\n')
296
+ }
297
+
298
+ // oneOf — try each branch, return errors from all if none match
299
+ if (hasOneOf(schema)) {
300
+ const branches = schema.oneOf
301
+ .map((branch, i) => {
302
+ if (!hasRef(branch)) return null
303
+ const bName = validatorName(refToName((branch as { $ref: string }).$ref))
304
+ return ` const _r${i} = ${bName}(input, _path)\n if (_r${i} === true) return true`
305
+ })
306
+ .filter(Boolean)
307
+ .join('\n')
308
+
309
+ return [
310
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
311
+ branches,
312
+ ` return { valid: false, errors: [{ message: 'must match one of the expected schemas', path: _path }] }`,
313
+ `}`,
314
+ ].join('\n')
315
+ }
316
+
317
+ // Top-level typed schema (string, number, boolean, array)
318
+ if (hasType(schema)) {
319
+ const t = schema.type as string
320
+ const wrongType = wrongTypeCondition('input', t)
321
+ const typLabel = typeofString(t)
322
+
323
+ const constraintLines: string[] = []
324
+
325
+ if (t === 'string') {
326
+ if (hasPattern(schema)) {
327
+ constraintLines.push(` if (typeof input === 'string' && !/${schema.pattern}/.test(input)) {`)
328
+ constraintLines.push(
329
+ ` return { valid: false, errors: [{ message: 'must match pattern ${schema.pattern}', path: _path }] }`,
330
+ )
331
+ constraintLines.push(` }`)
332
+ }
333
+ if (hasMinLength(schema)) {
334
+ constraintLines.push(` if (typeof input === 'string' && input.length < ${schema.minLength}) {`)
335
+ constraintLines.push(
336
+ ` return { valid: false, errors: [{ message: 'must have at least ${schema.minLength} characters', path: _path }] }`,
337
+ )
338
+ constraintLines.push(` }`)
339
+ }
340
+ if (hasMaxLength(schema)) {
341
+ constraintLines.push(` if (typeof input === 'string' && input.length > ${schema.maxLength}) {`)
342
+ constraintLines.push(
343
+ ` return { valid: false, errors: [{ message: 'must have at most ${schema.maxLength} characters', path: _path }] }`,
344
+ )
345
+ constraintLines.push(` }`)
346
+ }
347
+ }
348
+
349
+ const body = constraintLines.length > 0 ? '\n' + constraintLines.join('\n') + '\n' : ''
350
+
351
+ return wrongType
352
+ ? [
353
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
354
+ ` if (${wrongType}) {`,
355
+ ` return { valid: false, errors: [{ message: 'must be ${typLabel}', path: _path }] }`,
356
+ ` }`,
357
+ body,
358
+ ` return true`,
359
+ `}`,
360
+ ].join('\n')
361
+ : [`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`, ` return true`, `}`].join(
362
+ '\n',
363
+ )
364
+ }
365
+
366
+ return [`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`, ` return true`, `}`].join(
367
+ '\n',
368
+ )
369
+ }
370
+
371
+ /**
372
+ * Generates a TypeScript validator function from a JSON Schema.
373
+ *
374
+ * The generated function accepts `unknown` input and returns `true` if valid,
375
+ * or `{ valid: false, errors }` with a list of errors if not.
376
+ *
377
+ * Object schemas check that required properties are present and that all
378
+ * provided properties match their declared types. Non-object schemas (strings,
379
+ * numbers, enums, $refs) emit an inline type check.
380
+ *
381
+ * @example
382
+ * ```typescript
383
+ * generateValidatorFunction({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, 'Info')
384
+ * // export const validateInfo = (input: unknown, _path = ''): ValidationResult => {
385
+ * // if (typeof input !== 'object' || ...) return { valid: false, ... }
386
+ * // const errors: ValidationError[] = []
387
+ * // const obj = input as Record<string, unknown>
388
+ * // if (!('name' in obj)) { errors.push(...) } else if (typeof obj['name'] !== 'string') { errors.push(...) }
389
+ * // return errors.length > 0 ? { valid: false, errors } : true
390
+ * // }
391
+ * ```
392
+ */
393
+ export const generateValidatorFunction = (schema: JSONSchema, typeName: string): string => {
394
+ if (isObjectSchema(schema)) {
395
+ return generateObjectValidator(schema, typeName)
396
+ }
397
+
398
+ return generateScalarValidator(schema, typeName)
399
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { GeneratedFile } from './generators/build-schema'
2
+ export { buildValidatorSchema } from './generators/build-schema'