@panomc/client-gen 0.0.0-stage → 1.0.0-dev.61

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.
@@ -0,0 +1,59 @@
1
+ // Generated by pano-client from openapi.json. Do not edit; run pano-client again.
2
+
3
+ /**
4
+ * Who wrote a post.
5
+ * @typedef {Object} Author
6
+ * @property {string | null} [avatar] Head image url.
7
+ * @property {number} id
8
+ * @property {string} username
9
+ */
10
+
11
+ /** @typedef {Author & { extra?: number }} Combined */
12
+
13
+ /**
14
+ * @typedef {Object} ErrorEnvelope
15
+ * @property {{ code: string, fields?: Record<string, string>, message?: string }} error
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} PageInfo
20
+ * @property {number} number
21
+ * @property {number} size
22
+ * @property {number} totalItems
23
+ * @property {number} totalPages
24
+ */
25
+
26
+ /** @typedef {{ author: Author, body?: string | Author, id: number, image?: string | null, meta?: Record<string, string>, status: 'DRAFT' | 'PUBLISHED', tags?: string[], title: string, 'x-extra'?: boolean }} Post */
27
+
28
+ /**
29
+ * @typedef {Object} PostInput
30
+ * @property {number} [categoryId]
31
+ * @property {string} [text]
32
+ * @property {string} title
33
+ */
34
+
35
+ /**
36
+ * @typedef {Object} PostPage
37
+ * @property {Post[]} items
38
+ * @property {PageInfo} page
39
+ */
40
+
41
+ /** @typedef {'UP' | 'DOWN'} Status */
42
+
43
+ /** @typedef {Post} CreatePostResponse */
44
+
45
+ /** @typedef {PostInput} CreatePostRequest */
46
+
47
+ /** @typedef {void} DeletePostResponse */
48
+
49
+ /** @typedef {Blob} GetApiV1ExportByKindResponse */
50
+
51
+ /** @typedef {any} GetLegacyThingResponse */
52
+
53
+ /** @typedef {Post} GetPostResponse */
54
+
55
+ /** @typedef {PostPage} GetPostsResponse */
56
+
57
+ /** @typedef {{ url: string }} UploadAvatarResponse */
58
+
59
+ export {};
package/src/http.js ADDED
@@ -0,0 +1,58 @@
1
+ import { ClientGenError } from './errors.js'
2
+
3
+ /**
4
+ * @typedef {Object} Fetched
5
+ * @property {number} status
6
+ * @property {boolean} ok
7
+ * @property {string} text
8
+ */
9
+
10
+ /** @param {string} url */
11
+ export function trimBase(url) {
12
+ return String(url).trim().replace(/\/+$/, '')
13
+ }
14
+
15
+ /**
16
+ * GET a URL as text. A network failure throws `onUnreachable()`'s message; any HTTP status is returned.
17
+ * @param {string} url
18
+ * @param {{ fetch?: typeof fetch, onUnreachable: () => string }} opts
19
+ * @returns {Promise<Fetched>}
20
+ */
21
+ export async function getText(url, opts) {
22
+ const doFetch = opts.fetch ?? globalThis.fetch
23
+ let res
24
+ try {
25
+ res = await doFetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(20_000), redirect: 'follow' })
26
+ } catch {
27
+ throw new ClientGenError(opts.onUnreachable())
28
+ }
29
+ let text = ''
30
+ try {
31
+ text = await res.text()
32
+ } catch {
33
+ throw new ClientGenError(opts.onUnreachable())
34
+ }
35
+ return { status: res.status, ok: res.ok, text }
36
+ }
37
+
38
+ /** @param {string} base */
39
+ export const unreachableMessage = (base) =>
40
+ `Pano did not answer at ${base}/api/v1/openapi.json. Is it running? Try --url http://localhost:8088`
41
+
42
+ /**
43
+ * @param {string} text
44
+ * @param {string} what
45
+ */
46
+ export function parseJson(text, what) {
47
+ try {
48
+ return JSON.parse(text)
49
+ } catch {
50
+ throw new ClientGenError(`${what} is not valid JSON.`)
51
+ }
52
+ }
53
+
54
+ /** Plugin ids and namespaces end up in URLs and file names. @param {string} s */
55
+ export const isSafeName = (s) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(s) && !s.includes('..')
56
+
57
+ /** Namespace rule of doc 01: the id minus a leading `pano-plugin-`. @param {string} pluginId */
58
+ export const namespaceOf = (pluginId) => (pluginId.startsWith('pano-plugin-') ? pluginId.slice('pano-plugin-'.length) : pluginId)
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { generateClient } from './generate.js'
2
+ export { pull } from './pull.js'
3
+ export { check } from './check.js'
4
+ export { scaffold, STARTER_TARBALL } from './new.js'
5
+ export { canonicalJson, shortHash } from './canonical.js'
6
+ export { ClientGenError } from './errors.js'
package/src/new.js ADDED
@@ -0,0 +1,115 @@
1
+ // new: copy the starter template, write .env, bun install (doc 06 §2.4, §5.1).
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { tmpdir } from 'node:os'
5
+ import { basename, join, resolve } from 'node:path'
6
+ import { ClientGenError } from './errors.js'
7
+ import { trimBase } from './http.js'
8
+
9
+ /** Where the starter comes from when --from is not given. */
10
+ export const STARTER_TARBALL = 'https://codeload.github.com/PanoMC/pano-starter-sveltekit/tar.gz/refs/heads/main'
11
+
12
+ const SKIP = new Set(['node_modules', '.git', 'build', '.svelte-kit', '.DS_Store'])
13
+
14
+ /**
15
+ * @typedef {Object} NewOptions
16
+ * @property {string} dir
17
+ * @property {string} [url] Pano address; written to .env as API_URL=<url>/api
18
+ * @property {string} [from] local template directory (default: download the starter)
19
+ * @property {boolean} [install] run `bun install` (default true)
20
+ * @property {string} [cwd]
21
+ * @property {typeof fetch} [fetch]
22
+ * @property {(dir: string) => { ok: boolean, message?: string }} [installer] replaces the `bun install` call (tests)
23
+ * @property {(line: string) => void} [log]
24
+ */
25
+
26
+ /** @param {string} dir */
27
+ function isEmptyDir(dir) {
28
+ return !existsSync(dir) || (statSync(dir).isDirectory() && readdirSync(dir).length === 0)
29
+ }
30
+
31
+ /** @param {string} url @param {typeof fetch | undefined} doFetch @returns {Promise<string>} directory with the extracted template */
32
+ async function downloadStarter(url, doFetch) {
33
+ const tmp = mkdtempSync(join(tmpdir(), 'pano-starter-'))
34
+ let res
35
+ try {
36
+ res = await (doFetch ?? globalThis.fetch)(url, { redirect: 'follow', signal: AbortSignal.timeout(60_000) })
37
+ } catch {
38
+ throw new ClientGenError(`Could not download the starter from ${url}. Check your connection or pass --from <dir>.`)
39
+ }
40
+ if (!res.ok) throw new ClientGenError(`Could not download the starter from ${url} (HTTP ${res.status}). Pass --from <dir> instead.`)
41
+ const archive = join(tmp, 'starter.tar.gz')
42
+ writeFileSync(archive, new Uint8Array(await res.arrayBuffer()))
43
+ const extracted = join(tmp, 'tree')
44
+ mkdirSync(extracted)
45
+ const r = spawnSync('tar', ['-xzf', archive, '-C', extracted, '--strip-components=1'], { encoding: 'utf8' })
46
+ if (r.status !== 0) throw new ClientGenError(`Could not unpack the starter archive: ${(r.stderr || r.error?.message || '').trim()}`)
47
+ return extracted
48
+ }
49
+
50
+ /** @param {string} dir */
51
+ function defaultInstaller(dir) {
52
+ const r = spawnSync('bun', ['install'], { cwd: dir, stdio: 'inherit' })
53
+ if (r.error) return { ok: false, message: r.error.message }
54
+ return { ok: r.status === 0, message: `exit code ${r.status}` }
55
+ }
56
+
57
+ /**
58
+ * @param {NewOptions} options
59
+ * @returns {Promise<{ dir: string, installed: boolean }>}
60
+ */
61
+ export async function scaffold(options) {
62
+ if (!options.dir) throw new ClientGenError('new needs a directory, e.g. pano-client new my-site')
63
+ const cwd = resolve(options.cwd ?? process.cwd())
64
+ const target = resolve(cwd, options.dir)
65
+ const log = options.log ?? (() => {})
66
+ if (!isEmptyDir(target)) throw new ClientGenError(`${target} already exists and is not empty.`)
67
+
68
+ let source
69
+ let cleanup = null
70
+ if (options.from) {
71
+ source = resolve(cwd, options.from)
72
+ if (!existsSync(source) || !statSync(source).isDirectory()) throw new ClientGenError(`--from ${source} is not a directory.`)
73
+ } else {
74
+ source = await downloadStarter(process.env.PANO_STARTER_URL || STARTER_TARBALL, options.fetch)
75
+ cleanup = join(source, '..')
76
+ }
77
+
78
+ try {
79
+ mkdirSync(target, { recursive: true })
80
+ cpSync(source, target, { recursive: true, filter: (src) => !SKIP.has(basename(src)) })
81
+ } finally {
82
+ if (cleanup) rmSync(cleanup, { recursive: true, force: true })
83
+ }
84
+
85
+ // package name from the folder
86
+ const pkgPath = join(target, 'package.json')
87
+ if (existsSync(pkgPath)) {
88
+ try {
89
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
90
+ const name = basename(target).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+/, '')
91
+ if (name) {
92
+ pkg.name = name
93
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
94
+ }
95
+ } catch {}
96
+ }
97
+
98
+ // .env from .env.example, API_URL from --url
99
+ const examplePath = join(target, '.env.example')
100
+ let env = existsSync(examplePath) ? readFileSync(examplePath, 'utf8') : 'API_URL=http://localhost:8088/api\n'
101
+ if (options.url) {
102
+ const apiUrl = `${trimBase(options.url).replace(/\/api$/, '')}/api`
103
+ env = /^API_URL=.*$/m.test(env) ? env.replace(/^API_URL=.*$/m, `API_URL=${apiUrl}`) : `API_URL=${apiUrl}\n${env}`
104
+ }
105
+ if (!existsSync(join(target, '.env'))) writeFileSync(join(target, '.env'), env)
106
+ log(`created ${target}`)
107
+
108
+ let installed = false
109
+ if (options.install !== false) {
110
+ const r = (options.installer ?? defaultInstaller)(target)
111
+ if (!r.ok) throw new ClientGenError(`bun install failed in ${target} (${r.message ?? 'unknown error'}). The project is created; run bun install there yourself.`)
112
+ installed = true
113
+ }
114
+ return { dir: target, installed }
115
+ }
@@ -0,0 +1,106 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { run } from './cli.js'
5
+ import { ClientGenError } from './errors.js'
6
+ import { scaffold } from './new.js'
7
+ import { tmp } from './test-helpers.js'
8
+
9
+ const dirs = []
10
+ afterAll(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true })))
11
+
12
+ /** a tiny template with the shape of the starter: .env.example, a build script, ignored folders */
13
+ function template() {
14
+ const t = tmp('tpl')
15
+ dirs.push(t)
16
+ writeFileSync(
17
+ join(t, 'package.json'),
18
+ JSON.stringify({ name: 'pano-starter-sveltekit', private: true, scripts: { build: "bun -e \"require('fs').writeFileSync('built.txt','ok')\"" } }, null, 2)
19
+ )
20
+ writeFileSync(join(t, '.env.example'), '# Where Pano answers.\nAPI_URL=http://localhost:8088/api\nPANO_FRONTEND_KEY=\n')
21
+ mkdirSync(join(t, 'src/lib/pano'), { recursive: true })
22
+ writeFileSync(join(t, 'src/lib/pano/index.json'), '{}')
23
+ for (const skipped of ['node_modules', '.git', 'build', '.svelte-kit']) {
24
+ mkdirSync(join(t, skipped), { recursive: true })
25
+ writeFileSync(join(t, skipped, 'x'), 'x')
26
+ }
27
+ return t
28
+ }
29
+
30
+ describe('new', () => {
31
+ test('--from copies the template, names the package, writes .env, runs bun install, and the tree builds', async () => {
32
+ const t = template()
33
+ const cwd = tmp('new')
34
+ dirs.push(cwd)
35
+ const outs = []
36
+ const code = await run(['new', 'my-site', '--from', t], { cwd, stdout: (s) => outs.push(s), stderr: () => {} })
37
+ expect(code).toBe(0)
38
+ const site = join(cwd, 'my-site')
39
+ expect(JSON.parse(readFileSync(join(site, 'package.json'), 'utf8')).name).toBe('my-site')
40
+ expect(readFileSync(join(site, '.env'), 'utf8')).toBe(readFileSync(join(t, '.env.example'), 'utf8'))
41
+ expect(existsSync(join(site, 'src/lib/pano/index.json'))).toBe(true)
42
+ for (const skipped of ['.git', 'build', '.svelte-kit']) expect(existsSync(join(site, skipped))).toBe(false)
43
+ expect(existsSync(join(site, 'bun.lock')) || existsSync(join(site, 'bun.lockb')) || existsSync(join(site, 'node_modules'))).toBe(true)
44
+ expect(outs.at(-1)).toBe('\nNext: cd my-site && bun dev')
45
+
46
+ const build = Bun.spawnSync(['bun', 'run', 'build'], { cwd: site })
47
+ expect(build.exitCode).toBe(0)
48
+ expect(readFileSync(join(site, 'built.txt'), 'utf8')).toBe('ok')
49
+ })
50
+
51
+ test('--url rewrites API_URL (the /api suffix is added once)', async () => {
52
+ const t = template()
53
+ const cwd = tmp('new-url')
54
+ dirs.push(cwd)
55
+ await scaffold({ dir: 'a', from: t, cwd, install: false, url: 'https://pano.example.com/' })
56
+ await scaffold({ dir: 'b', from: t, cwd, install: false, url: 'http://localhost:9000/api' })
57
+ expect(readFileSync(join(cwd, 'a/.env'), 'utf8')).toContain('API_URL=https://pano.example.com/api\n')
58
+ expect(readFileSync(join(cwd, 'a/.env'), 'utf8')).toContain('PANO_FRONTEND_KEY=\n')
59
+ expect(readFileSync(join(cwd, 'b/.env'), 'utf8')).toContain('API_URL=http://localhost:9000/api\n')
60
+ })
61
+
62
+ test('refuses a directory that is not empty, and a missing template', async () => {
63
+ const t = template()
64
+ const cwd = tmp('new-refuse')
65
+ dirs.push(cwd)
66
+ mkdirSync(join(cwd, 'taken'))
67
+ writeFileSync(join(cwd, 'taken/file'), 'x')
68
+ await expect(scaffold({ dir: 'taken', from: t, cwd, install: false })).rejects.toThrow(`${join(cwd, 'taken')} already exists and is not empty.`)
69
+ await expect(scaffold({ dir: 'z', from: join(cwd, 'nope'), cwd, install: false })).rejects.toThrow('is not a directory')
70
+ await expect(scaffold({ dir: '', from: t, cwd })).rejects.toBeInstanceOf(ClientGenError)
71
+ })
72
+
73
+ test('a failing install keeps the project and says what to do', async () => {
74
+ const t = template()
75
+ const cwd = tmp('new-install')
76
+ dirs.push(cwd)
77
+ await expect(scaffold({ dir: 's', from: t, cwd, installer: () => ({ ok: false, message: 'exit code 1' }) })).rejects.toThrow(
78
+ `bun install failed in ${join(cwd, 's')} (exit code 1). The project is created; run bun install there yourself.`
79
+ )
80
+ expect(existsSync(join(cwd, 's/.env'))).toBe(true)
81
+ })
82
+
83
+ test('without --from the starter is downloaded (stubbed fetch + tarball)', async () => {
84
+ const t = template()
85
+ const cwd = tmp('new-dl')
86
+ dirs.push(cwd)
87
+ const tarDir = tmp('tar')
88
+ dirs.push(tarDir)
89
+ // GitHub tarballs have one top folder
90
+ const tar = join(tarDir, 's.tar.gz')
91
+ Bun.spawnSync(['tar', '-czf', tar, '-C', join(t, '..'), '--transform', 's,^[^/]*,pano-starter-main,', t.split('/').pop()])
92
+ const bytes = readFileSync(tar)
93
+ let asked = ''
94
+ const fakeFetch = async (/** @type {string} */ url) => {
95
+ asked = url
96
+ return new Response(bytes)
97
+ }
98
+ await scaffold({ dir: 'dl', cwd, install: false, fetch: /** @type {any} */ (fakeFetch) })
99
+ expect(asked).toContain('pano-starter-sveltekit')
100
+ expect(JSON.parse(readFileSync(join(cwd, 'dl/package.json'), 'utf8')).name).toBe('dl')
101
+ expect(existsSync(join(cwd, 'dl/.env'))).toBe(true)
102
+
103
+ const failing = async () => new Response('no', { status: 404 })
104
+ await expect(scaffold({ dir: 'dl2', cwd, install: false, fetch: /** @type {any} */ (failing) })).rejects.toThrow('Pass --from <dir> instead.')
105
+ })
106
+ })
@@ -0,0 +1,244 @@
1
+ // Reads the operations of an OpenAPI document: names, parameters, bodies, responses, fingerprints.
2
+ import { canonicalJson } from './canonical.js'
3
+
4
+ export const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch']
5
+
6
+ const RESERVED = new Set([
7
+ 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum',
8
+ 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return',
9
+ 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'let', 'static',
10
+ 'implements', 'interface', 'package', 'private', 'protected', 'public', 'arguments', 'eval', 'operations'
11
+ ])
12
+
13
+ /** @param {string} s */
14
+ const upperFirst = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s)
15
+ /** @param {string} s */
16
+ const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s)
17
+
18
+ /** `get-posts`, `get_posts`, `GetPosts` -> `GetPosts` / `GetPosts` @param {string} s */
19
+ export function pascal(s) {
20
+ const parts = s.split(/[^A-Za-z0-9]+/).filter(Boolean)
21
+ return parts.map(upperFirst).join('')
22
+ }
23
+
24
+ /** Name for an operation without operationId: `<method><PascalPath>`. @param {string} method @param {string} path */
25
+ export function fallbackId(method, path) {
26
+ const segments = path
27
+ .split('/')
28
+ .filter(Boolean)
29
+ .map((seg) => {
30
+ const m = /^\{(.+)\}$/.exec(seg)
31
+ return m ? 'By' + pascal(m[1]) : pascal(seg)
32
+ })
33
+ return method.toLowerCase() + segments.join('')
34
+ }
35
+
36
+ /**
37
+ * @param {any} doc
38
+ * @param {string} ref
39
+ */
40
+ function resolveRef(doc, ref) {
41
+ if (typeof ref !== 'string' || !ref.startsWith('#/')) return undefined
42
+ let node = doc
43
+ for (const part of ref.slice(2).split('/')) {
44
+ if (node === null || typeof node !== 'object') return undefined
45
+ node = node[part.replace(/~1/g, '/').replace(/~0/g, '~')]
46
+ }
47
+ return node
48
+ }
49
+
50
+ /** Follows a single level of `$ref` for parameters, request bodies and responses. @param {any} doc @param {any} node */
51
+ function deref(doc, node) {
52
+ let n = node
53
+ for (let i = 0; i < 8 && n && typeof n === 'object' && typeof n.$ref === 'string'; i++) n = resolveRef(doc, n.$ref)
54
+ return n
55
+ }
56
+
57
+ /**
58
+ * @typedef {Object} Operation
59
+ * @property {string} id operationId (or the generated fallback)
60
+ * @property {string} name function name in index.js
61
+ * @property {string} method upper case
62
+ * @property {string} path the request path: server prefix plus `docPath`
63
+ * @property {string} docPath the key in `paths`
64
+ * @property {'none'|'user'} auth
65
+ * @property {boolean} mutation
66
+ * @property {boolean} deprecated
67
+ * @property {boolean} undocumented
68
+ * @property {string} summary
69
+ * @property {string} description
70
+ * @property {{ name: string, in: string, required: boolean, schema: any, description: string }[]} parameters
71
+ * @property {{ required: boolean, kind: 'json'|'form'|'other', schema: any } | null} body
72
+ * @property {{ kind: 'void'|'json'|'text'|'blob', schema: any }} response
73
+ * @property {any} raw
74
+ */
75
+
76
+ /** @param {any} security */
77
+ function authOf(security) {
78
+ if (!Array.isArray(security) || security.length === 0) return 'none'
79
+ return security.some((s) => s && typeof s === 'object' && Object.keys(s).length === 0) ? 'none' : 'user'
80
+ }
81
+
82
+ /**
83
+ * The path part of the first usable server (`/api/plugins/pano-plugin-market`, `/api/v1`), without a trailing slash;
84
+ * `''` for none, `/` or an absolute URL without a path. An operation's `servers` wins over its path item's, and
85
+ * that over the document's (OpenAPI 3.1).
86
+ * @param {any} servers
87
+ */
88
+ export function serverPrefix(servers) {
89
+ const first = Array.isArray(servers) ? servers.find((s) => s && typeof s.url === 'string') : undefined
90
+ if (!first) return ''
91
+ let url = first.url
92
+ const absolute = /^[a-z][a-z0-9+.-]*:\/\/[^/]*/i.exec(url)
93
+ if (absolute) url = url.slice(absolute[0].length)
94
+ return url.startsWith('/') ? url.replace(/\/+$/, '') : ''
95
+ }
96
+
97
+ /** The request path of an operation: the server prefix plus the document path (not doubled when already there). */
98
+ function joinServerPath(prefix, path) {
99
+ if (!prefix || path === prefix || path.startsWith(`${prefix}/`)) return path
100
+ return `${prefix}${path}`
101
+ }
102
+
103
+ /**
104
+ * Collects every operation of the document, sorted by function name.
105
+ * @param {any} doc
106
+ * @param {(message: string) => void} [warn]
107
+ * @returns {Operation[]}
108
+ */
109
+ export function collectOperations(doc, warn = () => {}) {
110
+ /** @type {Operation[]} */
111
+ const list = []
112
+ const paths = doc && typeof doc.paths === 'object' && doc.paths ? doc.paths : {}
113
+
114
+ for (const path of Object.keys(paths).sort()) {
115
+ const item = paths[path]
116
+ if (!item || typeof item !== 'object') continue
117
+ for (const method of METHODS) {
118
+ const raw = item[method]
119
+ if (!raw || typeof raw !== 'object') continue
120
+
121
+ let id = typeof raw.operationId === 'string' && raw.operationId ? raw.operationId : ''
122
+ if (!id) {
123
+ id = fallbackId(method, path)
124
+ warn(`${method.toUpperCase()} ${path} has no operationId, using "${id}"`)
125
+ }
126
+
127
+ let name = lowerFirst(id.includes('-') || id.includes('_') || id.includes(' ') || id.includes('.') ? pascal(id) : id.replace(/[^A-Za-z0-9_$]/g, ''))
128
+ if (!name || /^[0-9]/.test(name)) name = 'op' + upperFirst(name)
129
+ if (RESERVED.has(name)) name += 'Operation'
130
+
131
+ /** @type {Map<string, any>} */
132
+ const params = new Map()
133
+ for (const p of [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(raw.parameters) ? raw.parameters : [])]) {
134
+ const r = deref(doc, p)
135
+ if (r && typeof r === 'object' && typeof r.name === 'string') params.set(`${r.in}:${r.name}`, r)
136
+ }
137
+ const parameters = [...params.values()]
138
+ .filter((p) => p.in === 'path' || p.in === 'query')
139
+ .map((p) => ({
140
+ name: p.name,
141
+ in: p.in,
142
+ required: p.in === 'path' ? true : p.required === true,
143
+ schema: p.schema ?? (p.content?.['application/json']?.schema ?? {}),
144
+ description: typeof p.description === 'string' ? p.description : ''
145
+ }))
146
+ // placeholders in the path that the document forgot to declare
147
+ for (const m of path.matchAll(/\{([^}]+)\}/g)) {
148
+ if (!parameters.some((p) => p.in === 'path' && p.name === m[1])) {
149
+ parameters.push({ name: m[1], in: 'path', required: true, schema: { type: 'string' }, description: '' })
150
+ }
151
+ }
152
+ parameters.sort((a, b) => (a.in === b.in ? a.name.localeCompare(b.name) : a.in === 'path' ? -1 : 1))
153
+
154
+ const undocumented = raw['x-pano-undocumented'] === true || raw['x-pano-undocumented'] === 'true'
155
+
156
+ /** @type {Operation['body']} */
157
+ let body = null
158
+ const rb = deref(doc, raw.requestBody)
159
+ if (rb && typeof rb === 'object') {
160
+ const content = rb.content && typeof rb.content === 'object' ? rb.content : {}
161
+ const jsonKey = Object.keys(content).find((k) => /^application\/(.+\+)?json/.test(k))
162
+ const formKey = Object.keys(content).find((k) => k.startsWith('multipart/form-data'))
163
+ if (jsonKey) body = { required: rb.required === true, kind: 'json', schema: content[jsonKey].schema ?? {} }
164
+ else if (formKey) body = { required: rb.required === true, kind: 'form', schema: null }
165
+ else body = { required: rb.required === true, kind: 'other', schema: null }
166
+ }
167
+
168
+ /** @type {Operation['response']} */
169
+ let response = { kind: 'void', schema: null }
170
+ const responses = raw.responses && typeof raw.responses === 'object' ? raw.responses : {}
171
+ const okKey = Object.keys(responses).filter((k) => /^2/.test(k)).sort()[0]
172
+ if (okKey !== undefined) {
173
+ const r = deref(doc, responses[okKey])
174
+ const content = r && typeof r.content === 'object' && r.content ? r.content : {}
175
+ const keys = Object.keys(content)
176
+ const jsonKey = keys.find((k) => /^application\/(.+\+)?json/.test(k))
177
+ if (jsonKey) response = { kind: 'json', schema: content[jsonKey].schema ?? {} }
178
+ else if (keys.some((k) => k.startsWith('text/'))) response = { kind: 'text', schema: null }
179
+ else if (keys.length) response = { kind: 'blob', schema: null }
180
+ } else if (Object.keys(responses).length) {
181
+ response = { kind: 'json', schema: {} }
182
+ }
183
+
184
+ list.push({
185
+ id,
186
+ name,
187
+ method: method.toUpperCase(),
188
+ path: joinServerPath(serverPrefix(raw.servers ?? item.servers ?? doc.servers), path),
189
+ docPath: path,
190
+ auth: authOf(raw.security !== undefined ? raw.security : doc.security),
191
+ mutation: !['get', 'head', 'options'].includes(method),
192
+ deprecated: raw.deprecated === true,
193
+ undocumented,
194
+ summary: typeof raw.summary === 'string' ? raw.summary : '',
195
+ description: typeof raw.description === 'string' ? raw.description : '',
196
+ parameters,
197
+ body,
198
+ response,
199
+ raw
200
+ })
201
+ }
202
+ }
203
+
204
+ // unique function names, deterministic: the later one in (path, method) order gets a number
205
+ const used = new Set()
206
+ for (const op of list) {
207
+ let name = op.name
208
+ for (let n = 2; used.has(name); n++) name = `${op.name}${n}`
209
+ if (name !== op.name) warn(`${op.method} ${op.path}: operation name "${op.name}" is taken, using "${name}"`)
210
+ op.name = name
211
+ used.add(name)
212
+ }
213
+ return list.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
214
+ }
215
+
216
+ /**
217
+ * Operation object with every `$ref` replaced by its target (cycles stay as `$ref`), canonical text.
218
+ * Two documents with the same fingerprint for an operation describe the same request and response types.
219
+ * @param {any} doc
220
+ * @param {Operation} op
221
+ */
222
+ export function fingerprint(doc, op) {
223
+ /** @param {any} node @param {string[]} stack @returns {any} */
224
+ const inline = (node, stack) => {
225
+ if (Array.isArray(node)) return node.map((n) => inline(n, stack))
226
+ if (!node || typeof node !== 'object') return node
227
+ if (typeof node.$ref === 'string') {
228
+ if (stack.includes(node.$ref)) return { $ref: node.$ref }
229
+ const target = resolveRef(doc, node.$ref)
230
+ return target === undefined ? { $ref: node.$ref } : inline(target, [...stack, node.$ref])
231
+ }
232
+ /** @type {Record<string, any>} */
233
+ const out = {}
234
+ for (const k of Object.keys(node)) out[k] = inline(node[k], stack)
235
+ return out
236
+ }
237
+ const pathItem = doc.paths?.[op.docPath ?? op.path] ?? {}
238
+ return canonicalJson({
239
+ method: op.method,
240
+ path: op.path,
241
+ pathParameters: inline(pathItem.parameters ?? [], []),
242
+ operation: inline(op.raw, [])
243
+ })
244
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { collectOperations, serverPrefix } from './operations.js'
3
+
4
+ const get = { operationId: 'Thing', responses: { 200: { description: 'ok' } } }
5
+
6
+ describe('server prefix of an operation path', () => {
7
+ test('serverPrefix reads the path of a relative or absolute server url', () => {
8
+ expect(serverPrefix([{ url: '/api/plugins/pano-plugin-market/' }])).toBe('/api/plugins/pano-plugin-market')
9
+ expect(serverPrefix([{ url: 'https://pano.test/api/v1' }])).toBe('/api/v1')
10
+ expect(serverPrefix([{ url: '/' }])).toBe('')
11
+ expect(serverPrefix(undefined)).toBe('')
12
+ })
13
+
14
+ test('the document server prefixes every path', () => {
15
+ const [op] = collectOperations({ servers: [{ url: '/api/plugins/pano-plugin-market' }], paths: { '/store/products': { get } } })
16
+ expect(op.path).toBe('/api/plugins/pano-plugin-market/store/products')
17
+ expect(op.docPath).toBe('/store/products')
18
+ })
19
+
20
+ test('an operation-level servers entry overrides the document server (internal document panel routes)', () => {
21
+ const doc = {
22
+ servers: [{ url: '/api/v1' }],
23
+ paths: {
24
+ '/posts': { get },
25
+ '/plugins/pano-plugin-market/panel/products': { get: { ...get, operationId: 'Panel', servers: [{ url: '/api' }] } }
26
+ }
27
+ }
28
+ const byId = Object.fromEntries(collectOperations(doc).map((o) => [o.id, o.path]))
29
+ expect(byId.Thing).toBe('/api/v1/posts')
30
+ expect(byId.Panel).toBe('/api/plugins/pano-plugin-market/panel/products')
31
+ })
32
+
33
+ test('a path that already carries the prefix is not doubled', () => {
34
+ const [op] = collectOperations({ servers: [{ url: '/api/v1' }], paths: { '/api/v1/posts': { get } } })
35
+ expect(op.path).toBe('/api/v1/posts')
36
+ })
37
+ })