@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.
- package/bin/pano-client.js +4 -0
- package/package.json +24 -4
- package/src/canonical.js +36 -0
- package/src/check.js +143 -0
- package/src/cli.js +106 -0
- package/src/errors.js +8 -0
- package/src/generate.js +201 -0
- package/src/generate.test.js +210 -0
- package/src/golden/mini/index.js +59 -0
- package/src/golden/mini/openapi.json +412 -0
- package/src/golden/mini/operations.js +11 -0
- package/src/golden/mini/types.js +59 -0
- package/src/http.js +58 -0
- package/src/index.js +6 -0
- package/src/new.js +115 -0
- package/src/new.test.js +106 -0
- package/src/operations.js +244 -0
- package/src/operations.test.js +37 -0
- package/src/pull.js +178 -0
- package/src/pull.test.js +301 -0
- package/src/schema.js +202 -0
- package/src/test-helpers.js +10 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|
package/src/new.test.js
ADDED
|
@@ -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
|
+
})
|