@panomc/client-gen 0.0.0-stage → 1.0.0-dev.62
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
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panomc/client-gen",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-dev.62",
|
|
4
|
+
"description": "Generates a typed JavaScript client from a Pano OpenAPI document and scaffolds headless sites (pano-client).",
|
|
5
|
+
"author": "PanoMC",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/PanoMC/sdk"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.js",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"pano-client": "./bin/pano-client.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"bin",
|
|
21
|
+
"templates"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/canonical.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deep copy with every object's keys sorted. Arrays keep their order.
|
|
5
|
+
* @param {any} value
|
|
6
|
+
* @returns {any}
|
|
7
|
+
*/
|
|
8
|
+
export function sortKeys(value) {
|
|
9
|
+
if (Array.isArray(value)) return value.map(sortKeys)
|
|
10
|
+
if (value && typeof value === 'object') {
|
|
11
|
+
/** @type {Record<string, any>} */
|
|
12
|
+
const out = {}
|
|
13
|
+
for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key])
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The one text form of an OpenAPI document: sorted keys, two spaces, trailing newline.
|
|
21
|
+
* Generated `openapi.json` files and the byte comparison of `pull` / `check` use it.
|
|
22
|
+
* @param {any} doc
|
|
23
|
+
*/
|
|
24
|
+
export function canonicalJson(doc) {
|
|
25
|
+
return JSON.stringify(sortKeys(doc), null, 2) + '\n'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {string|Uint8Array} data */
|
|
29
|
+
export function sha256(data) {
|
|
30
|
+
return createHash('sha256').update(data).digest('hex')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Short stable fingerprint used in `plugins/index.json`. @param {string|Uint8Array} data */
|
|
34
|
+
export function shortHash(data) {
|
|
35
|
+
return sha256(data).slice(0, 16)
|
|
36
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// check: compare a pulled directory with a running Pano (doc 06 §2.4). Exit 1 on removed / changed operations.
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
import { shortHash } from './canonical.js'
|
|
6
|
+
import { ClientGenError } from './errors.js'
|
|
7
|
+
import { collectOperations, fingerprint } from './operations.js'
|
|
8
|
+
import { getText, isSafeName, parseJson, trimBase, unreachableMessage } from './http.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} CheckOptions
|
|
12
|
+
* @property {string} url
|
|
13
|
+
* @property {string} [dir] default src/lib/pano
|
|
14
|
+
* @property {string} [cwd]
|
|
15
|
+
* @property {typeof fetch} [fetch]
|
|
16
|
+
* @property {string | null} [installedCore]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} CheckReport
|
|
21
|
+
* @property {boolean} ok
|
|
22
|
+
* @property {string[]} problems removed / changed operations, removed plugins, changed controllers
|
|
23
|
+
* @property {string[]} notes additions and skipped parts (never fail the check)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @param {any} doc @param {string} label @param {any} oldDoc @param {string[]} problems @param {string[]} notes */
|
|
27
|
+
function diffDocs(label, oldDoc, doc, problems, notes) {
|
|
28
|
+
const before = new Map(collectOperations(oldDoc).map((o) => [o.name, o]))
|
|
29
|
+
const after = new Map(collectOperations(doc).map((o) => [o.name, o]))
|
|
30
|
+
for (const [name, op] of before) {
|
|
31
|
+
const now = after.get(name)
|
|
32
|
+
if (!now) problems.push(`${label}: removed ${name} (${op.method} ${op.path})`)
|
|
33
|
+
else if (fingerprint(oldDoc, op) !== fingerprint(doc, now)) {
|
|
34
|
+
const moved = now.method !== op.method || now.path !== op.path
|
|
35
|
+
problems.push(`${label}: changed ${name} (${op.method} ${op.path}${moved ? ` -> ${now.method} ${now.path}` : ''})`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const [name, op] of after) if (!before.has(name)) notes.push(`${label}: new ${name} (${op.method} ${op.path})`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {string} p */
|
|
42
|
+
function readJson(p) {
|
|
43
|
+
return JSON.parse(readFileSync(p, 'utf8'))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {CheckOptions} options
|
|
48
|
+
* @returns {Promise<CheckReport>}
|
|
49
|
+
*/
|
|
50
|
+
export async function check(options) {
|
|
51
|
+
if (!options.url) throw new ClientGenError('check needs --url <pano>, e.g. --url http://localhost:8088')
|
|
52
|
+
const base = trimBase(options.url)
|
|
53
|
+
const cwd = resolve(options.cwd ?? process.cwd())
|
|
54
|
+
const dir = resolve(cwd, options.dir ?? 'src/lib/pano')
|
|
55
|
+
const fetchOpts = { fetch: options.fetch, onUnreachable: () => unreachableMessage(base) }
|
|
56
|
+
/** @type {string[]} */
|
|
57
|
+
const problems = []
|
|
58
|
+
/** @type {string[]} */
|
|
59
|
+
const notes = []
|
|
60
|
+
|
|
61
|
+
if (!existsSync(join(dir, 'plugins', 'index.json')) && !existsSync(join(dir, 'core', 'openapi.json'))) {
|
|
62
|
+
throw new ClientGenError(`Nothing to check in ${dir}. Run pano-client pull --url ${base} first.`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- core: the pulled snapshot, else the installed @panomc/client one
|
|
66
|
+
const coreRes = await getText(`${base}/api/v1/openapi.json`, fetchOpts)
|
|
67
|
+
if (!coreRes.ok) {
|
|
68
|
+
throw new ClientGenError(`Pano answered ${coreRes.status} at ${base}/api/v1/openapi.json. Is this a Pano with the /api/v1 API?`)
|
|
69
|
+
}
|
|
70
|
+
const coreDoc = parseJson(coreRes.text, `${base}/api/v1/openapi.json`)
|
|
71
|
+
let coreSnapshot = join(dir, 'core', 'openapi.json')
|
|
72
|
+
if (!existsSync(coreSnapshot)) {
|
|
73
|
+
let installed = options.installedCore
|
|
74
|
+
if (installed === undefined) {
|
|
75
|
+
try {
|
|
76
|
+
installed = createRequire(join(cwd, 'package.json')).resolve('@panomc/client/core/openapi.json')
|
|
77
|
+
} catch {
|
|
78
|
+
installed = null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
coreSnapshot = installed ?? ''
|
|
82
|
+
}
|
|
83
|
+
if (coreSnapshot && existsSync(coreSnapshot)) diffDocs('core', readJson(coreSnapshot), coreDoc, problems, notes)
|
|
84
|
+
else notes.push('core: no snapshot to compare with, skipped')
|
|
85
|
+
|
|
86
|
+
// ---- plugins
|
|
87
|
+
/** @type {Record<string, any>} */
|
|
88
|
+
const index = existsSync(join(dir, 'plugins', 'index.json')) ? readJson(join(dir, 'plugins', 'index.json')) : {}
|
|
89
|
+
/** @type {Map<string, any>} */
|
|
90
|
+
const packages = new Map()
|
|
91
|
+
const listRes = await getText(`${base}/api/v1/plugin-packages`, fetchOpts)
|
|
92
|
+
if (listRes.ok) {
|
|
93
|
+
const plugins = parseJson(listRes.text, `${base}/api/v1/plugin-packages`)?.plugins ?? {}
|
|
94
|
+
for (const id of Object.keys(plugins)) packages.set(id, plugins[id] ?? {})
|
|
95
|
+
} else {
|
|
96
|
+
notes.push(`plugins: ${base}/api/v1/plugin-packages answered ${listRes.status}, plugins without a UI package are checked by their OpenAPI only`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const ns of Object.keys(index).sort()) {
|
|
100
|
+
const entry = index[ns]
|
|
101
|
+
const pluginId = entry?.pluginId
|
|
102
|
+
if (!isSafeName(ns) || !isSafeName(pluginId)) continue
|
|
103
|
+
const id = encodeURIComponent(pluginId)
|
|
104
|
+
const pkg = packages.get(pluginId)
|
|
105
|
+
|
|
106
|
+
// OpenAPI
|
|
107
|
+
const snapshot = join(dir, 'plugins', ns, 'openapi.json')
|
|
108
|
+
const docRes = await getText(`${base}/api/v1/plugins/${id}/_/openapi.json`, fetchOpts)
|
|
109
|
+
if (docRes.ok) {
|
|
110
|
+
if (existsSync(snapshot)) diffDocs(ns, readJson(snapshot), parseJson(docRes.text, `${pluginId} openapi.json`), problems, notes)
|
|
111
|
+
else problems.push(`${ns}: now ships an OpenAPI document (run pano-client pull)`)
|
|
112
|
+
} else if (existsSync(snapshot)) {
|
|
113
|
+
if (listRes.ok && !pkg) problems.push(`${ns}: plugin ${pluginId} is no longer active on this Pano (run pano-client pull)`)
|
|
114
|
+
else problems.push(`${ns}: plugin ${pluginId} no longer ships an OpenAPI document (answered ${docRes.status}, run pano-client pull)`)
|
|
115
|
+
continue
|
|
116
|
+
} else if (listRes.ok && !pkg) {
|
|
117
|
+
problems.push(`${ns}: plugin ${pluginId} is no longer active on this Pano (run pano-client pull)`)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// controllers
|
|
122
|
+
if (entry.controllersHash || (pkg && pkg.controllers)) {
|
|
123
|
+
let current = null
|
|
124
|
+
if (pkg && pkg.controllers) {
|
|
125
|
+
const mjs = await getText(`${base}/api/v1/plugins/${id}/_/ui/controllers/controllers.mjs`, fetchOpts)
|
|
126
|
+
const types = await getText(`${base}/api/v1/plugins/${id}/_/ui/contract/controllers.types.js`, fetchOpts)
|
|
127
|
+
if (mjs.ok) current = shortHash(mjs.text + '\0' + (types.ok ? types.text : ''))
|
|
128
|
+
}
|
|
129
|
+
if (current !== (entry.controllersHash ?? null)) {
|
|
130
|
+
problems.push(`${ns}: controllers changed (run pano-client pull)`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// plugins that appeared since the pull
|
|
136
|
+
for (const [pluginId, pkg] of packages) {
|
|
137
|
+
const ns = pkg && typeof pkg.namespace === 'string' && pkg.namespace ? pkg.namespace : pluginId.replace(/^pano-plugin-/, '')
|
|
138
|
+
if (!index[ns]) notes.push(`${ns}: plugin ${pluginId} is active but was not pulled`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { ok: problems.length === 0, problems, notes }
|
|
142
|
+
}
|
|
143
|
+
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { check } from './check.js'
|
|
5
|
+
import { ClientGenError } from './errors.js'
|
|
6
|
+
import { generateClient } from './generate.js'
|
|
7
|
+
import { scaffold } from './new.js'
|
|
8
|
+
import { pull, writeFiles } from './pull.js'
|
|
9
|
+
|
|
10
|
+
export const USAGE = `pano-client - typed client generator for Pano
|
|
11
|
+
|
|
12
|
+
pano-client generate --input <openapi.json> --out <dir>
|
|
13
|
+
pano-client pull --url <pano> [--out src/lib/pano] [--plugin <pluginId>]...
|
|
14
|
+
pano-client check --url <pano> [--dir src/lib/pano]
|
|
15
|
+
pano-client new <dir> [--url <pano>] [--from <dir>] [--no-install]
|
|
16
|
+
`
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string[]} argv arguments after the program name
|
|
20
|
+
* @param {{ cwd?: string, stdout?: (s: string) => void, stderr?: (s: string) => void, fetch?: typeof fetch }} [io]
|
|
21
|
+
* @returns {Promise<number>} exit code
|
|
22
|
+
*/
|
|
23
|
+
export async function run(argv, io = {}) {
|
|
24
|
+
const out = io.stdout ?? ((s) => process.stdout.write(s + '\n'))
|
|
25
|
+
const err = io.stderr ?? ((s) => process.stderr.write(s + '\n'))
|
|
26
|
+
const cwd = io.cwd ?? process.cwd()
|
|
27
|
+
const [command, ...rest] = argv
|
|
28
|
+
|
|
29
|
+
if (!command || command === '--help' || command === '-h' || command === 'help') {
|
|
30
|
+
out(USAGE)
|
|
31
|
+
return command ? 0 : 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
switch (command) {
|
|
36
|
+
case 'generate': {
|
|
37
|
+
const { values } = parseArgs({ args: rest, options: { input: { type: 'string' }, out: { type: 'string' } } })
|
|
38
|
+
if (!values.input || !values.out) throw new ClientGenError('generate needs --input <openapi.json> and --out <dir>')
|
|
39
|
+
let text
|
|
40
|
+
try {
|
|
41
|
+
text = readFileSync(resolve(cwd, values.input), 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
throw new ClientGenError(`Cannot read ${resolve(cwd, values.input)}`)
|
|
44
|
+
}
|
|
45
|
+
const gen = generateClient(text)
|
|
46
|
+
gen.warnings.forEach((w) => err(`warning: ${w}`))
|
|
47
|
+
const dir = resolve(cwd, values.out)
|
|
48
|
+
mkdirSync(dir, { recursive: true })
|
|
49
|
+
writeFiles(dir, gen.files)
|
|
50
|
+
out(`generated ${gen.operations.length} operations in ${dir}`)
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
case 'pull': {
|
|
54
|
+
const { values } = parseArgs({
|
|
55
|
+
args: rest,
|
|
56
|
+
options: { url: { type: 'string' }, out: { type: 'string' }, plugin: { type: 'string', multiple: true } }
|
|
57
|
+
})
|
|
58
|
+
await pull({ url: values.url ?? '', out: values.out, plugins: values.plugin, cwd, fetch: io.fetch, log: out })
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
case 'check': {
|
|
62
|
+
const { values } = parseArgs({ args: rest, options: { url: { type: 'string' }, dir: { type: 'string' } } })
|
|
63
|
+
const report = await check({ url: values.url ?? '', dir: values.dir, cwd, fetch: io.fetch })
|
|
64
|
+
report.notes.forEach((n) => out(n))
|
|
65
|
+
if (report.ok) {
|
|
66
|
+
out('ok: the generated client matches this Pano')
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
report.problems.forEach((p) => err(p))
|
|
70
|
+
err('The generated client is out of date. Run pano-client pull.')
|
|
71
|
+
return 1
|
|
72
|
+
}
|
|
73
|
+
case 'new': {
|
|
74
|
+
const { values, positionals } = parseArgs({
|
|
75
|
+
args: rest,
|
|
76
|
+
allowPositionals: true,
|
|
77
|
+
options: { url: { type: 'string' }, from: { type: 'string' }, 'no-install': { type: 'boolean' } }
|
|
78
|
+
})
|
|
79
|
+
const r = await scaffold({
|
|
80
|
+
dir: positionals[0] ?? '',
|
|
81
|
+
url: values.url,
|
|
82
|
+
from: values.from,
|
|
83
|
+
install: !values['no-install'],
|
|
84
|
+
cwd,
|
|
85
|
+
fetch: io.fetch,
|
|
86
|
+
log: out
|
|
87
|
+
})
|
|
88
|
+
out(`\nNext: cd ${positionals[0]} && bun dev`)
|
|
89
|
+
return r.dir ? 0 : 1
|
|
90
|
+
}
|
|
91
|
+
default:
|
|
92
|
+
err(`Unknown command "${command}".\n\n${USAGE}`)
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e instanceof ClientGenError) {
|
|
97
|
+
err(e.message)
|
|
98
|
+
return 1
|
|
99
|
+
}
|
|
100
|
+
if (e && typeof e === 'object' && /** @type {any} */ (e).code?.startsWith?.('ERR_PARSE_ARGS')) {
|
|
101
|
+
err(`${/** @type {Error} */ (e).message}\n\n${USAGE}`)
|
|
102
|
+
return 1
|
|
103
|
+
}
|
|
104
|
+
throw e
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/errors.js
ADDED
package/src/generate.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// OpenAPI 3.1 document -> index.js, types.js, operations.js, openapi.json (doc 06 §2.3). Pure and deterministic.
|
|
2
|
+
import { canonicalJson, sortKeys } from './canonical.js'
|
|
3
|
+
import { ClientGenError } from './errors.js'
|
|
4
|
+
import { collectOperations, pascal } from './operations.js'
|
|
5
|
+
import { isIdent, objectProperties, oneLine, propKey, quote, schemaType } from './schema.js'
|
|
6
|
+
|
|
7
|
+
const HEADER = '// Generated by pano-client from openapi.json. Do not edit; run pano-client again.\n'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} GenerateResult
|
|
11
|
+
* @property {Record<string, string>} files file name -> content: index.js, types.js, operations.js, openapi.json
|
|
12
|
+
* @property {string[]} warnings
|
|
13
|
+
* @property {import('./operations.js').Operation[]} operations
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {any} input parsed OpenAPI 3.1 document, or its JSON text
|
|
18
|
+
* @returns {GenerateResult}
|
|
19
|
+
*/
|
|
20
|
+
export function generateClient(input) {
|
|
21
|
+
/** @type {any} */
|
|
22
|
+
let doc = input
|
|
23
|
+
if (typeof input === 'string') {
|
|
24
|
+
try {
|
|
25
|
+
doc = JSON.parse(input)
|
|
26
|
+
} catch (e) {
|
|
27
|
+
throw new ClientGenError(`The OpenAPI document is not valid JSON: ${/** @type {Error} */ (e).message}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!doc || typeof doc !== 'object' || typeof doc.paths !== 'object') {
|
|
31
|
+
throw new ClientGenError('The input is not an OpenAPI document (no "paths" object).')
|
|
32
|
+
}
|
|
33
|
+
doc = sortKeys(doc)
|
|
34
|
+
|
|
35
|
+
/** @type {string[]} */
|
|
36
|
+
const warnings = []
|
|
37
|
+
const warn = (/** @type {string} */ m) => warnings.push(m)
|
|
38
|
+
const operations = collectOperations(doc, warn)
|
|
39
|
+
|
|
40
|
+
// type names: components.schemas first, then the per-operation aliases
|
|
41
|
+
const schemas = doc.components && typeof doc.components.schemas === 'object' && doc.components.schemas ? doc.components.schemas : {}
|
|
42
|
+
/** @type {Map<string, string>} */
|
|
43
|
+
const typeNames = new Map()
|
|
44
|
+
const taken = new Set()
|
|
45
|
+
for (const raw of Object.keys(schemas)) {
|
|
46
|
+
let name = isIdent(raw) ? raw : pascal(raw) || 'Schema'
|
|
47
|
+
if (/^[0-9]/.test(name)) name = '_' + name
|
|
48
|
+
let unique = name
|
|
49
|
+
for (let n = 2; taken.has(unique); n++) unique = `${name}${n}`
|
|
50
|
+
if (unique !== name) warn(`schema "${raw}" maps to a type name that is taken, using "${unique}"`)
|
|
51
|
+
taken.add(unique)
|
|
52
|
+
typeNames.set(raw, unique)
|
|
53
|
+
}
|
|
54
|
+
const claim = (/** @type {string} */ base) => {
|
|
55
|
+
let name = base
|
|
56
|
+
if (taken.has(name)) name = base + 'Operation'
|
|
57
|
+
for (let n = 2; taken.has(name); n++) name = `${base}Operation${n}`
|
|
58
|
+
taken.add(name)
|
|
59
|
+
return name
|
|
60
|
+
}
|
|
61
|
+
const refIn = (/** @type {boolean} */ local) => (/** @type {string} */ name) => {
|
|
62
|
+
const t = typeNames.get(name)
|
|
63
|
+
if (t === undefined) {
|
|
64
|
+
warn(`reference to unknown schema "${name}", typed as any`)
|
|
65
|
+
return 'any'
|
|
66
|
+
}
|
|
67
|
+
return local ? t : `import('./types.js').${t}`
|
|
68
|
+
}
|
|
69
|
+
const localCtx = { ref: refIn(true) }
|
|
70
|
+
const importCtx = { ref: refIn(false) }
|
|
71
|
+
|
|
72
|
+
// ---- types.js
|
|
73
|
+
const typeBlocks = []
|
|
74
|
+
for (const raw of [...typeNames.keys()].sort()) {
|
|
75
|
+
const name = /** @type {string} */ (typeNames.get(raw))
|
|
76
|
+
typeBlocks.push(typedef(name, schemas[raw], localCtx))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @type {Map<import('./operations.js').Operation, { response: string, request: string | null }>} */
|
|
80
|
+
const opTypes = new Map()
|
|
81
|
+
for (const op of operations) {
|
|
82
|
+
const base = pascal(op.name)
|
|
83
|
+
let response
|
|
84
|
+
if (op.undocumented) response = 'any'
|
|
85
|
+
else if (op.response.kind === 'json') response = schemaType(op.response.schema, localCtx)
|
|
86
|
+
else if (op.response.kind === 'text') response = 'string'
|
|
87
|
+
else if (op.response.kind === 'blob') response = 'Blob'
|
|
88
|
+
else response = 'void'
|
|
89
|
+
const responseName = claim(`${base}Response`)
|
|
90
|
+
typeBlocks.push(`/** @typedef {${response}} ${responseName} */`)
|
|
91
|
+
|
|
92
|
+
let requestName = null
|
|
93
|
+
if (op.body && !op.undocumented) {
|
|
94
|
+
if (op.body.kind === 'json') {
|
|
95
|
+
requestName = claim(`${base}Request`)
|
|
96
|
+
typeBlocks.push(typedef(requestName, op.body.schema, localCtx))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
opTypes.set(op, { response: responseName, request: requestName })
|
|
100
|
+
}
|
|
101
|
+
const typesJs = HEADER + '\n' + typeBlocks.join('\n\n') + (typeBlocks.length ? '\n\n' : '') + 'export {};\n'
|
|
102
|
+
|
|
103
|
+
// ---- operations.js
|
|
104
|
+
const opLines = operations.map(
|
|
105
|
+
(op) =>
|
|
106
|
+
` ${propKey(op.name)}: { method: ${quote(op.method)}, path: ${quote(op.path)}, auth: ${quote(op.auth)}, mutation: ${op.mutation} }`
|
|
107
|
+
)
|
|
108
|
+
const operationsJs =
|
|
109
|
+
HEADER + '\n' + `export const operations = /** @type {const} */ ({\n${opLines.join(',\n')}${opLines.length ? '\n' : ''}});\n`
|
|
110
|
+
|
|
111
|
+
// ---- index.js
|
|
112
|
+
const fnBlocks = operations.map((op) => {
|
|
113
|
+
const t = /** @type {{ response: string, request: string | null }} */ (opTypes.get(op))
|
|
114
|
+
const { type: paramsType, required } = paramsOf(op, t.request, importCtx)
|
|
115
|
+
const lines = ['/**']
|
|
116
|
+
const doc1 = oneLine(op.summary) || oneLine(op.description) || `${op.method} ${op.path}`
|
|
117
|
+
lines.push(` * ${doc1}`)
|
|
118
|
+
if (op.summary && op.description && oneLine(op.description) !== oneLine(op.summary)) lines.push(` * ${oneLine(op.description)}`)
|
|
119
|
+
if (op.deprecated) lines.push(' * @deprecated')
|
|
120
|
+
lines.push(` * @param {import('@panomc/client').PanoClient} client`)
|
|
121
|
+
lines.push(` * @param {${paramsType}} ${required ? 'params' : '[params]'}`)
|
|
122
|
+
lines.push(
|
|
123
|
+
` * @returns {Promise<import('@panomc/client').ApiResult<import('./types.js').${t.response}>>}`
|
|
124
|
+
)
|
|
125
|
+
lines.push(' */')
|
|
126
|
+
lines.push(`export const ${op.name} = (client, params) => client.request(operations.${op.name}, params);`)
|
|
127
|
+
return lines.join('\n')
|
|
128
|
+
})
|
|
129
|
+
const indexJs = HEADER + "import { operations } from './operations.js';\n\n" + fnBlocks.join('\n\n') + (fnBlocks.length ? '\n' : '')
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
files: { 'index.js': indexJs, 'types.js': typesJs, 'operations.js': operationsJs, 'openapi.json': canonicalJson(doc) },
|
|
133
|
+
warnings,
|
|
134
|
+
operations
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {import('./operations.js').Operation} op
|
|
140
|
+
* @param {string | null} requestName
|
|
141
|
+
* @param {import('./schema.js').TypeContext} ctx
|
|
142
|
+
*/
|
|
143
|
+
function paramsOf(op, requestName, ctx) {
|
|
144
|
+
const members = []
|
|
145
|
+
let required = false
|
|
146
|
+
if (op.undocumented) {
|
|
147
|
+
if (/\{[^}]+\}/.test(op.path)) {
|
|
148
|
+
members.push('path: Record<string, string | number | boolean>')
|
|
149
|
+
required = true
|
|
150
|
+
}
|
|
151
|
+
members.push('query?: Record<string, any>')
|
|
152
|
+
if (op.mutation) members.push('body?: any')
|
|
153
|
+
} else {
|
|
154
|
+
const pathParams = op.parameters.filter((p) => p.in === 'path')
|
|
155
|
+
const queryParams = op.parameters.filter((p) => p.in === 'query')
|
|
156
|
+
if (pathParams.length) {
|
|
157
|
+
members.push(`path: { ${pathParams.map((p) => `${propKey(p.name)}: ${schemaType(p.schema, ctx)}`).join(', ')} }`)
|
|
158
|
+
required = true
|
|
159
|
+
}
|
|
160
|
+
if (queryParams.length) {
|
|
161
|
+
const anyRequired = queryParams.some((p) => p.required)
|
|
162
|
+
members.push(
|
|
163
|
+
`query${anyRequired ? '' : '?'}: { ${queryParams.map((p) => `${propKey(p.name)}${p.required ? '' : '?'}: ${schemaType(p.schema, ctx)}`).join(', ')} }`
|
|
164
|
+
)
|
|
165
|
+
if (anyRequired) required = true
|
|
166
|
+
}
|
|
167
|
+
if (op.body) {
|
|
168
|
+
const type =
|
|
169
|
+
op.body.kind === 'form' ? 'FormData' : op.body.kind === 'json' && requestName ? `import('./types.js').${requestName}` : 'any'
|
|
170
|
+
members.push(`body${op.body.required ? '' : '?'}: ${type}`)
|
|
171
|
+
if (op.body.required) required = true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
members.push('signal?: AbortSignal')
|
|
175
|
+
return { type: `{ ${members.join(', ')} }`, required }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {string} name
|
|
180
|
+
* @param {any} schema
|
|
181
|
+
* @param {import('./schema.js').TypeContext} ctx
|
|
182
|
+
*/
|
|
183
|
+
function typedef(name, schema, ctx) {
|
|
184
|
+
const props = objectProperties(schema, ctx)
|
|
185
|
+
const description = schema && typeof schema === 'object' ? oneLine(schema.description) : ''
|
|
186
|
+
const deprecated = schema && typeof schema === 'object' && schema.deprecated === true
|
|
187
|
+
if (props) {
|
|
188
|
+
const lines = ['/**']
|
|
189
|
+
if (description) lines.push(` * ${description}`)
|
|
190
|
+
if (deprecated) lines.push(' * @deprecated')
|
|
191
|
+
lines.push(` * @typedef {Object} ${name}`)
|
|
192
|
+
for (const p of props) {
|
|
193
|
+
lines.push(` * @property {${p.type}} ${p.optional ? `[${p.name}]` : p.name}${p.description ? ` ${p.description}` : ''}`)
|
|
194
|
+
}
|
|
195
|
+
lines.push(' */')
|
|
196
|
+
return lines.join('\n')
|
|
197
|
+
}
|
|
198
|
+
const type = schemaType(schema, ctx)
|
|
199
|
+
if (!description && !deprecated) return `/** @typedef {${type}} ${name} */`
|
|
200
|
+
return ['/**', ...(description ? [` * ${description}`] : []), ...(deprecated ? [' * @deprecated'] : []), ` * @typedef {${type}} ${name}`, ' */'].join('\n')
|
|
201
|
+
}
|