@amritk/generate-validators 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -24,8 +24,7 @@
24
24
  "url": "https://github.com/amritk/mjst/issues"
25
25
  },
26
26
  "files": [
27
- "dist",
28
- "src"
27
+ "dist"
29
28
  ],
30
29
  "publishConfig": {
31
30
  "access": "public"
@@ -33,23 +32,27 @@
33
32
  "scripts": {
34
33
  "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
35
34
  "types:check": "tsgo -p . --noEmit",
36
- "test": "NODE_ENV=production vitest run --root ../.. generate-validators"
35
+ "test": "NODE_ENV=production vitest run --root ../.. generate-validators",
36
+ "bench": "bun run ./bench/run.ts"
37
37
  },
38
38
  "imports": {
39
39
  "#generators/*": "./src/generators/*.ts"
40
40
  },
41
41
  "exports": {
42
42
  ".": {
43
- "development": "./src/index.ts",
44
43
  "default": "./dist/index.js",
45
44
  "types": "./dist/index.d.ts"
46
45
  }
47
46
  },
48
47
  "dependencies": {
49
48
  "json-schema-typed": "^8.0.1",
50
- "@amritk/helpers": "0.8.0"
49
+ "@amritk/helpers": "0.10.0"
51
50
  },
52
51
  "devDependencies": {
53
- "@scalar/openapi-parser": "^0.26.1"
52
+ "@scalar/openapi-parser": "^0.26.1",
53
+ "@sinclair/typebox": "^0.34.49",
54
+ "ajv": "^8.17.1",
55
+ "ajv-formats": "^3.0.1",
56
+ "zod": "^4.4.3"
54
57
  }
55
58
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../../src/generators/build-schema.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAIjE;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAmBD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,eACnB,UAAU,gBACR,MAAM,0BAEnB,OAAO,CAAC,aAAa,EAAE,CAuBzB,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"collect-validator-imports.d.ts","sourceRoot":"","sources":["../../src/generators/collect-validator-imports.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE;;GAEG;AACH,KAAK,8BAA8B,GAAG;IACpC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACrC;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAA;IACzD;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAoED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,WAAY,UAAU,YAAY,8BAA8B,KAAG,MAAM,EA6B5G,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"generate-files.d.ts","sourceRoot":"","sources":["../../src/generators/generate-files.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAKjE;;GAEG;AACH,KAAK,4BAA4B,GAAG;IAClC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB;;OAEG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,qBAAqB,WACxB,UAAU,YACR,MAAM,YACN,4BAA4B,KACrC,MA0BF,CAAA"}
@@ -1 +0,0 @@
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 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAC9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA"}
@@ -1,163 +0,0 @@
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('validateInfo')
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('validateInfo')
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
- })
@@ -1,81 +0,0 @@
1
- import { generateIndexBarrel } from '@amritk/helpers/generate-index-barrel'
2
- import { walkRefGraph } from '@amritk/helpers/walk-ref-graph'
3
- import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
4
-
5
- import { generateValidatorFile } from './generate-files'
6
-
7
- /**
8
- * Represents a generated TypeScript file with its filename and content.
9
- */
10
- export type GeneratedFile = {
11
- filename: string
12
- content: string
13
- }
14
-
15
- const VALIDATION_RESULT_CONTENT = `/**
16
- * A single validation error with a human-readable message and a JSON Pointer
17
- * path indicating where in the document the error occurred.
18
- */
19
- export type ValidationError = {
20
- message: string
21
- path: string
22
- }
23
-
24
- /**
25
- * The result of a generated validator function.
26
- * Returns \`true\` when the input is valid, or an object with \`valid: false\`
27
- * and a list of errors when it is not.
28
- */
29
- export type ValidationResult = true | { valid: false; errors: ValidationError[] }
30
- `
31
-
32
- /**
33
- * Builds all TypeScript validator files from a JSON Schema by traversing all
34
- * `$ref` / `$dynamicRef` references recursively (via the shared
35
- * `@amritk/helpers/walk-ref-graph` walker).
36
- *
37
- * Each generated file exports:
38
- * - A TypeScript type definition
39
- * - A `validateFoo(input: unknown, _path?: string): ValidationResult` function
40
- *
41
- * A `validation-result.ts` file containing the `ValidationResult` and `ValidationError`
42
- * runtime contract is always emitted. An `index.ts` re-exports everything.
43
- *
44
- * @param rootSchema - The root JSON Schema to build from
45
- * @param rootTypeName - The name for the root type (e.g. "Document")
46
- * @returns An array of generated TypeScript files
47
- *
48
- * @example
49
- * ```typescript
50
- * const files = await buildValidatorSchema(schema, 'Document')
51
- * // files → [{ filename: 'document.ts', content: '...' }, { filename: 'info.ts', ... }, ...]
52
- * ```
53
- */
54
- export const buildValidatorSchema = async (
55
- rootSchema: JSONSchema,
56
- rootTypeName: string,
57
- typeSuffix = '',
58
- ): Promise<GeneratedFile[]> => {
59
- const files: GeneratedFile[] = []
60
-
61
- walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
62
- // `validation-result` and `index` are reserved output filenames, so never
63
- // let a definition of either name overwrite them.
64
- if (node.filename === 'validation-result' || node.filename === 'index') return
65
-
66
- const content = generateValidatorFile(node.schema, node.typeName, {
67
- rootSchema: node.rootSchema,
68
- typeSuffix,
69
- ...(node.ref !== undefined ? { selfRef: node.ref } : {}),
70
- })
71
- files.push({ filename: `${node.filename}.ts`, content })
72
- })
73
-
74
- // Emit the runtime contract for validators. ValidationResult is mjst-defined
75
- // (not derived from the input schema), so its content is fixed.
76
- files.push({ filename: 'validation-result.ts', content: VALIDATION_RESULT_CONTENT })
77
-
78
- files.push({ filename: 'index.ts', content: generateIndexBarrel(files) })
79
-
80
- return files
81
- }
@@ -1,134 +0,0 @@
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
- * Suffix appended to every type/validator name derived from a `$ref`. Must
23
- * match the suffix used when generating the referenced files. Defaults to `''`.
24
- */
25
- readonly typeSuffix?: string
26
- }
27
-
28
- /**
29
- * Generates an import statement for a single $ref, importing both the type
30
- * and the validator function from the ref's generated file.
31
- */
32
- const buildImport = (ref: string, suffix: string): string => {
33
- const filename = refToFilename(ref)
34
- const typeName = refToName(ref, suffix)
35
- const validatorName = `validate${typeName}`
36
- return `import { type ${typeName}, ${validatorName} } from './${filename}'`
37
- }
38
-
39
- /**
40
- * Resolves the canonical filename for a ref, stripping `-or-reference` suffixes
41
- * so that `#/$defs/parameter-or-reference` maps to `parameter`.
42
- */
43
- const canonicalFilename = (ref: string): string => {
44
- const base = ref.endsWith('-or-reference') ? ref.replace('-or-reference', '') : ref
45
- return refToFilename(base)
46
- }
47
-
48
- /**
49
- * Walks one level of the schema and yields all direct $ref strings that should
50
- * become imports: properties, additionalProperties, items, and union branches.
51
- */
52
- const collectDirectRefs = (schema: JSONSchema): string[] => {
53
- if (typeof schema === 'boolean' || schema === null) return []
54
-
55
- const refs: string[] = []
56
-
57
- if (hasRef(schema)) {
58
- refs.push(schema.$ref)
59
- return refs
60
- }
61
-
62
- const propSchemas =
63
- 'properties' in schema && typeof schema.properties === 'object' && schema.properties !== null
64
- ? Object.values(schema.properties as Record<string, JSONSchema>)
65
- : []
66
-
67
- for (const prop of propSchemas) {
68
- if (hasRef(prop)) refs.push((prop as { $ref: string }).$ref)
69
- if (hasItems(prop) && hasRef(prop.items)) refs.push((prop.items as { $ref: string }).$ref)
70
- if (hasAdditionalProperties(prop) && hasRef(prop.additionalProperties as JSONSchema)) {
71
- refs.push((prop.additionalProperties as { $ref: string }).$ref)
72
- }
73
- }
74
-
75
- if (hasItems(schema) && hasRef(schema.items)) {
76
- refs.push((schema.items as { $ref: string }).$ref)
77
- }
78
-
79
- if (hasAdditionalProperties(schema) && hasRef(schema.additionalProperties as JSONSchema)) {
80
- refs.push((schema.additionalProperties as { $ref: string }).$ref)
81
- }
82
-
83
- for (const branch of [
84
- ...(hasOneOf(schema) ? schema.oneOf : []),
85
- ...(hasAnyOf(schema) ? schema.anyOf : []),
86
- ...(hasAllOf(schema) ? schema.allOf : []),
87
- ]) {
88
- if (hasRef(branch)) refs.push((branch as { $ref: string }).$ref)
89
- }
90
-
91
- return refs
92
- }
93
-
94
- /**
95
- * Collects import statements for all $ref dependencies of a schema.
96
- * Each import brings in both the generated TypeScript type and validator function.
97
- *
98
- * @example
99
- * ```typescript
100
- * const schema = { properties: { contact: { $ref: '#/$defs/contact' } } }
101
- * collectValidatorImports(schema)
102
- * // ["import { type Contact, validateContact } from './contact'"]
103
- * ```
104
- */
105
- export const collectValidatorImports = (schema: JSONSchema, options?: CollectValidatorImportsOptions): string[] => {
106
- const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : null
107
- const rootSchema = options?.rootSchema
108
- const typeSuffix = options?.typeSuffix ?? ''
109
-
110
- const refs = collectDirectRefs(schema)
111
- const seen = new Set<string>()
112
- const imports: string[] = []
113
-
114
- for (const ref of refs) {
115
- const filename = canonicalFilename(ref)
116
-
117
- if (seen.has(filename)) continue
118
- if (selfFilename && filename === selfFilename) continue
119
-
120
- // Skip refs that don't resolve in this schema (external / never generated)
121
- if (rootSchema) {
122
- const resolved = resolveRef(ref, rootSchema)
123
- if (!resolved) continue
124
- }
125
-
126
- seen.add(filename)
127
-
128
- // -or-reference unions import the base type's validator
129
- const importRef = ref.endsWith('-or-reference') ? ref.replace('-or-reference', '') : ref
130
- imports.push(buildImport(importRef, typeSuffix))
131
- }
132
-
133
- return imports
134
- }
@@ -1,79 +0,0 @@
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
- * Suffix appended to every type/validator name derived from a `$ref`.
22
- * Defaults to `''` (no suffix).
23
- */
24
- readonly typeSuffix?: string
25
- }
26
-
27
- /**
28
- * Generates a complete TypeScript validator file from a JSON Schema.
29
- *
30
- * The file contains:
31
- * - Imports for the ValidationResult/ValidationError types
32
- * - Imports for any $ref types and their validator functions
33
- * - The exported TypeScript type definition
34
- * - The exported validator function
35
- *
36
- * @example
37
- * ```typescript
38
- * const schema = {
39
- * type: 'object',
40
- * properties: { title: { type: 'string' } },
41
- * required: ['title'],
42
- * }
43
- * generateValidatorFile(schema, 'Info')
44
- * // import type { ValidationResult, ValidationError } from './validation-result'
45
- * // export type Info = { title: string }
46
- * // export const validateInfo = (input: unknown, _path = ''): ValidationResult => { ... }
47
- * ```
48
- */
49
- export const generateValidatorFile = (
50
- schema: JSONSchema,
51
- typeName: string,
52
- options?: GenerateValidatorFileOptions,
53
- ): string => {
54
- const typeSuffix = options?.typeSuffix ?? ''
55
- const refImports = collectValidatorImports(schema, {
56
- selfRef: options?.selfRef,
57
- rootSchema: options?.rootSchema,
58
- typeSuffix,
59
- })
60
-
61
- const typeDefinition = generateTypeDefinition(schema, typeName, { typeSuffix })
62
- const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix)
63
-
64
- let result = `import type { ValidationResult, ValidationError } from './validation-result'\n`
65
-
66
- for (const imp of refImports) {
67
- result += imp + '\n'
68
- }
69
-
70
- if (refImports.length > 0) {
71
- result += '\n'
72
- } else {
73
- result += '\n'
74
- }
75
-
76
- result += typeDefinition + '\n\n' + validatorFunction
77
-
78
- return result
79
- }