schemastore-to-typescript 0.2.12
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.json +529 -0
- package/CHANGELOG.md +185 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +173 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +13 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +181 -0
- package/dist/normalize.js.map +1 -0
- package/license +10 -0
- package/package.json +80 -0
- package/readme.md +52 -0
- package/src/cli.ts +43 -0
- package/src/index.ts +256 -0
- package/src/normalize.ts +274 -0
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "schemastore-to-typescript",
|
|
3
|
+
"version": "0.2.12",
|
|
4
|
+
"description": "Fetch JSON schemas from the JSON Schema Store catalog and compile them to TypeScript typings",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"json-schema",
|
|
8
|
+
"schemastore",
|
|
9
|
+
"typescript"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/langri-sha/schemastore-to-typescript#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/langri-sha/schemastore-to-typescript/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/langri-sha/schemastore-to-typescript.git"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": {
|
|
21
|
+
"name": "Filip Dupanović",
|
|
22
|
+
"email": "filip.dupanovic@gmail.com",
|
|
23
|
+
"url": "https://langri-sha.com",
|
|
24
|
+
"organization": false
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "dist/index.js",
|
|
28
|
+
"bin": {
|
|
29
|
+
"schemastore-to-typescript": "dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"change": "pnpm exec projen change",
|
|
33
|
+
"default": "pnpm exec projen default",
|
|
34
|
+
"prepare": "husky",
|
|
35
|
+
"prepublishOnly": "rm -rf dist; tsc --project tsconfig.build.json",
|
|
36
|
+
"renovate": "pnpm exec projen renovate",
|
|
37
|
+
"test": "vitest"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"commander": "15.0.0",
|
|
41
|
+
"debug": "4.4.3",
|
|
42
|
+
"env-paths": "4.0.0",
|
|
43
|
+
"es-main": "1.4.0",
|
|
44
|
+
"got": "16.0.0",
|
|
45
|
+
"json-schema-to-typescript": "16.0.0",
|
|
46
|
+
"keyv": "5.6.0",
|
|
47
|
+
"keyv-file": "5.3.5"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@langri-sha/eslint-config": "0.9.15",
|
|
51
|
+
"@langri-sha/lint-staged": "0.9.7",
|
|
52
|
+
"@langri-sha/prettier": "0.4.7",
|
|
53
|
+
"@langri-sha/projen-project": "0.31.0",
|
|
54
|
+
"@langri-sha/tsconfig": "1.0.1",
|
|
55
|
+
"@langri-sha/vitest": "0.2.0",
|
|
56
|
+
"@types/debug": "4.1.13",
|
|
57
|
+
"@types/node": "24.13.6",
|
|
58
|
+
"beachball": "2.66.0",
|
|
59
|
+
"eslint": "10.11.0",
|
|
60
|
+
"husky": "9.1.7",
|
|
61
|
+
"lint-staged": "17.5.1",
|
|
62
|
+
"prettier": "3.9.8",
|
|
63
|
+
"projen": "0.86.5",
|
|
64
|
+
"tsx": "4.23.13",
|
|
65
|
+
"typescript": "5.9.3",
|
|
66
|
+
"vitest": "5.0.1"
|
|
67
|
+
},
|
|
68
|
+
"packageManager": "pnpm@12.5.1",
|
|
69
|
+
"devEngines": {
|
|
70
|
+
"runtime": {
|
|
71
|
+
"name": "node",
|
|
72
|
+
"version": ">= 24.16.0"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"publishConfig": {
|
|
76
|
+
"access": "public"
|
|
77
|
+
},
|
|
78
|
+
"//": "~~ Generated by projen. To modify, edit .projenrc.ts and run \"pnpm exec projen\".",
|
|
79
|
+
"types": "dist/index.d.ts"
|
|
80
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# schemastore-to-typescript
|
|
2
|
+
|
|
3
|
+
Fetch [JSON Schema] from the [JSON Schema Store] catalog and compile [TypeScript
|
|
4
|
+
typings].
|
|
5
|
+
|
|
6
|
+
The tool first fetches the schema catalog from the JSON Schema Store API,
|
|
7
|
+
searches for the requested schema by name (case-insensitive), and then downloads
|
|
8
|
+
and compiles the schema to TypeScript definitions.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- uses [`json-schema-to-typescript`] for TypeScript generation
|
|
13
|
+
- fetches schemas from the [JSON Schema Store catalog API]
|
|
14
|
+
- case-insensitive schema name matching
|
|
15
|
+
- offline cache using [`got`] for both catalog and schema requests
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install -D schemastore-to-typescript
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Name a schema as it appears in the catalog, and optionally where to write its
|
|
24
|
+
typings — `<schema>.d.ts` in the working directory by default:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
schemastore-to-typescript renovate src/renovate.ts
|
|
28
|
+
schemastore-to-typescript 'cargo manifest' src/cargo.ts
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requests are cached in the user cache directory, such as
|
|
32
|
+
`~/.cache/schemastore-to-typescript-nodejs` on Linux. Pass `--no-cache` to skip
|
|
33
|
+
it.
|
|
34
|
+
|
|
35
|
+
The same is available as a function, which resolves to the compiled module and
|
|
36
|
+
takes `false` as its second argument to skip the cache:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { compile } from 'schemastore-to-typescript'
|
|
40
|
+
|
|
41
|
+
const typings = await compile('swcrc')
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
[`got`]: https://www.npmjs.com/package/got
|
|
45
|
+
[`json-schema-to-typescript`]:
|
|
46
|
+
https://www.npmjs.com/package/json-schema-to-typescript
|
|
47
|
+
[json schema store]: https://www.schemastore.org/
|
|
48
|
+
[json schema store catalog api]:
|
|
49
|
+
https://www.schemastore.org/api/json/catalog.json
|
|
50
|
+
[json schema]: https://json-schema.org/
|
|
51
|
+
[typescript typings]:
|
|
52
|
+
https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-d-ts.html
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'node:fs/promises'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { Command } from 'commander'
|
|
6
|
+
import createDebug from 'debug'
|
|
7
|
+
import esMain from 'es-main'
|
|
8
|
+
|
|
9
|
+
import { compile } from './index.js'
|
|
10
|
+
|
|
11
|
+
const debug = createDebug('schema-store-to-typescript')
|
|
12
|
+
|
|
13
|
+
export const program: Command = new Command()
|
|
14
|
+
.name('schemastore-to-typescript')
|
|
15
|
+
.description('Compile a JSON schema store schema to TypeScript definitions')
|
|
16
|
+
.argument('<schema>', 'JSON schema store schema')
|
|
17
|
+
.argument('[output]', 'Path to output the TypeScript definition')
|
|
18
|
+
.option('-n, --no-cache', 'Skip caching requests')
|
|
19
|
+
.showHelpAfterError()
|
|
20
|
+
.action(async (schema, output, { cache }) => {
|
|
21
|
+
output ??= `${schema}.d.ts`
|
|
22
|
+
|
|
23
|
+
const compiled = await compile(schema, cache)
|
|
24
|
+
await fs.writeFile(
|
|
25
|
+
// Using `process.cwd()` as escape hatch for `process.chdir()` not being
|
|
26
|
+
// available in workers. See: https://github.com/vitest-dev/vitest/issues/1436.
|
|
27
|
+
path.resolve(process.cwd(), output),
|
|
28
|
+
compiled,
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if (esMain(import.meta)) {
|
|
33
|
+
debug('Parsing arguments', process.argv)
|
|
34
|
+
program
|
|
35
|
+
.parseAsync(process.argv)
|
|
36
|
+
.then(() => {
|
|
37
|
+
process.exit(0)
|
|
38
|
+
})
|
|
39
|
+
.catch((error) => {
|
|
40
|
+
console.error(error)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
})
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import createDebug from 'debug'
|
|
4
|
+
import envPaths from 'env-paths'
|
|
5
|
+
import got, { HTTPError } from 'got'
|
|
6
|
+
import {
|
|
7
|
+
type JSONSchema,
|
|
8
|
+
compile as compileSource,
|
|
9
|
+
} from 'json-schema-to-typescript'
|
|
10
|
+
import Keyv from 'keyv'
|
|
11
|
+
import { KeyvFile } from 'keyv-file'
|
|
12
|
+
|
|
13
|
+
import { normalizeSchema } from './normalize.js'
|
|
14
|
+
|
|
15
|
+
const debug = createDebug('schema-store-to-typescript')
|
|
16
|
+
const paths = envPaths('schemastore-to-typescript')
|
|
17
|
+
|
|
18
|
+
const store = new KeyvFile({
|
|
19
|
+
filename: path.join(paths.cache, 'requests.json'),
|
|
20
|
+
writeDelay: 0,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const keyv = new Keyv({ store })
|
|
24
|
+
|
|
25
|
+
// Both are declared private in keyv-file's typings, but flushing the cache
|
|
26
|
+
// needs them — see `flushCache`.
|
|
27
|
+
const flushable = store as unknown as {
|
|
28
|
+
save(): Promise<unknown>
|
|
29
|
+
saveToDisk(): Promise<unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `keyv-file@5.3.5` coalesces a `set` onto an in-flight save: `saveToDisk`
|
|
34
|
+
* snapshots the store synchronously at entry, then awaits `mkdir` and
|
|
35
|
+
* `writeFile`, and only clears the in-flight promise afterwards. A `set`
|
|
36
|
+
* landing in that window gets that promise back — it resolves, but the value
|
|
37
|
+
* was never in the snapshot and no further save is scheduled, so it stays in
|
|
38
|
+
* memory for the rest of the process.
|
|
39
|
+
*
|
|
40
|
+
* `compile` writes the catalog and then the schema straight into that window,
|
|
41
|
+
* which loses the schema. `writeDelay` is no help: the window is the write
|
|
42
|
+
* itself, not the timer.
|
|
43
|
+
*
|
|
44
|
+
* So settle whatever is in flight first — its `writeFile` would otherwise race
|
|
45
|
+
* ours and could land last with the older snapshot — and then write the
|
|
46
|
+
* current one unconditionally.
|
|
47
|
+
*/
|
|
48
|
+
const flushCache = async () => {
|
|
49
|
+
await flushable.save()
|
|
50
|
+
await flushable.saveToDisk()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface CatalogSchema {
|
|
54
|
+
name: string
|
|
55
|
+
description: string
|
|
56
|
+
fileMatch?: string[]
|
|
57
|
+
url: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Catalog {
|
|
61
|
+
$schema: string
|
|
62
|
+
version: number
|
|
63
|
+
schemas: CatalogSchema[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Holds literal instance data rather than nested schemas, so it must not be
|
|
67
|
+
// walked as if it were part of the schema shape.
|
|
68
|
+
const nonSchemaKeys = new Set(['default', 'examples', 'const', 'enum'])
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Some published schemas (e.g. Renovate's `registryAliases`) combine a
|
|
72
|
+
* `$ref` with a concrete `additionalProperties` schema on the same node — a
|
|
73
|
+
* JSON Schema anti-pattern that `json-schema-to-typescript` compiles into an
|
|
74
|
+
* interface whose own properties conflict with its index signature,
|
|
75
|
+
* producing uncompilable TypeScript (TS2411). `additionalProperties` is the
|
|
76
|
+
* more specific, deliberate declaration, so drop the `$ref` in favor of it.
|
|
77
|
+
*
|
|
78
|
+
* A node declaring `additionalProperties` at all compiles to an interface
|
|
79
|
+
* carrying a string index signature, and an optional member cannot sit beside
|
|
80
|
+
* one: its `undefined` is not assignable to the index type, which is TS2411
|
|
81
|
+
* again (Cargo's `[features]`, `[patch]` and `[lints]`). Where the index type
|
|
82
|
+
* is `unknown` the compiler does allow it, but the named members are then
|
|
83
|
+
* advisory — the same index signature accepts them either way, and following
|
|
84
|
+
* one is how Cargo's `package.metadata` reaches `quikrun`, a mutually
|
|
85
|
+
* recursive schema that overflows the compiler's stack and takes the whole
|
|
86
|
+
* manifest down with it.
|
|
87
|
+
*
|
|
88
|
+
* So optional members are dropped in favour of `additionalProperties`, which
|
|
89
|
+
* is the general contract they were there to narrow. Required ones stay: they
|
|
90
|
+
* are a promise the type has to keep.
|
|
91
|
+
*/
|
|
92
|
+
const sanitizeSchema = <T>(node: T): T => {
|
|
93
|
+
if (Array.isArray(node)) {
|
|
94
|
+
return node.map(sanitizeSchema) as unknown as T
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (node === null || typeof node !== 'object') {
|
|
98
|
+
return node
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const sanitized: Record<string, unknown> = {}
|
|
102
|
+
|
|
103
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
104
|
+
sanitized[key] = nonSchemaKeys.has(key) ? value : sanitizeSchema(value)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
typeof sanitized.$ref === 'string' &&
|
|
109
|
+
typeof sanitized.additionalProperties === 'object' &&
|
|
110
|
+
sanitized.additionalProperties !== null
|
|
111
|
+
) {
|
|
112
|
+
delete sanitized.$ref
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
sanitized.additionalProperties !== undefined &&
|
|
117
|
+
sanitized.additionalProperties !== false &&
|
|
118
|
+
typeof sanitized.properties === 'object' &&
|
|
119
|
+
sanitized.properties !== null
|
|
120
|
+
) {
|
|
121
|
+
const required = new Set(
|
|
122
|
+
Array.isArray(sanitized.required) ? sanitized.required : [],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const properties = Object.fromEntries(
|
|
126
|
+
Object.entries(sanitized.properties).filter(([property]) =>
|
|
127
|
+
required.has(property),
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if (Object.keys(properties).length > 0) {
|
|
132
|
+
sanitized.properties = properties
|
|
133
|
+
} else {
|
|
134
|
+
delete sanitized.properties
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return sanitized as T
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const compile = async (
|
|
142
|
+
name: string,
|
|
143
|
+
cache: boolean = true,
|
|
144
|
+
): Promise<string> => {
|
|
145
|
+
const catalogUrl = 'https://www.schemastore.org/api/json/catalog.json'
|
|
146
|
+
|
|
147
|
+
let catalog: Catalog
|
|
148
|
+
let schema: JSONSchema
|
|
149
|
+
|
|
150
|
+
const fetchWithRetry = async <T>(
|
|
151
|
+
url: string,
|
|
152
|
+
retryOnCacheError: boolean = true,
|
|
153
|
+
): Promise<T> => {
|
|
154
|
+
try {
|
|
155
|
+
return await got(url, {
|
|
156
|
+
cache: cache ? keyv : undefined,
|
|
157
|
+
headers: {
|
|
158
|
+
accept: 'application/json',
|
|
159
|
+
},
|
|
160
|
+
decompress: true,
|
|
161
|
+
}).json<T>()
|
|
162
|
+
} catch (e) {
|
|
163
|
+
debug(`Error fetching ${url}:`, e)
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
retryOnCacheError &&
|
|
167
|
+
cache &&
|
|
168
|
+
e instanceof Error &&
|
|
169
|
+
(e.message.includes('Unexpected token') ||
|
|
170
|
+
e.message.includes('is not valid JSON'))
|
|
171
|
+
) {
|
|
172
|
+
debug(`Clearing cache for ${url} and retrying...`)
|
|
173
|
+
try {
|
|
174
|
+
await keyv.delete(`GET:${url}`)
|
|
175
|
+
} catch (clearError) {
|
|
176
|
+
debug(`Failed to clear cache:`, clearError)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return fetchWithRetry<T>(url, false)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw e
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
// First, fetch the catalog
|
|
188
|
+
catalog = await fetchWithRetry<Catalog>(catalogUrl)
|
|
189
|
+
} catch (e) {
|
|
190
|
+
debug(e)
|
|
191
|
+
|
|
192
|
+
if (e instanceof HTTPError) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Couldn't retrieve catalog from ${catalogUrl}. [${e.code}]`,
|
|
195
|
+
{ cause: e },
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw e
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const schemaMap = new Map<string, CatalogSchema>()
|
|
203
|
+
for (const schema of catalog.schemas) {
|
|
204
|
+
schemaMap.set(schema.name.toLowerCase(), schema)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const catalogSchema = schemaMap.get(name.toLowerCase())
|
|
208
|
+
|
|
209
|
+
if (!catalogSchema) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Couldn't find schema ${name} in the catalog. Are you sure it exists?`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// Fetch the actual schema from the found URL
|
|
217
|
+
debug(`Fetching schema from: ${catalogSchema.url}`)
|
|
218
|
+
|
|
219
|
+
schema = await fetchWithRetry<JSONSchema>(catalogSchema.url)
|
|
220
|
+
|
|
221
|
+
if (typeof schema === 'object' && schema !== null) {
|
|
222
|
+
schema.title = catalogSchema.name
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {
|
|
225
|
+
debug(e)
|
|
226
|
+
|
|
227
|
+
if (e instanceof HTTPError) {
|
|
228
|
+
if (e.response.statusCode === 404) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Couldn't find schema ${name} at ${catalogSchema.url}. Are you sure it exists?`,
|
|
231
|
+
{ cause: e },
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Couldn't retrieve schema from ${catalogSchema.url}. [${e.code}] Status: ${e.response.statusCode}`,
|
|
237
|
+
{ cause: e },
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (e instanceof Error && e.message.includes('is not valid JSON')) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Failed to parse JSON from ${catalogSchema.url}. The server might be returning invalid or compressed content. Error: ${e.message}`,
|
|
244
|
+
{ cause: e },
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
throw e
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (cache) {
|
|
252
|
+
await flushCache()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return compileSource(sanitizeSchema(normalizeSchema(schema)), name)
|
|
256
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-to-typescript'
|
|
2
|
+
|
|
3
|
+
type Node = Record<string, unknown>
|
|
4
|
+
|
|
5
|
+
// Maps the JSON pointer of a schema node to the `definitions` name each of its
|
|
6
|
+
// `$defs` members moves to.
|
|
7
|
+
type Renames = Map<string, Map<string, string>>
|
|
8
|
+
|
|
9
|
+
const isObject = (value: unknown): value is Node =>
|
|
10
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
11
|
+
|
|
12
|
+
// Hold literal instance data rather than nested schemas, so they must not be
|
|
13
|
+
// walked as if they were part of the schema shape.
|
|
14
|
+
const literalKeys = new Set(['const', 'default', 'enum', 'examples'])
|
|
15
|
+
|
|
16
|
+
// Keyed by name rather than by keyword: a member of one of these called
|
|
17
|
+
// `$defs` is a subschema that happens to be named `$defs`, not a definition
|
|
18
|
+
// block.
|
|
19
|
+
const namedSchemaKeys = new Set([
|
|
20
|
+
'$defs',
|
|
21
|
+
'definitions',
|
|
22
|
+
'dependencies',
|
|
23
|
+
'dependentSchemas',
|
|
24
|
+
'patternProperties',
|
|
25
|
+
'properties',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
const escapeToken = (name: string) =>
|
|
29
|
+
name.replaceAll('~', '~0').replaceAll('/', '~1')
|
|
30
|
+
|
|
31
|
+
const unescapeToken = (token: string) =>
|
|
32
|
+
token.replaceAll('~1', '/').replaceAll('~0', '~')
|
|
33
|
+
|
|
34
|
+
const join = (path: string, token: string) =>
|
|
35
|
+
path === '' ? token : `${path}/${token}`
|
|
36
|
+
|
|
37
|
+
const resolvePointer = (document: Node, ref: string): unknown => {
|
|
38
|
+
if (ref === '#') {
|
|
39
|
+
return document
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!ref.startsWith('#/')) {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let node: unknown = document
|
|
47
|
+
|
|
48
|
+
for (const token of ref.slice(2).split('/')) {
|
|
49
|
+
if (Array.isArray(node)) {
|
|
50
|
+
node = node[Number(token)]
|
|
51
|
+
} else if (isObject(node)) {
|
|
52
|
+
node = node[unescapeToken(token)]
|
|
53
|
+
} else {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return node
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const collectRenames = (
|
|
62
|
+
node: unknown,
|
|
63
|
+
path: string,
|
|
64
|
+
inSchema: boolean,
|
|
65
|
+
renames: Renames,
|
|
66
|
+
): void => {
|
|
67
|
+
if (Array.isArray(node)) {
|
|
68
|
+
for (const [index, item] of node.entries()) {
|
|
69
|
+
collectRenames(item, join(path, String(index)), inSchema, renames)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!isObject(node)) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!inSchema) {
|
|
80
|
+
for (const [name, value] of Object.entries(node)) {
|
|
81
|
+
collectRenames(value, join(path, escapeToken(name)), true, renames)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isObject(node.$defs)) {
|
|
88
|
+
const taken = new Set(
|
|
89
|
+
isObject(node.definitions) ? Object.keys(node.definitions) : [],
|
|
90
|
+
)
|
|
91
|
+
const mapping = new Map<string, string>()
|
|
92
|
+
|
|
93
|
+
for (const name of Object.keys(node.$defs)) {
|
|
94
|
+
let target = name
|
|
95
|
+
|
|
96
|
+
for (let suffix = 2; taken.has(target); suffix++) {
|
|
97
|
+
target = `${name}_${suffix}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
taken.add(target)
|
|
101
|
+
mapping.set(name, target)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
renames.set(path, mapping)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const [key, value] of Object.entries(node)) {
|
|
108
|
+
if (literalKeys.has(key)) {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
collectRenames(
|
|
113
|
+
value,
|
|
114
|
+
join(path, escapeToken(key)),
|
|
115
|
+
!namedSchemaKeys.has(key),
|
|
116
|
+
renames,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rewriteRef = (ref: string, renames: Renames): string => {
|
|
122
|
+
if (!ref.startsWith('#/')) {
|
|
123
|
+
return ref
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const tokens = ref.slice(2).split('/')
|
|
127
|
+
const rewritten: string[] = []
|
|
128
|
+
let path = ''
|
|
129
|
+
|
|
130
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
131
|
+
const token = tokens[index]
|
|
132
|
+
const name = tokens[index + 1]
|
|
133
|
+
const mapping = token === '$defs' ? renames.get(path) : undefined
|
|
134
|
+
const target =
|
|
135
|
+
mapping && name !== undefined
|
|
136
|
+
? mapping.get(unescapeToken(name))
|
|
137
|
+
: undefined
|
|
138
|
+
|
|
139
|
+
if (target === undefined) {
|
|
140
|
+
rewritten.push(token)
|
|
141
|
+
path = join(path, token)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
rewritten.push('definitions', escapeToken(target))
|
|
146
|
+
path = join(join(path, token), name as string)
|
|
147
|
+
index++
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return `#/${rewritten.join('/')}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const applyRenames = (
|
|
154
|
+
node: unknown,
|
|
155
|
+
path: string,
|
|
156
|
+
inSchema: boolean,
|
|
157
|
+
renames: Renames,
|
|
158
|
+
): unknown => {
|
|
159
|
+
if (Array.isArray(node)) {
|
|
160
|
+
return node.map((item, index) =>
|
|
161
|
+
applyRenames(item, join(path, String(index)), inSchema, renames),
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!isObject(node)) {
|
|
166
|
+
return node
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!inSchema) {
|
|
170
|
+
return Object.fromEntries(
|
|
171
|
+
Object.entries(node).map(([name, value]) => [
|
|
172
|
+
name,
|
|
173
|
+
applyRenames(value, join(path, escapeToken(name)), true, renames),
|
|
174
|
+
]),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const mapping = renames.get(path)
|
|
179
|
+
const renamed: Node = {}
|
|
180
|
+
|
|
181
|
+
for (const [key, value] of Object.entries(node)) {
|
|
182
|
+
if (literalKeys.has(key)) {
|
|
183
|
+
renamed[key] = value
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (key === '$ref' && typeof value === 'string') {
|
|
188
|
+
renamed[key] = rewriteRef(value, renames)
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (key === '$defs' && mapping) {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
renamed[key] = applyRenames(
|
|
197
|
+
value,
|
|
198
|
+
join(path, escapeToken(key)),
|
|
199
|
+
!namedSchemaKeys.has(key),
|
|
200
|
+
renames,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (mapping) {
|
|
205
|
+
const defs = node.$defs as Node
|
|
206
|
+
const definitions: Node = isObject(renamed.definitions)
|
|
207
|
+
? renamed.definitions
|
|
208
|
+
: {}
|
|
209
|
+
|
|
210
|
+
for (const [name, target] of mapping) {
|
|
211
|
+
definitions[target] = applyRenames(
|
|
212
|
+
defs[name],
|
|
213
|
+
join(join(path, '$defs'), escapeToken(name)),
|
|
214
|
+
true,
|
|
215
|
+
renames,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
renamed.definitions = definitions
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return renamed
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A document whose root is `{$ref}` describes the referenced definition, with
|
|
227
|
+
* any sibling keyword layered on top of it — `title` above all, which is what
|
|
228
|
+
* names the exported type. The definition stays where it is, so pointers into
|
|
229
|
+
* it keep resolving; only a copy is hoisted.
|
|
230
|
+
*/
|
|
231
|
+
const inlineRootRef = (document: Node): Node => {
|
|
232
|
+
let root = document
|
|
233
|
+
const seen = new Set<string>()
|
|
234
|
+
|
|
235
|
+
while (typeof root.$ref === 'string' && !seen.has(root.$ref)) {
|
|
236
|
+
const target = resolvePointer(document, root.$ref)
|
|
237
|
+
|
|
238
|
+
if (!isObject(target) || target === root) {
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
seen.add(root.$ref)
|
|
243
|
+
|
|
244
|
+
const siblings = { ...root }
|
|
245
|
+
delete siblings.$ref
|
|
246
|
+
|
|
247
|
+
root = { ...target, ...siblings }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return root
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `json-schema-to-typescript` resolves references against draft-07's
|
|
255
|
+
* vocabulary: it only knows `definitions`, and it expects the root to be a
|
|
256
|
+
* schema rather than a reference to one. Draft 2020-12 documents that spell
|
|
257
|
+
* their definitions `$defs` or that point the root at one therefore fail to
|
|
258
|
+
* resolve, with `Refs should have been resolved by the resolver!`.
|
|
259
|
+
*
|
|
260
|
+
* Restate them in the shape it does resolve, without disturbing documents that
|
|
261
|
+
* are already in it.
|
|
262
|
+
*/
|
|
263
|
+
export const normalizeSchema = (schema: JSONSchema): JSONSchema => {
|
|
264
|
+
if (!isObject(schema)) {
|
|
265
|
+
return schema
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const renames: Renames = new Map()
|
|
269
|
+
collectRenames(schema, '', true, renames)
|
|
270
|
+
|
|
271
|
+
return inlineRootRef(
|
|
272
|
+
applyRenames(schema, '', true, renames) as Node,
|
|
273
|
+
) as JSONSchema
|
|
274
|
+
}
|