@znight/openapi 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +572 -2
- package/dist/build-BTUC96mY.d.mts +174 -0
- package/dist/build-DXunOzlI.mjs +733 -0
- package/dist/build-DXunOzlI.mjs.map +1 -0
- package/dist/compiler.d.mts +16 -0
- package/dist/compiler.mjs +2639 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/emit-child.d.mts +1 -0
- package/dist/emit-child.mjs +124 -0
- package/dist/emit-child.mjs.map +1 -0
- package/dist/emit.d.mts +2 -0
- package/dist/emit.mjs +252 -0
- package/dist/emit.mjs.map +1 -0
- package/dist/index-CqQ6IRnN.d.mts +60 -0
- package/dist/index.d.mts +260 -0
- package/dist/index.mjs +777 -0
- package/dist/index.mjs.map +1 -0
- package/dist/openapi-CQk5dHqc.mjs +39 -0
- package/dist/openapi-CQk5dHqc.mjs.map +1 -0
- package/dist/shared-BZxtyaB0.mjs +147 -0
- package/dist/shared-BZxtyaB0.mjs.map +1 -0
- package/dist/testing.d.mts +39 -0
- package/dist/testing.mjs +155 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-VzBexMKj.d.mts +213 -0
- package/dist/vite.d.mts +24 -0
- package/dist/vite.mjs +64 -0
- package/dist/vite.mjs.map +1 -0
- package/package.json +115 -4
- package/src/build.ts +898 -0
- package/src/compiler/analyze.ts +81 -0
- package/src/compiler/document.ts +612 -0
- package/src/compiler/generate.ts +79 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/model.ts +141 -0
- package/src/compiler/operations.ts +1475 -0
- package/src/compiler/schemas.ts +706 -0
- package/src/compiler/validate.ts +102 -0
- package/src/compiler/values.ts +81 -0
- package/src/converters.ts +137 -0
- package/src/decorators.ts +187 -0
- package/src/emit/child.ts +144 -0
- package/src/emit/index.ts +377 -0
- package/src/emit/protocol.ts +35 -0
- package/src/index.ts +94 -0
- package/src/metadata.ts +112 -0
- package/src/openapi.ts +55 -0
- package/src/plugin.ts +810 -0
- package/src/shared.ts +154 -0
- package/src/testing.ts +224 -0
- package/src/types.ts +240 -0
- package/src/ui.ts +215 -0
- package/src/vite.ts +126 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API reference pages (Scalar by default, Swagger UI as an option, §8.3 of RFC 0002): an HTML page
|
|
3
|
+
* whose inline script is fixed at boot (allowed by hash in its own Content-Security-Policy), with
|
|
4
|
+
* the UI assets served from `node_modules` or from a pinned CDN with Subresource Integrity.
|
|
5
|
+
*/
|
|
6
|
+
import type { JsonValue } from './types.ts'
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { pathToFileURL } from 'node:url'
|
|
13
|
+
|
|
14
|
+
/** The supported UIs. */
|
|
15
|
+
export type UiKind = 'scalar' | 'swagger'
|
|
16
|
+
|
|
17
|
+
/** An asset of a UI package. */
|
|
18
|
+
export interface UiAsset {
|
|
19
|
+
/** Path in the package. */
|
|
20
|
+
readonly file: string
|
|
21
|
+
/** SRI of the pinned CDN copy. */
|
|
22
|
+
readonly integrity: string
|
|
23
|
+
readonly kind: 'script' | 'style'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A UI package pinned by this release (bumped by Renovate; checked by a network test). */
|
|
27
|
+
export interface UiPackage {
|
|
28
|
+
readonly name: string
|
|
29
|
+
readonly version: string
|
|
30
|
+
readonly assets: readonly UiAsset[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** jsDelivr. */
|
|
34
|
+
export const CDN_ORIGIN = 'https://cdn.jsdelivr.net'
|
|
35
|
+
|
|
36
|
+
/** Pinned versions and SRI hashes of the UI assets. */
|
|
37
|
+
export const UI_PACKAGES: Readonly<Record<UiKind, UiPackage>> = Object.freeze({
|
|
38
|
+
scalar: Object.freeze({
|
|
39
|
+
name: '@scalar/api-reference',
|
|
40
|
+
version: '1.75.1',
|
|
41
|
+
assets: Object.freeze([
|
|
42
|
+
{
|
|
43
|
+
file: 'dist/browser/standalone.js',
|
|
44
|
+
integrity: 'sha384-G+2Q2i9l3PPfmYkk0b4BAIUrs7xb/DNaTL1wCYYab4c9dIKlCyiqzC0Qx3eq9Wf6',
|
|
45
|
+
kind: 'script' as const,
|
|
46
|
+
},
|
|
47
|
+
]),
|
|
48
|
+
}),
|
|
49
|
+
swagger: Object.freeze({
|
|
50
|
+
name: 'swagger-ui-dist',
|
|
51
|
+
version: '5.33.1',
|
|
52
|
+
assets: Object.freeze([
|
|
53
|
+
{
|
|
54
|
+
file: 'swagger-ui.css',
|
|
55
|
+
integrity: 'sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW',
|
|
56
|
+
kind: 'style' as const,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
file: 'swagger-ui-bundle.js',
|
|
60
|
+
integrity: 'sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw',
|
|
61
|
+
kind: 'script' as const,
|
|
62
|
+
},
|
|
63
|
+
]),
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** URL of a pinned asset on the CDN. */
|
|
68
|
+
export function cdnUrl(pkg: UiPackage, asset: UiAsset): string {
|
|
69
|
+
return `${CDN_ORIGIN}/npm/${pkg.name}@${pkg.version}/${asset.file}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An installed UI package. */
|
|
73
|
+
export interface LocalPackage {
|
|
74
|
+
readonly dir: string
|
|
75
|
+
readonly version: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Finds an installed UI package: from `@znight/openapi` (it is an optional peer), then from the
|
|
80
|
+
* working directory. `undefined` when it is not installed or misses an asset. The same on Node.js and
|
|
81
|
+
* Bun, whatever the exports map of the package says (`@scalar/api-reference` does not export its
|
|
82
|
+
* `package.json`).
|
|
83
|
+
*/
|
|
84
|
+
export function findLocalPackage(pkg: UiPackage, from: readonly string[] = defaultBases()): LocalPackage | undefined {
|
|
85
|
+
for (const base of from) {
|
|
86
|
+
for (const manifest of manifestCandidates(pkg.name, base)) {
|
|
87
|
+
const found = readLocalPackage(pkg, manifest)
|
|
88
|
+
if (found !== undefined) return found
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Where the manifest of `name` may be, seen from `base`: the `package.json` subpath when it resolves
|
|
96
|
+
* (Bun always resolves it, Node.js only when the exports map exports it), then `<dir>/<name>/package.json`
|
|
97
|
+
* in the `node_modules` directories the resolution searches.
|
|
98
|
+
*/
|
|
99
|
+
function manifestCandidates(name: string, base: string): string[] {
|
|
100
|
+
let require: ReturnType<typeof createRequire>
|
|
101
|
+
try {
|
|
102
|
+
require = createRequire(base)
|
|
103
|
+
} catch {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
const candidates: string[] = []
|
|
107
|
+
try {
|
|
108
|
+
candidates.push(require.resolve(`${name}/package.json`))
|
|
109
|
+
} catch {
|
|
110
|
+
// not installed, or an exports map without "./package.json"
|
|
111
|
+
}
|
|
112
|
+
for (const dir of require.resolve.paths(name) ?? []) candidates.push(join(dir, name, 'package.json'))
|
|
113
|
+
return [...new Set(candidates)]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readLocalPackage(pkg: UiPackage, manifest: string): LocalPackage | undefined {
|
|
117
|
+
try {
|
|
118
|
+
if (!existsSync(manifest)) return undefined
|
|
119
|
+
const { name, version } = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown; version?: unknown }
|
|
120
|
+
if (name !== pkg.name || typeof version !== 'string') return undefined
|
|
121
|
+
const dir = dirname(manifest)
|
|
122
|
+
return pkg.assets.every(asset => existsSync(join(dir, asset.file))) ? { dir, version } : undefined
|
|
123
|
+
} catch {
|
|
124
|
+
// unreadable manifest: try the next candidate
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function defaultBases(): string[] {
|
|
130
|
+
return [import.meta.url, pathToFileURL(join(process.cwd(), 'package.json')).href]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What the page needs. */
|
|
134
|
+
export interface UiPageInput {
|
|
135
|
+
readonly kind: UiKind
|
|
136
|
+
/** URL of the JSON document, prefix included (`/api/openapi.json`). */
|
|
137
|
+
readonly documentUrl: string
|
|
138
|
+
/** Base URL of the local assets (`/api/docs/_assets/scalar@1.75.1`), `undefined` for the CDN. */
|
|
139
|
+
readonly assetsBase: string | undefined
|
|
140
|
+
readonly title: string
|
|
141
|
+
/** User configuration (JSON-serialisable), merged over the defaults. */
|
|
142
|
+
readonly config: Readonly<Record<string, JsonValue>>
|
|
143
|
+
/** Origins the UI may call ("try it"): the absolute servers of the document. */
|
|
144
|
+
readonly connect: readonly string[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A rendered page. */
|
|
148
|
+
export interface UiPage {
|
|
149
|
+
readonly html: string
|
|
150
|
+
readonly csp: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The HTML page of the UI and its Content-Security-Policy. */
|
|
154
|
+
export function renderUiPage(input: UiPageInput): UiPage {
|
|
155
|
+
const pkg = UI_PACKAGES[input.kind]
|
|
156
|
+
const local = input.assetsBase !== undefined
|
|
157
|
+
const tags = pkg.assets.map(asset => {
|
|
158
|
+
const url = local ? `${input.assetsBase}/${asset.file.slice(asset.file.lastIndexOf('/') + 1)}` : cdnUrl(pkg, asset)
|
|
159
|
+
const integrity = local ? '' : ` integrity="${asset.integrity}" crossorigin="anonymous"`
|
|
160
|
+
return asset.kind === 'style'
|
|
161
|
+
? `<link rel="stylesheet" href="${escapeHtml(url)}"${integrity}>`
|
|
162
|
+
: `<script src="${escapeHtml(url)}"${integrity}></script>`
|
|
163
|
+
})
|
|
164
|
+
const styles = tags.filter(tag => tag.startsWith('<link'))
|
|
165
|
+
const scripts = tags.filter(tag => tag.startsWith('<script'))
|
|
166
|
+
const config =
|
|
167
|
+
input.kind === 'scalar'
|
|
168
|
+
? { url: input.documentUrl, withDefaultFonts: false, telemetry: false, ...input.config }
|
|
169
|
+
: { url: input.documentUrl, dom_id: '#swagger-ui', validatorUrl: null, ...input.config }
|
|
170
|
+
const json = scriptJson(config)
|
|
171
|
+
const inline =
|
|
172
|
+
input.kind === 'scalar' ? `Scalar.createApiReference('#app', ${json})` : `window.ui = SwaggerUIBundle(${json})`
|
|
173
|
+
const hash = `'sha256-${createHash('sha256').update(inline).digest('base64')}'`
|
|
174
|
+
const container = input.kind === 'scalar' ? '<div id="app"></div>' : '<div id="swagger-ui"></div>'
|
|
175
|
+
const html = [
|
|
176
|
+
'<!doctype html>',
|
|
177
|
+
'<html lang="en">',
|
|
178
|
+
'<head>',
|
|
179
|
+
'<meta charset="utf-8">',
|
|
180
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
181
|
+
`<title>${escapeHtml(input.title)}</title>`,
|
|
182
|
+
...styles,
|
|
183
|
+
'</head>',
|
|
184
|
+
'<body>',
|
|
185
|
+
container,
|
|
186
|
+
...scripts,
|
|
187
|
+
`<script>${inline}</script>`,
|
|
188
|
+
'</body>',
|
|
189
|
+
'</html>',
|
|
190
|
+
'',
|
|
191
|
+
].join('\n')
|
|
192
|
+
const cdn = local ? '' : ` ${CDN_ORIGIN}`
|
|
193
|
+
const connect = ["'self'", ...input.connect].join(' ')
|
|
194
|
+
const csp = [
|
|
195
|
+
"default-src 'none'",
|
|
196
|
+
`script-src 'self'${cdn} ${hash}`,
|
|
197
|
+
`style-src 'self' 'unsafe-inline'${cdn}`,
|
|
198
|
+
"img-src 'self' data: https:",
|
|
199
|
+
`font-src 'self' data:${cdn}`,
|
|
200
|
+
`connect-src ${connect}`,
|
|
201
|
+
"frame-ancestors 'none'",
|
|
202
|
+
"base-uri 'none'",
|
|
203
|
+
"form-action 'none'",
|
|
204
|
+
].join('; ')
|
|
205
|
+
return { html, csp }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** JSON safe inside an inline `<script>` (`<` escaped, line separators escaped). */
|
|
209
|
+
function scriptJson(value: unknown): string {
|
|
210
|
+
return JSON.stringify(value).replaceAll('<', '\\u003c').replaceAll('
', '\\u2028').replaceAll('
', '\\u2029')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function escapeHtml(text: string): string {
|
|
214
|
+
return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
215
|
+
}
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@znight/openapi/vite`: the `openapi()` Vite plugin. After `vite build` / `znight build` wrote the
|
|
3
|
+
* bundle (and after each good rebuild of `znight dev`), it emits the OpenAPI document with
|
|
4
|
+
* `emitOpenApi()`; with `check`, a missing or stale committed output fails the build.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* export default defineConfig({
|
|
8
|
+
* plugins: [znight(options), openapi({ output: ['openapi.json', 'dist/openapi.json'], check: process.env['CI'] === 'true' })],
|
|
9
|
+
* })
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import type { Plugin, ResolvedConfig } from 'vite'
|
|
13
|
+
import type { EmitRuntime } from './emit/index.ts'
|
|
14
|
+
|
|
15
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** Name of the plugin (an application uses one instance: the first one wins). */
|
|
18
|
+
export const OPENAPI_PLUGIN_NAME = 'znight:openapi'
|
|
19
|
+
|
|
20
|
+
/** Options of {@link openapi}. */
|
|
21
|
+
export interface OpenApiVitePluginOptions {
|
|
22
|
+
/** Output files, relative to the Vite root (`.yaml` / `.yml` write YAML). Default: `<build.outDir>/openapi.json`. */
|
|
23
|
+
readonly output?: string | readonly string[]
|
|
24
|
+
/**
|
|
25
|
+
* CI: never write the outputs outside `build.outDir` (the committed files), fail the build when one
|
|
26
|
+
* is missing or stale. The outputs inside `build.outDir` are build artifacts, written by every
|
|
27
|
+
* build. Default `false`.
|
|
28
|
+
*/
|
|
29
|
+
readonly check?: boolean
|
|
30
|
+
/** HTTP prefix (`servers` default when `@OpenApiDocument` declares none). */
|
|
31
|
+
readonly prefix?: string
|
|
32
|
+
/** Runtime loading the application modules. Default `'auto'`. */
|
|
33
|
+
readonly runtime?: EmitRuntime
|
|
34
|
+
/** Module whose default export is a `SchemaConverter[]`, relative to the Vite root. */
|
|
35
|
+
readonly converters?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ZnightApiLike {
|
|
39
|
+
readonly options?: unknown
|
|
40
|
+
readonly result?: { readonly ok?: unknown }
|
|
41
|
+
readonly compiler?: { readonly options?: { readonly root?: unknown } }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `openapi()` Vite plugin (build only). */
|
|
45
|
+
export function openapi(options: OpenApiVitePluginOptions = {}): Plugin {
|
|
46
|
+
let config: ResolvedConfig | undefined
|
|
47
|
+
let active = true
|
|
48
|
+
let emitted = false
|
|
49
|
+
const plugin: Plugin = {
|
|
50
|
+
name: OPENAPI_PLUGIN_NAME,
|
|
51
|
+
apply: 'build',
|
|
52
|
+
configResolved(resolved) {
|
|
53
|
+
config = resolved
|
|
54
|
+
// the config file wins over a copy added by the CLI (znight build --openapi)
|
|
55
|
+
active = resolved.plugins.find(candidate => candidate.name === OPENAPI_PLUGIN_NAME) === plugin
|
|
56
|
+
},
|
|
57
|
+
buildStart() {
|
|
58
|
+
emitted = false
|
|
59
|
+
},
|
|
60
|
+
async writeBundle() {
|
|
61
|
+
if (!active || emitted || config === undefined) return
|
|
62
|
+
emitted = true
|
|
63
|
+
const znight = config.plugins.find(candidate => candidate.name === 'znight') as
|
|
64
|
+
| { api?: ZnightApiLike }
|
|
65
|
+
| undefined
|
|
66
|
+
const api = znight?.api
|
|
67
|
+
// watch mode: the bundle of a failed compile throws the diagnostics; keep the last document
|
|
68
|
+
if (api?.result !== undefined && api.result.ok === false) return
|
|
69
|
+
const root = config.root
|
|
70
|
+
const outputs =
|
|
71
|
+
options.output === undefined
|
|
72
|
+
? [join(resolve(root, config.build.outDir), 'openapi.json')]
|
|
73
|
+
: (typeof options.output === 'string' ? [options.output] : options.output).map(file => resolve(root, file))
|
|
74
|
+
const outDir = resolve(root, config.build.outDir)
|
|
75
|
+
const checked = options.check === true ? outputs.filter(file => !isInside(outDir, file)) : []
|
|
76
|
+
if (options.check === true && checked.length === 0) {
|
|
77
|
+
this.warn(
|
|
78
|
+
'[znight:openapi] check: every output is in build.outDir, written by each build: nothing is checked (add the committed file to output)',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
const compilerRoot = api?.compiler?.options?.root
|
|
82
|
+
const given =
|
|
83
|
+
typeof api?.options === 'object' && api.options !== null ? (api.options as Record<string, unknown>) : undefined
|
|
84
|
+
const { emitOpenApi } = await import('./emit/index.ts')
|
|
85
|
+
const result = await emitOpenApi({
|
|
86
|
+
root,
|
|
87
|
+
output: outputs,
|
|
88
|
+
...(checked.length === 0 ? {} : { check: checked }),
|
|
89
|
+
...(options.prefix === undefined ? {} : { prefix: options.prefix }),
|
|
90
|
+
...(options.runtime === undefined ? {} : { runtime: options.runtime }),
|
|
91
|
+
...(options.converters === undefined ? {} : { converters: options.converters }),
|
|
92
|
+
...(config.configFile === undefined ? {} : { config: config.configFile }),
|
|
93
|
+
...(given === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: {
|
|
96
|
+
compilerOptions: {
|
|
97
|
+
...given,
|
|
98
|
+
root: typeof compilerRoot === 'string' ? compilerRoot : resolve(root, String(given['root'] ?? '.')),
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
const show = (file: string): string => {
|
|
103
|
+
const path = relative(root, file)
|
|
104
|
+
return path === '' || path.startsWith('..') || isAbsolute(path) ? file : path
|
|
105
|
+
}
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
this.error(
|
|
108
|
+
`[znight:openapi] the application does not compile: ${result.diagnostics.filter(d => d.severity === 'error').length} error(s)`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
for (const warning of result.warnings) this.warn(`${warning.code}: ${warning.message}`)
|
|
112
|
+
if (result.stale) {
|
|
113
|
+
this.error(
|
|
114
|
+
`[znight:openapi] ${result.staleFiles.map(show).join(', ')} ${result.staleFiles.length > 1 ? 'are' : 'is'} missing or out of date: run "znight openapi"`,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
for (const file of result.written) config.logger.info(`[znight:openapi] wrote ${show(file)}`)
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
return plugin
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isInside(directory: string, file: string): boolean {
|
|
124
|
+
const path = relative(directory, file)
|
|
125
|
+
return path !== '' && !path.startsWith('..') && !isAbsolute(path)
|
|
126
|
+
}
|