@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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -2
  3. package/dist/build-BTUC96mY.d.mts +174 -0
  4. package/dist/build-DXunOzlI.mjs +733 -0
  5. package/dist/build-DXunOzlI.mjs.map +1 -0
  6. package/dist/compiler.d.mts +16 -0
  7. package/dist/compiler.mjs +2639 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/emit-child.d.mts +1 -0
  10. package/dist/emit-child.mjs +124 -0
  11. package/dist/emit-child.mjs.map +1 -0
  12. package/dist/emit.d.mts +2 -0
  13. package/dist/emit.mjs +252 -0
  14. package/dist/emit.mjs.map +1 -0
  15. package/dist/index-CqQ6IRnN.d.mts +60 -0
  16. package/dist/index.d.mts +260 -0
  17. package/dist/index.mjs +777 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/openapi-CQk5dHqc.mjs +39 -0
  20. package/dist/openapi-CQk5dHqc.mjs.map +1 -0
  21. package/dist/shared-BZxtyaB0.mjs +147 -0
  22. package/dist/shared-BZxtyaB0.mjs.map +1 -0
  23. package/dist/testing.d.mts +39 -0
  24. package/dist/testing.mjs +155 -0
  25. package/dist/testing.mjs.map +1 -0
  26. package/dist/types-VzBexMKj.d.mts +213 -0
  27. package/dist/vite.d.mts +24 -0
  28. package/dist/vite.mjs +64 -0
  29. package/dist/vite.mjs.map +1 -0
  30. package/package.json +115 -4
  31. package/src/build.ts +898 -0
  32. package/src/compiler/analyze.ts +81 -0
  33. package/src/compiler/document.ts +612 -0
  34. package/src/compiler/generate.ts +79 -0
  35. package/src/compiler/index.ts +47 -0
  36. package/src/compiler/model.ts +141 -0
  37. package/src/compiler/operations.ts +1475 -0
  38. package/src/compiler/schemas.ts +706 -0
  39. package/src/compiler/validate.ts +102 -0
  40. package/src/compiler/values.ts +81 -0
  41. package/src/converters.ts +137 -0
  42. package/src/decorators.ts +187 -0
  43. package/src/emit/child.ts +144 -0
  44. package/src/emit/index.ts +377 -0
  45. package/src/emit/protocol.ts +35 -0
  46. package/src/index.ts +94 -0
  47. package/src/metadata.ts +112 -0
  48. package/src/openapi.ts +55 -0
  49. package/src/plugin.ts +810 -0
  50. package/src/shared.ts +154 -0
  51. package/src/testing.ts +224 -0
  52. package/src/types.ts +240 -0
  53. package/src/ui.ts +215 -0
  54. 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
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
+ }