@erenthedeveloper0/zen-openapi 0.1.0-alpha.1

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/src/types.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { JsonSchema } from '@erenthedeveloper0/zen-core'
2
+
3
+ /**
4
+ * OpenAPI 3.1 document types — rfcs/0001 §29.
5
+ *
6
+ * 3.1 rather than 3.0 for one reason that matters here: 3.1's Schema Object *is*
7
+ * JSON Schema 2020-12, the same dialect the validation layer produces and the
8
+ * serializer IR consumes. Under 3.0 every schema would need lossy
9
+ * down-conversion (`nullable`, `exclusiveMinimum`, tuples, `const`) — which is
10
+ * precisely the silent drift this subsystem exists to prevent.
11
+ *
12
+ * Only the parts Zen emits are typed. A partial-but-honest type is more useful
13
+ * than a complete one nobody reads, and `[extension: string]` keeps `x-` fields
14
+ * legal without pretending to enumerate them.
15
+ */
16
+
17
+ /** OAS 3.1 Schema Objects are JSON Schema 2020-12, so this is not an alias of convenience. */
18
+ export type OpenApiSchema = JsonSchema
19
+
20
+ export interface OpenApiDocument {
21
+ readonly openapi: string
22
+ readonly info: InfoObject
23
+ readonly servers?: readonly ServerObject[] | undefined
24
+ readonly paths: Readonly<Record<string, PathItemObject>>
25
+ readonly components?: ComponentsObject | undefined
26
+ readonly tags?: readonly TagObject[] | undefined
27
+ readonly security?: readonly SecurityRequirement[] | undefined
28
+ readonly externalDocs?: ExternalDocs | undefined
29
+ readonly [extension: string]: unknown
30
+ }
31
+
32
+ export interface InfoObject {
33
+ readonly title: string
34
+ readonly version: string
35
+ readonly summary?: string | undefined
36
+ readonly description?: string | undefined
37
+ readonly license?: { readonly name: string; readonly identifier?: string; readonly url?: string } | undefined
38
+ readonly contact?: { readonly name?: string; readonly url?: string; readonly email?: string } | undefined
39
+ }
40
+
41
+ export interface ServerObject {
42
+ readonly url: string
43
+ readonly description?: string | undefined
44
+ readonly variables?: Readonly<Record<string, { readonly default: string; readonly enum?: readonly string[]; readonly description?: string }>> | undefined
45
+ }
46
+
47
+ export interface TagObject {
48
+ readonly name: string
49
+ readonly description?: string | undefined
50
+ readonly externalDocs?: ExternalDocs | undefined
51
+ }
52
+
53
+ export interface ExternalDocs {
54
+ readonly url: string
55
+ readonly description?: string | undefined
56
+ }
57
+
58
+ export type HttpOperation = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace'
59
+
60
+ export type PathItemObject = {
61
+ readonly [M in HttpOperation]?: OperationObject
62
+ } & {
63
+ readonly summary?: string | undefined
64
+ readonly description?: string | undefined
65
+ readonly parameters?: readonly ParameterObject[] | undefined
66
+ }
67
+
68
+ export interface OperationObject {
69
+ readonly operationId: string
70
+ readonly summary?: string | undefined
71
+ readonly description?: string | undefined
72
+ readonly tags?: readonly string[] | undefined
73
+ readonly deprecated?: boolean | undefined
74
+ readonly parameters?: readonly ParameterObject[] | undefined
75
+ readonly requestBody?: RequestBodyObject | undefined
76
+ readonly responses: Readonly<Record<string, ResponseObject>>
77
+ readonly security?: readonly SecurityRequirement[] | undefined
78
+ readonly externalDocs?: ExternalDocs | undefined
79
+ readonly [extension: string]: unknown
80
+ }
81
+
82
+ export type ParameterLocation = 'path' | 'query' | 'header' | 'cookie'
83
+
84
+ export interface ParameterObject {
85
+ readonly name: string
86
+ readonly in: ParameterLocation
87
+ readonly required?: boolean | undefined
88
+ readonly description?: string | undefined
89
+ readonly deprecated?: boolean | undefined
90
+ readonly schema: OpenApiSchema
91
+ readonly [extension: string]: unknown
92
+ }
93
+
94
+ export interface RequestBodyObject {
95
+ readonly required?: boolean | undefined
96
+ readonly description?: string | undefined
97
+ readonly content: Readonly<Record<string, MediaTypeObject>>
98
+ }
99
+
100
+ export interface ResponseObject {
101
+ readonly description: string
102
+ readonly content?: Readonly<Record<string, MediaTypeObject>> | undefined
103
+ readonly headers?: Readonly<Record<string, HeaderObject>> | undefined
104
+ }
105
+
106
+ export interface HeaderObject {
107
+ readonly description?: string | undefined
108
+ readonly required?: boolean | undefined
109
+ readonly schema: OpenApiSchema
110
+ }
111
+
112
+ export interface MediaTypeObject {
113
+ readonly schema: OpenApiSchema
114
+ readonly example?: unknown
115
+ }
116
+
117
+ export interface ComponentsObject {
118
+ readonly schemas?: Readonly<Record<string, OpenApiSchema>> | undefined
119
+ readonly securitySchemes?: Readonly<Record<string, SecurityScheme>> | undefined
120
+ }
121
+
122
+ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>
123
+
124
+ export interface SecurityScheme {
125
+ readonly type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect' | 'mutualTLS'
126
+ readonly description?: string | undefined
127
+ readonly name?: string | undefined
128
+ readonly in?: 'query' | 'header' | 'cookie' | undefined
129
+ readonly scheme?: string | undefined
130
+ readonly bearerFormat?: string | undefined
131
+ readonly openIdConnectUrl?: string | undefined
132
+ readonly flows?: Readonly<Record<string, unknown>> | undefined
133
+ }
134
+
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Route metadata this generator reads — rfcs/0001 §5.1.
139
+ *
140
+ * `meta` is a free-form map on purpose, but a generator that reads undocumented
141
+ * keys is a generator nobody can predict. These are the keys, and they are the
142
+ * whole list:
143
+ *
144
+ * ```ts
145
+ * app.get('/users/:id<int>', {
146
+ * name: 'users.show',
147
+ * meta: { summary: 'Fetch one user', tags: ['users'], deprecated: true },
148
+ * response: { 200: PublicUser },
149
+ * }, handler)
150
+ * ```
151
+ */
152
+ export interface RouteDocMeta {
153
+ readonly summary?: string
154
+ readonly description?: string
155
+ readonly tags?: readonly string[]
156
+ readonly operationId?: string
157
+ readonly deprecated?: boolean
158
+ /** Excluded from the document entirely. The docs endpoints set this on themselves. */
159
+ readonly hidden?: boolean
160
+ readonly security?: readonly SecurityRequirement[]
161
+ readonly externalDocs?: ExternalDocs
162
+ }
163
+
164
+ export const DOC_META_KEYS = [
165
+ 'summary', 'description', 'tags', 'operationId', 'deprecated', 'hidden', 'security', 'externalDocs',
166
+ ] as const satisfies readonly (keyof RouteDocMeta)[]
package/src/ui.ts ADDED
@@ -0,0 +1,198 @@
1
+ import type { OpenApiDocument } from './types.ts'
2
+
3
+ /**
4
+ * A self-contained API reference — rfcs/0001 §29.2.
5
+ *
6
+ * Scalar, Swagger UI and Redoc are all better-looking than this, and all three
7
+ * are normally wired in with a `<script src="https://cdn…">`. That is a fine
8
+ * default for a public docs site and a bad one for a framework: it makes an
9
+ * internal endpoint phone a third party on every page load, it breaks behind a
10
+ * strict CSP, it breaks in an air-gapped deployment, and it breaks on a laptop
11
+ * on a train. So the built-in viewer has **no external requests at all** — the
12
+ * document is inlined at boot and the page is HTML, CSS and ~90 lines of script.
13
+ *
14
+ * `ui: false` turns it off; serving Scalar or Redoc instead is a five-line route
15
+ * in application code, and the README shows it.
16
+ */
17
+ export function renderReference(document: OpenApiDocument, options: { jsonPath: string | null } = { jsonPath: null }): string {
18
+ const title = escapeHtml(document.info.title)
19
+ // `</script` inside a JSON island would end the block early. Escaping `<` is
20
+ // the standard, complete fix — `<` is the same character to JSON.parse.
21
+ const inlined = JSON.stringify(document).replace(/</g, '\\u003c')
22
+ const jsonLink = options.jsonPath === null
23
+ ? ''
24
+ : `<a class="json-link" href="${escapeHtml(options.jsonPath)}">openapi.json</a>`
25
+
26
+ return `<!doctype html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="utf-8">
30
+ <meta name="viewport" content="width=device-width, initial-scale=1">
31
+ <meta name="robots" content="noindex">
32
+ <title>${title} — API reference</title>
33
+ <style>${STYLE}</style>
34
+ </head>
35
+ <body>
36
+ <header>
37
+ <h1>${title}</h1>
38
+ <p class="version">v${escapeHtml(document.info.version)}${jsonLink}</p>
39
+ </header>
40
+ <main id="root"></main>
41
+ <script type="application/json" id="spec">${inlined}</script>
42
+ <script>${SCRIPT}</script>
43
+ </body>
44
+ </html>
45
+ `
46
+ }
47
+
48
+ function escapeHtml(raw: string): string {
49
+ return raw.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char)
50
+ }
51
+
52
+ const HTML_ESCAPES: Readonly<Record<string, string>> = {
53
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
54
+ }
55
+
56
+ const STYLE = `
57
+ :root{--bg:#fff;--fg:#1a1a1a;--muted:#666;--line:#e3e3e3;--code:#f6f6f7;--accent:#3060d0}
58
+ @media(prefers-color-scheme:dark){:root{--bg:#16171a;--fg:#e8e8ea;--muted:#9a9aa2;--line:#2c2d33;--code:#1e1f24;--accent:#7aa2f7}}
59
+ *{box-sizing:border-box}
60
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
61
+ header{padding:28px 32px 18px;border-bottom:1px solid var(--line)}
62
+ h1{margin:0;font-size:22px;font-weight:650}
63
+ .version{margin:6px 0 0;color:var(--muted);font-size:13px}
64
+ .json-link{margin-left:12px;color:var(--accent);text-decoration:none}
65
+ .json-link:hover{text-decoration:underline}
66
+ main{padding:24px 32px 64px;max-width:1000px}
67
+ h2{margin:32px 0 4px;font-size:15px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
68
+ .tag-desc{margin:0 0 10px;color:var(--muted);font-size:13.5px}
69
+ details{border:1px solid var(--line);border-radius:8px;margin:8px 0;background:var(--bg)}
70
+ details[open]{background:var(--code)}
71
+ summary{cursor:pointer;padding:11px 14px;display:flex;align-items:center;gap:10px;list-style:none;font-size:14px}
72
+ summary::-webkit-details-marker{display:none}
73
+ .method{font:600 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.05em;padding:5px 7px;border-radius:4px;color:#fff;min-width:56px;text-align:center}
74
+ .get{background:#2b7a4b}.post{background:#2f5fb3}.put{background:#a86100}.patch{background:#7a4bb3}.delete{background:#b33a3a}.head,.options{background:#5a5a66}
75
+ .path{font:13.5px ui-monospace,SFMono-Regular,Menlo,monospace}
76
+ .summary{color:var(--muted);font-size:13px;margin-left:auto;text-align:right}
77
+ .dep{text-decoration:line-through;opacity:.65}
78
+ .body{padding:2px 14px 16px;border-top:1px solid var(--line)}
79
+ h3{margin:16px 0 6px;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
80
+ table{border-collapse:collapse;width:100%;font-size:13.5px}
81
+ td,th{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
82
+ th{color:var(--muted);font-weight:500;font-size:12px}
83
+ code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:1px 5px}
84
+ pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:10px 12px;overflow:auto;font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;margin:6px 0 0}
85
+ .req{color:#b33a3a;font-size:11px;margin-left:4px}
86
+ .status{font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;margin-right:8px}
87
+ `
88
+
89
+ const SCRIPT = `
90
+ const spec = JSON.parse(document.getElementById('spec').textContent)
91
+ const root = document.getElementById('root')
92
+ const METHODS = ['get','post','put','patch','delete','head','options']
93
+
94
+ function resolve(schema, depth) {
95
+ if (!schema || depth > 6) return schema || {}
96
+ if (schema.$ref) {
97
+ const name = schema.$ref.split('/').pop()
98
+ const target = (spec.components && spec.components.schemas || {})[name]
99
+ return target ? Object.assign({ 'x-name': name }, resolve(target, depth + 1)) : {}
100
+ }
101
+ return schema
102
+ }
103
+
104
+ function typeOf(schema) {
105
+ const s = resolve(schema, 0)
106
+ if (s['x-name']) return s['x-name']
107
+ if (Array.isArray(s.type)) return s.type.join(' | ')
108
+ if (s.type === 'array') return typeOf(s.items || {}) + '[]'
109
+ if (s.enum) return s.enum.map(v => JSON.stringify(v)).join(' | ')
110
+ if (s.const !== undefined) return JSON.stringify(s.const)
111
+ return s.type || 'any'
112
+ }
113
+
114
+ function el(tag, cls, text) {
115
+ const node = document.createElement(tag)
116
+ if (cls) node.className = cls
117
+ if (text !== undefined) node.textContent = text
118
+ return node
119
+ }
120
+
121
+ function paramTable(params) {
122
+ const table = el('table')
123
+ table.innerHTML = '<tr><th>Name</th><th>In</th><th>Type</th><th>Description</th></tr>'
124
+ for (const p of params) {
125
+ const row = table.insertRow()
126
+ const name = row.insertCell()
127
+ name.appendChild(el('code', null, p.name))
128
+ if (p.required) name.appendChild(el('span', 'req', 'required'))
129
+ row.insertCell().textContent = p.in
130
+ row.insertCell().appendChild(el('code', null, typeOf(p.schema)))
131
+ row.insertCell().textContent = p.description || ''
132
+ }
133
+ return table
134
+ }
135
+
136
+ function schemaBlock(schema) {
137
+ return el('pre', null, JSON.stringify(schema, null, 2))
138
+ }
139
+
140
+ function operationNode(path, method, op) {
141
+ const details = el('details')
142
+ const summary = el('summary')
143
+ summary.appendChild(el('span', 'method ' + method, method.toUpperCase()))
144
+ summary.appendChild(el('span', 'path' + (op.deprecated ? ' dep' : ''), path))
145
+ if (op.summary) summary.appendChild(el('span', 'summary', op.summary))
146
+ details.appendChild(summary)
147
+
148
+ const body = el('div', 'body')
149
+ if (op.description) body.appendChild(el('p', 'tag-desc', op.description))
150
+ body.appendChild(el('h3', null, 'operationId'))
151
+ body.appendChild(el('code', null, op.operationId))
152
+
153
+ if (op.parameters && op.parameters.length) {
154
+ body.appendChild(el('h3', null, 'Parameters'))
155
+ body.appendChild(paramTable(op.parameters))
156
+ }
157
+ if (op.requestBody) {
158
+ body.appendChild(el('h3', null, 'Request body'))
159
+ for (const [media, content] of Object.entries(op.requestBody.content || {})) {
160
+ body.appendChild(el('code', null, media))
161
+ body.appendChild(schemaBlock(content.schema))
162
+ }
163
+ }
164
+ body.appendChild(el('h3', null, 'Responses'))
165
+ for (const [status, response] of Object.entries(op.responses || {})) {
166
+ const line = el('div')
167
+ line.appendChild(el('span', 'status', status))
168
+ line.appendChild(el('span', null, response.description || ''))
169
+ body.appendChild(line)
170
+ for (const content of Object.values(response.content || {})) {
171
+ body.appendChild(schemaBlock(content.schema))
172
+ }
173
+ }
174
+ details.appendChild(body)
175
+ return details
176
+ }
177
+
178
+ const groups = new Map()
179
+ for (const [path, item] of Object.entries(spec.paths || {})) {
180
+ for (const method of METHODS) {
181
+ const op = item[method]
182
+ if (!op) continue
183
+ const tag = (op.tags && op.tags[0]) || 'default'
184
+ if (!groups.has(tag)) groups.set(tag, [])
185
+ groups.get(tag).push([path, method, op])
186
+ }
187
+ }
188
+
189
+ const described = new Map((spec.tags || []).map(t => [t.name, t.description]))
190
+ for (const tag of [...groups.keys()].sort()) {
191
+ root.appendChild(el('h2', null, tag))
192
+ const description = described.get(tag)
193
+ if (description) root.appendChild(el('p', 'tag-desc', description))
194
+ for (const [path, method, op] of groups.get(tag)) {
195
+ root.appendChild(operationNode(path, method, op))
196
+ }
197
+ }
198
+ `