@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
package/src/pull.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// pull: core document + every active plugin's OpenAPI and controllers from a running Pano (doc 06 §2.4).
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { canonicalJson, shortHash } from './canonical.js'
|
|
6
|
+
import { ClientGenError } from './errors.js'
|
|
7
|
+
import { generateClient } from './generate.js'
|
|
8
|
+
import { getText, isSafeName, namespaceOf, parseJson, trimBase, unreachableMessage } from './http.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} PullOptions
|
|
12
|
+
* @property {string} url
|
|
13
|
+
* @property {string} [out] default src/lib/pano
|
|
14
|
+
* @property {string[]} [plugins] extra plugin ids that have no UI package
|
|
15
|
+
* @property {string} [cwd]
|
|
16
|
+
* @property {typeof fetch} [fetch]
|
|
17
|
+
* @property {string | null} [installedCore] path of the installed @panomc/client core/openapi.json; default: resolved from cwd
|
|
18
|
+
* @property {(line: string) => void} [log]
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} PullReport
|
|
23
|
+
* @property {'written'|'skipped'} core
|
|
24
|
+
* @property {{ pluginId: string, ns: string, openapi: boolean, controllers: boolean }[]} plugins
|
|
25
|
+
* @property {string[]} warnings
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @param {string} cwd */
|
|
29
|
+
function findInstalledCore(cwd) {
|
|
30
|
+
try {
|
|
31
|
+
return createRequire(join(cwd, 'package.json')).resolve('@panomc/client/core/openapi.json')
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {string} dir @param {Record<string, string>} files */
|
|
38
|
+
export function writeFiles(dir, files) {
|
|
39
|
+
for (const [name, content] of Object.entries(files)) {
|
|
40
|
+
const target = join(dir, name)
|
|
41
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
42
|
+
writeFileSync(target, content)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {PullOptions} options
|
|
48
|
+
* @returns {Promise<PullReport>}
|
|
49
|
+
*/
|
|
50
|
+
export async function pull(options) {
|
|
51
|
+
if (!options.url) throw new ClientGenError('pull 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 out = resolve(cwd, options.out ?? 'src/lib/pano')
|
|
55
|
+
const log = options.log ?? (() => {})
|
|
56
|
+
const fetchOpts = { fetch: options.fetch, onUnreachable: () => unreachableMessage(base) }
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
const warnings = []
|
|
59
|
+
const warn = (/** @type {string} */ m) => {
|
|
60
|
+
warnings.push(m)
|
|
61
|
+
log(`warning: ${m}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- core
|
|
65
|
+
const coreRes = await getText(`${base}/api/v1/openapi.json`, fetchOpts)
|
|
66
|
+
if (!coreRes.ok) {
|
|
67
|
+
throw new ClientGenError(`Pano answered ${coreRes.status} at ${base}/api/v1/openapi.json. Is this a Pano with the /api/v1 API?`)
|
|
68
|
+
}
|
|
69
|
+
const coreDoc = parseJson(coreRes.text, `${base}/api/v1/openapi.json`)
|
|
70
|
+
const coreCanonical = canonicalJson(coreDoc)
|
|
71
|
+
const installedPath = options.installedCore === undefined ? findInstalledCore(cwd) : options.installedCore
|
|
72
|
+
let coreState = /** @type {'written'|'skipped'} */ ('written')
|
|
73
|
+
if (installedPath && existsSync(installedPath) && readFileSync(installedPath, 'utf8') === coreCanonical) {
|
|
74
|
+
coreState = 'skipped'
|
|
75
|
+
log('core: matches the installed @panomc/client, import from @panomc/client/core')
|
|
76
|
+
} else {
|
|
77
|
+
const gen = generateClient(coreDoc)
|
|
78
|
+
gen.warnings.forEach((w) => warn(`core: ${w}`))
|
|
79
|
+
writeFiles(join(out, 'core'), gen.files)
|
|
80
|
+
log(`core: ${gen.operations.length} operations written to ${join(out, 'core')}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- plugin package list
|
|
84
|
+
/** @type {Map<string, any>} */
|
|
85
|
+
const packages = new Map()
|
|
86
|
+
const listRes = await getText(`${base}/api/v1/plugin-packages`, fetchOpts)
|
|
87
|
+
if (listRes.ok) {
|
|
88
|
+
const list = parseJson(listRes.text, `${base}/api/v1/plugin-packages`)
|
|
89
|
+
const plugins = list && typeof list.plugins === 'object' && list.plugins ? list.plugins : {}
|
|
90
|
+
for (const id of Object.keys(plugins).sort()) packages.set(id, plugins[id] ?? {})
|
|
91
|
+
} else {
|
|
92
|
+
warn(`${base}/api/v1/plugin-packages answered ${listRes.status}; only plugins named with --plugin are pulled`)
|
|
93
|
+
}
|
|
94
|
+
for (const id of options.plugins ?? []) if (!packages.has(id)) packages.set(id, null)
|
|
95
|
+
|
|
96
|
+
// ---- per plugin
|
|
97
|
+
/** @type {Record<string, { pluginId: string, version: string | null, operationsHash: string | null, controllersHash: string | null }>} */
|
|
98
|
+
const index = {}
|
|
99
|
+
/** @type {PullReport['plugins']} */
|
|
100
|
+
const reported = []
|
|
101
|
+
const indexPath = join(out, 'plugins', 'index.json')
|
|
102
|
+
/** @type {Record<string, any>} */
|
|
103
|
+
let previous = {}
|
|
104
|
+
try {
|
|
105
|
+
previous = JSON.parse(readFileSync(indexPath, 'utf8'))
|
|
106
|
+
} catch {}
|
|
107
|
+
|
|
108
|
+
for (const [pluginId, pkg] of packages) {
|
|
109
|
+
if (!isSafeName(pluginId)) {
|
|
110
|
+
warn(`plugin id "${pluginId}" is not a valid id, skipped`)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const ns = pkg && typeof pkg.namespace === 'string' && pkg.namespace ? pkg.namespace : namespaceOf(pluginId)
|
|
114
|
+
if (!isSafeName(ns) || index[ns]) {
|
|
115
|
+
warn(`plugin ${pluginId}: namespace "${ns}" is invalid or already taken, skipped`)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const id = encodeURIComponent(pluginId)
|
|
119
|
+
const pluginDir = join(out, 'plugins', ns)
|
|
120
|
+
|
|
121
|
+
// OpenAPI
|
|
122
|
+
let operationsHash = null
|
|
123
|
+
let openapi = false
|
|
124
|
+
const docRes = await getText(`${base}/api/v1/plugins/${id}/_/openapi.json`, fetchOpts)
|
|
125
|
+
if (docRes.ok) {
|
|
126
|
+
const doc = parseJson(docRes.text, `${base}/api/v1/plugins/${pluginId}/_/openapi.json`)
|
|
127
|
+
const gen = generateClient(doc)
|
|
128
|
+
gen.warnings.forEach((w) => warn(`${ns}: ${w}`))
|
|
129
|
+
writeFiles(pluginDir, gen.files)
|
|
130
|
+
operationsHash = shortHash(gen.files['openapi.json'])
|
|
131
|
+
openapi = true
|
|
132
|
+
log(`${ns}: ${gen.operations.length} operations written to ${pluginDir}`)
|
|
133
|
+
} else if (docRes.status === 404) {
|
|
134
|
+
warn(`plugin ${pluginId} ships no OpenAPI (no client generated)`)
|
|
135
|
+
} else {
|
|
136
|
+
warn(`plugin ${pluginId}: ${base}/api/v1/plugins/${pluginId}/_/openapi.json answered ${docRes.status}, no client generated`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// controllers
|
|
140
|
+
let controllersHash = null
|
|
141
|
+
let controllers = false
|
|
142
|
+
if (pkg && pkg.controllers) {
|
|
143
|
+
const mjs = await getText(`${base}/api/v1/plugins/${id}/_/ui/controllers/controllers.mjs`, fetchOpts)
|
|
144
|
+
const types = await getText(`${base}/api/v1/plugins/${id}/_/ui/contract/controllers.types.js`, fetchOpts)
|
|
145
|
+
if (mjs.ok) {
|
|
146
|
+
/** @type {Record<string, string>} */
|
|
147
|
+
const files = { 'controllers.mjs': mjs.text }
|
|
148
|
+
if (types.ok) files['controllers.types.js'] = types.text
|
|
149
|
+
else warn(`plugin ${pluginId}: controllers.types.js answered ${types.status}, controllers are copied without types`)
|
|
150
|
+
writeFiles(join(pluginDir, 'controllers'), files)
|
|
151
|
+
controllersHash = shortHash(mjs.text + '\0' + (types.ok ? types.text : ''))
|
|
152
|
+
controllers = true
|
|
153
|
+
log(`${ns}: controllers copied`)
|
|
154
|
+
} else {
|
|
155
|
+
warn(`plugin ${pluginId}: controllers.mjs answered ${mjs.status}, no controllers copied`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
index[ns] = {
|
|
160
|
+
pluginId,
|
|
161
|
+
version: pkg && typeof pkg.version === 'string' ? pkg.version : null,
|
|
162
|
+
operationsHash,
|
|
163
|
+
controllersHash
|
|
164
|
+
}
|
|
165
|
+
reported.push({ pluginId, ns, openapi, controllers })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// generated folders of plugins that are gone (only those a previous pull recorded)
|
|
169
|
+
for (const ns of Object.keys(previous)) {
|
|
170
|
+
if (!index[ns] && isSafeName(ns)) rmSync(join(out, 'plugins', ns), { recursive: true, force: true })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sorted = Object.fromEntries(Object.keys(index).sort().map((k) => [k, index[k]]))
|
|
174
|
+
writeFiles(join(out, 'plugins'), { 'index.json': JSON.stringify(sorted, null, 2) + '\n' })
|
|
175
|
+
log(`plugins: ${Object.keys(sorted).length} recorded in ${indexPath}`)
|
|
176
|
+
|
|
177
|
+
return { core: coreState, plugins: reported, warnings }
|
|
178
|
+
}
|
package/src/pull.test.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { canonicalJson, shortHash } from './canonical.js'
|
|
5
|
+
import { check } from './check.js'
|
|
6
|
+
import { run } from './cli.js'
|
|
7
|
+
import { ClientGenError } from './errors.js'
|
|
8
|
+
import { pull } from './pull.js'
|
|
9
|
+
import { FIXTURE, tmp } from './test-helpers.js'
|
|
10
|
+
|
|
11
|
+
const core = JSON.parse(readFileSync(FIXTURE, 'utf8'))
|
|
12
|
+
|
|
13
|
+
const marketDoc = {
|
|
14
|
+
openapi: '3.1.0',
|
|
15
|
+
info: { title: 'market', version: '1' },
|
|
16
|
+
servers: [{ url: '/api/plugins/pano-plugin-market' }],
|
|
17
|
+
paths: {
|
|
18
|
+
'/store/products': {
|
|
19
|
+
get: { operationId: 'ListProducts', responses: { 200: { content: { 'application/json': { schema: { type: 'object', properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Product' } } } } } } } } }
|
|
20
|
+
},
|
|
21
|
+
'/store/products/{id}': {
|
|
22
|
+
get: { operationId: 'GetProduct', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Product' } } } } } }
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
components: { schemas: { Product: { type: 'object', required: ['id'], properties: { id: { type: 'integer' }, name: { type: 'string' } } } } }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const extraDoc = {
|
|
29
|
+
openapi: '3.1.0',
|
|
30
|
+
servers: [{ url: '/api/plugins/pano-plugin-extra' }],
|
|
31
|
+
paths: { '/ping': { get: { operationId: 'Ping', responses: { 200: { description: 'pong' } } } } }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** the stub's world; tests mutate it */
|
|
35
|
+
function freshWorld() {
|
|
36
|
+
return {
|
|
37
|
+
core: structuredClone(core),
|
|
38
|
+
packages: /** @type {Record<string, any>} */ ({
|
|
39
|
+
'pano-plugin-market': { format: 1, pluginId: 'pano-plugin-market', namespace: 'market', version: '1.2.0', controllers: 'contract/controllers.json' },
|
|
40
|
+
'pano-plugin-faq': { format: 1, pluginId: 'pano-plugin-faq', namespace: 'faq', version: '0.3.1' }
|
|
41
|
+
}),
|
|
42
|
+
docs: /** @type {Record<string, any>} */ ({ 'pano-plugin-market': structuredClone(marketDoc), 'pano-plugin-extra': extraDoc }),
|
|
43
|
+
controllers: 'export const controllers = { cart: {} }\n',
|
|
44
|
+
types: '/** @typedef {{ count: number }} CartState */\nexport {};\n',
|
|
45
|
+
requests: /** @type {string[]} */ ([]),
|
|
46
|
+
listBroken: false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @type {ReturnType<typeof freshWorld>} */
|
|
51
|
+
let world
|
|
52
|
+
/** @type {any} */
|
|
53
|
+
let server
|
|
54
|
+
let base = ''
|
|
55
|
+
const dirs = []
|
|
56
|
+
|
|
57
|
+
beforeAll(() => {
|
|
58
|
+
world = freshWorld()
|
|
59
|
+
server = Bun.serve({
|
|
60
|
+
port: 0,
|
|
61
|
+
hostname: '127.0.0.1',
|
|
62
|
+
fetch(req) {
|
|
63
|
+
const path = new URL(req.url).pathname
|
|
64
|
+
world.requests.push(path)
|
|
65
|
+
const json = (/** @type {any} */ v) => new Response(JSON.stringify(v), { headers: { 'content-type': 'application/json' } })
|
|
66
|
+
if (path === '/api/v1/openapi.json') return json(world.core)
|
|
67
|
+
if (path === '/api/v1/plugin-packages') return world.listBroken ? new Response('nope', { status: 404 }) : json({ plugins: world.packages })
|
|
68
|
+
const m = /^\/api\/v1\/plugins\/([^/]+)\/_\/(.+)$/.exec(path)
|
|
69
|
+
if (m) {
|
|
70
|
+
const [, id, rest] = m
|
|
71
|
+
if (rest === 'openapi.json') return world.docs[id] ? json(world.docs[id]) : new Response('{"error":{"code":"NOT_FOUND"}}', { status: 404 })
|
|
72
|
+
if (id === 'pano-plugin-market' && rest === 'ui/controllers/controllers.mjs') return new Response(world.controllers, { headers: { 'content-type': 'text/javascript' } })
|
|
73
|
+
if (id === 'pano-plugin-market' && rest === 'ui/contract/controllers.types.js') return new Response(world.types)
|
|
74
|
+
}
|
|
75
|
+
return new Response('{"error":{"code":"NOT_FOUND"}}', { status: 404 })
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
base = `http://127.0.0.1:${server.port}`
|
|
79
|
+
})
|
|
80
|
+
afterAll(() => {
|
|
81
|
+
server.stop(true)
|
|
82
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const newDir = (/** @type {string} */ n) => {
|
|
86
|
+
const d = tmp(n)
|
|
87
|
+
dirs.push(d)
|
|
88
|
+
return d
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('pull', () => {
|
|
92
|
+
test('core, both plugins, controllers and the index', async () => {
|
|
93
|
+
world = Object.assign(world, freshWorld(), { requests: [] })
|
|
94
|
+
const cwd = newDir('pull')
|
|
95
|
+
const logs = []
|
|
96
|
+
const report = await pull({ url: base + '/', cwd, installedCore: null, log: (l) => logs.push(l) })
|
|
97
|
+
const out = join(cwd, 'src/lib/pano')
|
|
98
|
+
|
|
99
|
+
expect(report.core).toBe('written')
|
|
100
|
+
for (const f of ['index.js', 'types.js', 'operations.js', 'openapi.json']) {
|
|
101
|
+
expect(existsSync(join(out, 'core', f))).toBe(true)
|
|
102
|
+
expect(existsSync(join(out, 'plugins/market', f))).toBe(true)
|
|
103
|
+
}
|
|
104
|
+
expect(readFileSync(join(out, 'core/openapi.json'), 'utf8')).toBe(canonicalJson(core))
|
|
105
|
+
expect(readFileSync(join(out, 'plugins/market/index.js'), 'utf8')).toContain('export const listProducts')
|
|
106
|
+
expect(readFileSync(join(out, 'plugins/market/controllers/controllers.mjs'), 'utf8')).toBe(world.controllers)
|
|
107
|
+
expect(readFileSync(join(out, 'plugins/market/controllers/controllers.types.js'), 'utf8')).toBe(world.types)
|
|
108
|
+
|
|
109
|
+
// faq ships no OpenAPI: no folder, a warning, but it is recorded
|
|
110
|
+
expect(existsSync(join(out, 'plugins/faq'))).toBe(false)
|
|
111
|
+
expect(report.warnings).toContain('plugin pano-plugin-faq ships no OpenAPI (no client generated)')
|
|
112
|
+
expect(logs).toContain('warning: plugin pano-plugin-faq ships no OpenAPI (no client generated)')
|
|
113
|
+
|
|
114
|
+
const index = JSON.parse(readFileSync(join(out, 'plugins/index.json'), 'utf8'))
|
|
115
|
+
expect(Object.keys(index)).toEqual(['faq', 'market'])
|
|
116
|
+
expect(index.faq).toEqual({ pluginId: 'pano-plugin-faq', version: '0.3.1', operationsHash: null, controllersHash: null })
|
|
117
|
+
expect(index.market).toEqual({
|
|
118
|
+
pluginId: 'pano-plugin-market',
|
|
119
|
+
version: '1.2.0',
|
|
120
|
+
operationsHash: shortHash(canonicalJson(marketDoc)),
|
|
121
|
+
controllersHash: shortHash(world.controllers + '\0' + world.types)
|
|
122
|
+
})
|
|
123
|
+
// the plugin documents were read from the plugin paths, the controllers from _/ui
|
|
124
|
+
expect(world.requests).toContain('/api/v1/plugins/pano-plugin-market/_/openapi.json')
|
|
125
|
+
expect(world.requests).toContain('/api/v1/plugins/pano-plugin-market/_/ui/controllers/controllers.mjs')
|
|
126
|
+
expect(world.requests).toContain('/api/v1/plugins/pano-plugin-market/_/ui/contract/controllers.types.js')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('a second pull writes identical bytes', async () => {
|
|
130
|
+
world = Object.assign(world, freshWorld())
|
|
131
|
+
const cwd = newDir('pull2')
|
|
132
|
+
await pull({ url: base, cwd, installedCore: null })
|
|
133
|
+
const read = () => ['core/index.js', 'plugins/market/types.js', 'plugins/index.json'].map((f) => readFileSync(join(cwd, 'src/lib/pano', f), 'utf8'))
|
|
134
|
+
const first = read()
|
|
135
|
+
await pull({ url: base, cwd, installedCore: null })
|
|
136
|
+
expect(read()).toEqual(first)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('core/ is skipped when the installed @panomc/client snapshot is byte-equal', async () => {
|
|
140
|
+
world = Object.assign(world, freshWorld())
|
|
141
|
+
const cwd = newDir('pull-skip')
|
|
142
|
+
const installed = join(cwd, 'installed-openapi.json')
|
|
143
|
+
writeFileSync(installed, canonicalJson(core))
|
|
144
|
+
const report = await pull({ url: base, cwd, installedCore: installed })
|
|
145
|
+
expect(report.core).toBe('skipped')
|
|
146
|
+
expect(existsSync(join(cwd, 'src/lib/pano/core'))).toBe(false)
|
|
147
|
+
expect(existsSync(join(cwd, 'src/lib/pano/plugins/market/index.js'))).toBe(true)
|
|
148
|
+
|
|
149
|
+
writeFileSync(installed, canonicalJson({ ...core, info: { title: 'older', version: '0' } }))
|
|
150
|
+
expect((await pull({ url: base, cwd, installedCore: installed })).core).toBe('written')
|
|
151
|
+
expect(existsSync(join(cwd, 'src/lib/pano/core/index.js'))).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('--plugin adds a plugin that is not in the package list; --out is honoured', async () => {
|
|
155
|
+
world = Object.assign(world, freshWorld())
|
|
156
|
+
const cwd = newDir('pull-plugin')
|
|
157
|
+
const report = await pull({ url: base, cwd, out: 'gen', plugins: ['pano-plugin-extra', 'pano-plugin-market'], installedCore: null })
|
|
158
|
+
expect(report.plugins.map((p) => p.ns).sort()).toEqual(['extra', 'faq', 'market'])
|
|
159
|
+
expect(readFileSync(join(cwd, 'gen/plugins/extra/index.js'), 'utf8')).toContain('export const ping')
|
|
160
|
+
const index = JSON.parse(readFileSync(join(cwd, 'gen/plugins/index.json'), 'utf8'))
|
|
161
|
+
expect(index.extra).toEqual({ pluginId: 'pano-plugin-extra', version: null, operationsHash: shortHash(canonicalJson(extraDoc)), controllersHash: null })
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('an extra plugin without OpenAPI warns and a plugin that left is removed on the next pull', async () => {
|
|
165
|
+
world = Object.assign(world, freshWorld())
|
|
166
|
+
const cwd = newDir('pull-gone')
|
|
167
|
+
await pull({ url: base, cwd, installedCore: null })
|
|
168
|
+
expect(existsSync(join(cwd, 'src/lib/pano/plugins/market'))).toBe(true)
|
|
169
|
+
delete world.packages['pano-plugin-market']
|
|
170
|
+
const report = await pull({ url: base, cwd, installedCore: null, plugins: ['pano-plugin-ghost'] })
|
|
171
|
+
expect(report.warnings).toContain('plugin pano-plugin-ghost ships no OpenAPI (no client generated)')
|
|
172
|
+
expect(existsSync(join(cwd, 'src/lib/pano/plugins/market'))).toBe(false)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('unsafe plugin ids are skipped', async () => {
|
|
176
|
+
world = Object.assign(world, freshWorld())
|
|
177
|
+
const cwd = newDir('pull-unsafe')
|
|
178
|
+
const report = await pull({ url: base, cwd, installedCore: null, plugins: ['../evil'] })
|
|
179
|
+
expect(report.warnings.some((w) => w.includes('"../evil" is not a valid id'))).toBe(true)
|
|
180
|
+
expect(existsSync(join(cwd, 'src/lib/evil'))).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('a Pano without the package list still pulls core and named plugins', async () => {
|
|
184
|
+
world = Object.assign(world, freshWorld(), { listBroken: true })
|
|
185
|
+
const cwd = newDir('pull-nolist')
|
|
186
|
+
const report = await pull({ url: base, cwd, installedCore: null, plugins: ['pano-plugin-extra'] })
|
|
187
|
+
world.listBroken = false
|
|
188
|
+
expect(report.warnings.some((w) => w.includes('/api/v1/plugin-packages answered 404'))).toBe(true)
|
|
189
|
+
expect(report.plugins.map((p) => p.ns)).toEqual(['extra'])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('unreachable Pano: the exact message', async () => {
|
|
193
|
+
const dead = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('x') })
|
|
194
|
+
const url = `http://127.0.0.1:${dead.port}`
|
|
195
|
+
dead.stop(true)
|
|
196
|
+
const cwd = newDir('pull-dead')
|
|
197
|
+
await expect(pull({ url, cwd, installedCore: null })).rejects.toThrow(
|
|
198
|
+
`Pano did not answer at ${url}/api/v1/openapi.json. Is it running? Try --url http://localhost:8088`
|
|
199
|
+
)
|
|
200
|
+
const errs = []
|
|
201
|
+
const code = await run(['pull', '--url', url], { cwd, stderr: (s) => errs.push(s) })
|
|
202
|
+
expect(code).toBe(1)
|
|
203
|
+
expect(errs).toEqual([`Pano did not answer at ${url}/api/v1/openapi.json. Is it running? Try --url http://localhost:8088`])
|
|
204
|
+
expect(existsSync(join(cwd, 'src'))).toBe(false)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('missing --url', async () => {
|
|
208
|
+
await expect(pull({ url: '' })).rejects.toBeInstanceOf(ClientGenError)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('cli: pull with --plugin twice', async () => {
|
|
212
|
+
world = Object.assign(world, freshWorld())
|
|
213
|
+
const cwd = newDir('pull-cli')
|
|
214
|
+
const outs = []
|
|
215
|
+
const code = await run(['pull', '--url', base, '--out', 'o', '--plugin', 'pano-plugin-extra', '--plugin', 'pano-plugin-ghost'], { cwd, stdout: (s) => outs.push(s), stderr: () => {} })
|
|
216
|
+
expect(code).toBe(0)
|
|
217
|
+
expect(existsSync(join(cwd, 'o/plugins/extra/index.js'))).toBe(true)
|
|
218
|
+
expect(outs.some((l) => l.includes('plugin pano-plugin-ghost ships no OpenAPI (no client generated)'))).toBe(true)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('check', () => {
|
|
223
|
+
/** pulls the fresh world into a new dir and returns { cwd } */
|
|
224
|
+
async function pulled() {
|
|
225
|
+
world = Object.assign(world, freshWorld())
|
|
226
|
+
const cwd = newDir('check')
|
|
227
|
+
await pull({ url: base, cwd, installedCore: null })
|
|
228
|
+
return cwd
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
test('unchanged Pano: ok, exit 0', async () => {
|
|
232
|
+
const cwd = await pulled()
|
|
233
|
+
const report = await check({ url: base, cwd, installedCore: null })
|
|
234
|
+
expect(report).toEqual({ ok: true, problems: [], notes: [] })
|
|
235
|
+
const outs = []
|
|
236
|
+
expect(await run(['check', '--url', base, '--dir', 'src/lib/pano'], { cwd, stdout: (s) => outs.push(s), stderr: () => {} })).toBe(0)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('removed and changed operations, a new one is only a note', async () => {
|
|
240
|
+
const cwd = await pulled()
|
|
241
|
+
delete world.core.paths['/api/v1/posts/{url}'].delete
|
|
242
|
+
world.core.paths['/api/v1/posts'].get.parameters.push({ name: 'q', in: 'query', schema: { type: 'string' } })
|
|
243
|
+
world.core.components.schemas.Author.properties.nick = { type: 'string' } // changes GetPost / GetPosts / CreatePost responses
|
|
244
|
+
world.core.paths['/api/v1/hello'] = { get: { operationId: 'Hello', responses: { 200: { description: 'hi' } } } }
|
|
245
|
+
const report = await check({ url: base, cwd, installedCore: null })
|
|
246
|
+
expect(report.ok).toBe(false)
|
|
247
|
+
expect(report.problems).toEqual([
|
|
248
|
+
'core: changed createPost (POST /api/v1/posts)',
|
|
249
|
+
'core: removed deletePost (DELETE /api/v1/posts/{url})',
|
|
250
|
+
'core: changed getPost (GET /api/v1/posts/{url})',
|
|
251
|
+
'core: changed getPosts (GET /api/v1/posts)'
|
|
252
|
+
])
|
|
253
|
+
expect(report.notes).toContain('core: new hello (GET /api/v1/hello)')
|
|
254
|
+
|
|
255
|
+
const errs = []
|
|
256
|
+
expect(await run(['check', '--url', base], { cwd, stdout: () => {}, stderr: (s) => errs.push(s) })).toBe(1)
|
|
257
|
+
expect(errs).toContain('core: removed deletePost (DELETE /api/v1/posts/{url})')
|
|
258
|
+
expect(errs.at(-1)).toBe('The generated client is out of date. Run pano-client pull.')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('controllersHash differs -> run pano-client pull', async () => {
|
|
262
|
+
const cwd = await pulled()
|
|
263
|
+
world.controllers += '// new action\n'
|
|
264
|
+
const report = await check({ url: base, cwd, installedCore: null })
|
|
265
|
+
expect(report.ok).toBe(false)
|
|
266
|
+
expect(report.problems).toEqual(['market: controllers changed (run pano-client pull)'])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('plugin operation removed, and a plugin that is gone', async () => {
|
|
270
|
+
const cwd = await pulled()
|
|
271
|
+
delete world.docs['pano-plugin-market'].paths['/store/products']
|
|
272
|
+
let report = await check({ url: base, cwd, installedCore: null })
|
|
273
|
+
expect(report.problems).toEqual(['market: removed listProducts (GET /api/plugins/pano-plugin-market/store/products)'])
|
|
274
|
+
|
|
275
|
+
delete world.packages['pano-plugin-market']
|
|
276
|
+
delete world.docs['pano-plugin-market']
|
|
277
|
+
report = await check({ url: base, cwd, installedCore: null })
|
|
278
|
+
expect(report.problems).toEqual(['market: plugin pano-plugin-market is no longer active on this Pano (run pano-client pull)'])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('core snapshot comes from the installed package when core/ was skipped', async () => {
|
|
282
|
+
world = Object.assign(world, freshWorld())
|
|
283
|
+
const cwd = newDir('check-installed')
|
|
284
|
+
const installed = join(cwd, 'installed.json')
|
|
285
|
+
writeFileSync(installed, canonicalJson(core))
|
|
286
|
+
await pull({ url: base, cwd, installedCore: installed })
|
|
287
|
+
expect((await check({ url: base, cwd, installedCore: installed })).ok).toBe(true)
|
|
288
|
+
delete world.core.paths['/api/v1/posts/{url}'].delete
|
|
289
|
+
expect((await check({ url: base, cwd, installedCore: installed })).problems).toEqual(['core: removed deletePost (DELETE /api/v1/posts/{url})'])
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('nothing pulled yet, and an unreachable Pano', async () => {
|
|
293
|
+
const empty = newDir('check-empty')
|
|
294
|
+
await expect(check({ url: base, cwd: empty })).rejects.toThrow(`Nothing to check in ${join(empty, 'src/lib/pano')}. Run pano-client pull --url ${base} first.`)
|
|
295
|
+
const cwd = await pulled()
|
|
296
|
+
const dead = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('x') })
|
|
297
|
+
const url = `http://127.0.0.1:${dead.port}`
|
|
298
|
+
dead.stop(true)
|
|
299
|
+
await expect(check({ url, cwd, installedCore: null })).rejects.toThrow(`Pano did not answer at ${url}/api/v1/openapi.json. Is it running? Try --url http://localhost:8088`)
|
|
300
|
+
})
|
|
301
|
+
})
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// OpenAPI 3.1 schema -> JSDoc type string. A plain walk over the JSON, no dependency.
|
|
2
|
+
|
|
3
|
+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
4
|
+
|
|
5
|
+
/** @param {string} s */
|
|
6
|
+
export const isIdent = (s) => IDENT.test(s)
|
|
7
|
+
|
|
8
|
+
/** Property key as it appears in an object type literal. @param {string} s */
|
|
9
|
+
export const propKey = (s) => (isIdent(s) ? s : quote(s))
|
|
10
|
+
|
|
11
|
+
/** @param {string} s */
|
|
12
|
+
export function quote(s) {
|
|
13
|
+
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r?\n/g, '\\n')}'`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Precedence of a rendered type: 0 union, 1 intersection, 2 atom (needs no parentheses anywhere).
|
|
18
|
+
* @typedef {{ s: string, p: number }} Rendered
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** @param {Rendered} r @param {number} min */
|
|
22
|
+
const at = (r, min) => (r.p < min ? `(${r.s})` : r.s)
|
|
23
|
+
|
|
24
|
+
/** @param {string} s @returns {Rendered} */
|
|
25
|
+
const atom = (s) => ({ s, p: 2 })
|
|
26
|
+
|
|
27
|
+
/** @param {Rendered[]} parts @returns {Rendered} */
|
|
28
|
+
function union(parts) {
|
|
29
|
+
const seen = new Set()
|
|
30
|
+
const out = []
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
if (seen.has(part.s)) continue
|
|
33
|
+
seen.add(part.s)
|
|
34
|
+
out.push(part)
|
|
35
|
+
}
|
|
36
|
+
if (out.length === 0) return atom('never')
|
|
37
|
+
if (out.length === 1) return out[0]
|
|
38
|
+
if (out.some((p) => p.s === 'any')) return atom('any')
|
|
39
|
+
return { s: out.map((p) => at(p, 0)).join(' | '), p: 0 }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {Rendered[]} parts @returns {Rendered} */
|
|
43
|
+
function intersection(parts) {
|
|
44
|
+
if (parts.length === 1) return parts[0]
|
|
45
|
+
if (parts.length === 0) return atom('any')
|
|
46
|
+
return { s: parts.map((p) => at(p, 1)).join(' & '), p: 1 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {any} v */
|
|
50
|
+
function literal(v) {
|
|
51
|
+
if (typeof v === 'string') return quote(v)
|
|
52
|
+
if (v === null) return 'null'
|
|
53
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
54
|
+
return 'any'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {Object} TypeContext
|
|
59
|
+
* @property {(schemaName: string) => string} ref how a components.schemas entry is named at the call site
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {any} schema
|
|
64
|
+
* @param {TypeContext} ctx
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function schemaType(schema, ctx) {
|
|
68
|
+
return render(schema, ctx).s
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {any} schema
|
|
73
|
+
* @param {TypeContext} ctx
|
|
74
|
+
* @returns {Rendered}
|
|
75
|
+
*/
|
|
76
|
+
function render(schema, ctx) {
|
|
77
|
+
if (schema === false) return atom('never')
|
|
78
|
+
if (schema === true || schema === null || typeof schema !== 'object') return atom('any')
|
|
79
|
+
|
|
80
|
+
if (typeof schema.$ref === 'string') {
|
|
81
|
+
const m = /^#\/components\/schemas\/(.+)$/.exec(schema.$ref)
|
|
82
|
+
if (!m) return atom('any')
|
|
83
|
+
const name = m[1].replace(/~1/g, '/').replace(/~0/g, '~')
|
|
84
|
+
const r = atom(ctx.ref(name))
|
|
85
|
+
return schema.nullable === true ? union([r, atom('null')]) : r
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let out = renderBody(schema, ctx)
|
|
89
|
+
if (schema.nullable === true) out = union([out, atom('null')])
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {any} schema @param {TypeContext} ctx @returns {Rendered} */
|
|
94
|
+
function renderBody(schema, ctx) {
|
|
95
|
+
if ('const' in schema) return atom(literal(schema.const))
|
|
96
|
+
if (Array.isArray(schema.enum)) return union(schema.enum.map((v) => atom(literal(v))))
|
|
97
|
+
|
|
98
|
+
/** @type {Rendered[]} */
|
|
99
|
+
const parts = []
|
|
100
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length) {
|
|
101
|
+
parts.push(intersection(schema.allOf.map((/** @type {any} */ s) => render(s, ctx))))
|
|
102
|
+
}
|
|
103
|
+
for (const key of ['oneOf', 'anyOf']) {
|
|
104
|
+
if (Array.isArray(schema[key]) && schema[key].length) {
|
|
105
|
+
parts.push(union(schema[key].map((/** @type {any} */ s) => render(s, ctx))))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const hasShape = schema.type !== undefined || schema.properties || schema.additionalProperties !== undefined || schema.items
|
|
110
|
+
if (hasShape || parts.length === 0) {
|
|
111
|
+
const own = renderTyped(schema, ctx)
|
|
112
|
+
// allOf/oneOf next to a bare `type: object` adds nothing; keep the composed part alone
|
|
113
|
+
if (!(parts.length && own.s === 'Record<string, any>' && !schema.properties)) parts.push(own)
|
|
114
|
+
}
|
|
115
|
+
return intersection(parts)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {any} schema @param {TypeContext} ctx @returns {Rendered} */
|
|
119
|
+
function renderTyped(schema, ctx) {
|
|
120
|
+
/** @type {string[]} */
|
|
121
|
+
let types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []
|
|
122
|
+
if (types.length === 0) {
|
|
123
|
+
if (schema.properties || schema.additionalProperties !== undefined) types = ['object']
|
|
124
|
+
else if (schema.items) types = ['array']
|
|
125
|
+
else return atom('any')
|
|
126
|
+
}
|
|
127
|
+
return union(types.map((t) => renderOne(t, schema, ctx)))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {string} type @param {any} schema @param {TypeContext} ctx @returns {Rendered} */
|
|
131
|
+
function renderOne(type, schema, ctx) {
|
|
132
|
+
switch (type) {
|
|
133
|
+
case 'string':
|
|
134
|
+
return atom(schema.format === 'binary' ? 'Blob' : 'string')
|
|
135
|
+
case 'integer':
|
|
136
|
+
case 'number':
|
|
137
|
+
return atom('number')
|
|
138
|
+
case 'boolean':
|
|
139
|
+
return atom('boolean')
|
|
140
|
+
case 'null':
|
|
141
|
+
return atom('null')
|
|
142
|
+
case 'array':
|
|
143
|
+
return atom(`${at(schema.items === undefined ? atom('any') : render(schema.items, ctx), 2)}[]`)
|
|
144
|
+
case 'object':
|
|
145
|
+
return renderObject(schema, ctx)
|
|
146
|
+
default:
|
|
147
|
+
return atom('any')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {any} schema @param {TypeContext} ctx @returns {Rendered} */
|
|
152
|
+
function renderObject(schema, ctx) {
|
|
153
|
+
const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
|
|
154
|
+
const names = Object.keys(props)
|
|
155
|
+
const extra = schema.additionalProperties
|
|
156
|
+
const extraType =
|
|
157
|
+
extra === undefined || extra === false
|
|
158
|
+
? null
|
|
159
|
+
: extra === true
|
|
160
|
+
? atom('any')
|
|
161
|
+
: render(extra, ctx)
|
|
162
|
+
|
|
163
|
+
if (names.length === 0) return atom(`Record<string, ${extraType ? extraType.s : 'any'}>`)
|
|
164
|
+
|
|
165
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : [])
|
|
166
|
+
const body = names
|
|
167
|
+
.map((n) => `${propKey(n)}${required.has(n) ? '' : '?'}: ${render(props[n], ctx).s}`)
|
|
168
|
+
.join(', ')
|
|
169
|
+
const literalType = atom(`{ ${body} }`)
|
|
170
|
+
return extraType ? intersection([literalType, atom(`Record<string, ${extraType.s}>`)]) : literalType
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Names, types and descriptions of an object schema's properties when every name is a plain identifier,
|
|
175
|
+
* so the typedef can use `@property`. Null otherwise (the caller falls back to the inline literal).
|
|
176
|
+
* @param {any} schema
|
|
177
|
+
* @param {TypeContext} ctx
|
|
178
|
+
* @returns {{ name: string, type: string, optional: boolean, description: string }[] | null}
|
|
179
|
+
*/
|
|
180
|
+
export function objectProperties(schema, ctx) {
|
|
181
|
+
if (!schema || typeof schema !== 'object' || schema.$ref) return null
|
|
182
|
+
if (schema.allOf || schema.oneOf || schema.anyOf || schema.enum || 'const' in schema || schema.nullable === true) return null
|
|
183
|
+
if (schema.type !== undefined && schema.type !== 'object') return null
|
|
184
|
+
if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) return null
|
|
185
|
+
const props = schema.properties
|
|
186
|
+
if (!props || typeof props !== 'object') return null
|
|
187
|
+
const names = Object.keys(props)
|
|
188
|
+
if (names.length === 0 || !names.every(isIdent)) return null
|
|
189
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : [])
|
|
190
|
+
return names.map((name) => ({
|
|
191
|
+
name,
|
|
192
|
+
type: render(props[name], ctx).s,
|
|
193
|
+
optional: !required.has(name),
|
|
194
|
+
description: oneLine(props[name] && typeof props[name] === 'object' ? props[name].description : '')
|
|
195
|
+
}))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Collapse text to one comment-safe line. @param {any} text */
|
|
199
|
+
export function oneLine(text) {
|
|
200
|
+
if (typeof text !== 'string') return ''
|
|
201
|
+
return text.replace(/\*\//g, '*\\/').replace(/\s+/g, ' ').trim()
|
|
202
|
+
}
|