@xoong/cli 0.0.0-stage → 0.0.35
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/LICENSE +21 -0
- package/README.md +26 -2
- package/package.json +47 -4
- package/src/body-schema.js +161 -0
- package/src/build.js +211 -0
- package/src/check.js +347 -0
- package/src/cli.js +101 -0
- package/src/commands.js +55 -0
- package/src/dev.js +48 -0
- package/src/html-data.js +85 -0
- package/src/i18n-extract.js +78 -0
- package/src/index.js +2 -0
- package/src/openapi.js +474 -0
- package/src/scaffold.js +159 -0
- package/src/swagger2.js +88 -0
package/src/openapi.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { swagger2ToOpenApi3 } from './swagger2.js';
|
|
3
|
+
import { bodySchemaOf } from './body-schema.js';
|
|
4
|
+
/**
|
|
5
|
+
* OpenAPI 3.x (JSON) -> xoong tables.
|
|
6
|
+
* Produces:
|
|
7
|
+
* operations.json : { operations, entities } (consumed by @xoong/data at runtime)
|
|
8
|
+
* api.d.ts : TypeScript types for schemas and operations
|
|
9
|
+
* problems : findings for `xoong doctor`
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
13
|
+
const MAX_DEPTH = 60; // schemas nest far less than this; deeper means a cycle
|
|
14
|
+
const refName = (ref) => {
|
|
15
|
+
const seg = ref.slice(ref.lastIndexOf('/') + 1);
|
|
16
|
+
try { return decodeURIComponent(seg).replace(/~1/g, '/').replace(/~0/g, '~'); } catch { return seg; }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Resolve a local "#/a/b" pointer in `doc`; undefined when it does not exist. */
|
|
20
|
+
function resolvePointer(doc, ref) {
|
|
21
|
+
let node = doc;
|
|
22
|
+
for (const raw of ref.slice(2).split('/')) {
|
|
23
|
+
let seg = raw;
|
|
24
|
+
try { seg = decodeURIComponent(raw); } catch { /* keep raw */ }
|
|
25
|
+
seg = seg.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
26
|
+
if (node == null || typeof node !== 'object' || !Object.hasOwn(node, seg)) return undefined;
|
|
27
|
+
node = node[seg];
|
|
28
|
+
}
|
|
29
|
+
return node;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every distinct `$ref` string in the document (iterative: specs can be deeply nested). */
|
|
33
|
+
function collectRefs(doc) {
|
|
34
|
+
const refs = new Set();
|
|
35
|
+
const seen = new Set(); // YAML anchors make shared (even cyclic) objects: visit each once
|
|
36
|
+
const stack = [doc];
|
|
37
|
+
while (stack.length) {
|
|
38
|
+
const n = stack.pop();
|
|
39
|
+
if (seen.has(n)) continue;
|
|
40
|
+
seen.add(n);
|
|
41
|
+
if (Array.isArray(n)) { for (const x of n) if (x && typeof x === 'object') stack.push(x); continue; }
|
|
42
|
+
for (const [k, v] of Object.entries(n)) {
|
|
43
|
+
if (k === '$ref' && typeof v === 'string') refs.add(v);
|
|
44
|
+
else if (v && typeof v === 'object') stack.push(v);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return refs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {any} doc
|
|
52
|
+
* @param {{ hints?: { idNames?: string[], schemas?: Record<string, { id?: string, entity?: string }>, unwrap?: string, idempotencyKeys?: Record<string, string>, fields?: Record<string, any> } }} [options]
|
|
53
|
+
* `hints` lets the frontend declare identity without editing the backend's document:
|
|
54
|
+
* { "idNames": ["id", "gid"], // any schema with one of these properties is an entity
|
|
55
|
+
* "schemas": { "SessionResponse": { "entity": "Session", "id": "session_id" } },
|
|
56
|
+
* "unwrap": "data", // responses shaped { code, message, data } are read as `data`
|
|
57
|
+
* "idempotencyKeys": { "sendMessage": "client_message_id" } } // a fresh UUID is filled in per call
|
|
58
|
+
*/
|
|
59
|
+
export function analyze(doc, { hints } = {}) {
|
|
60
|
+
if (doc && typeof doc === 'object' && String(doc.swagger ?? '').startsWith('2')) doc = swagger2ToOpenApi3(doc); // Swagger 2.0 is converted first
|
|
61
|
+
if (!doc || typeof doc !== 'object' || !String(doc.openapi ?? '').startsWith('3')) {
|
|
62
|
+
throw new Error('xoong: expected an OpenAPI 3.x document (field "openapi" starting with "3")');
|
|
63
|
+
}
|
|
64
|
+
const schemas = { ...(doc.components?.schemas ?? {}) }; // a copy: hints must not modify the caller's document
|
|
65
|
+
for (const [name, h] of Object.entries(hints?.schemas ?? {})) {
|
|
66
|
+
if (!schemas[name]) throw new Error(`xoong: hints refer to unknown schema "${name}"`);
|
|
67
|
+
schemas[name] = { ...schemas[name], ...(h.id ? { 'x-xoong-id': h.id } : {}), ...(h.entity ? { 'x-xoong-entity': h.entity } : {}) };
|
|
68
|
+
}
|
|
69
|
+
/** @type {{level:'error'|'warn', code:string, where:string, message:string, fix:string}[]} */
|
|
70
|
+
const problems = [];
|
|
71
|
+
if (!doc.paths && !doc.webhooks) {
|
|
72
|
+
problems.push({ level: 'error', code: 'XO100', where: '#/paths', message: 'the document has no "paths"', fix: 'Add a paths object (OpenAPI requires it unless the document only declares webhooks/components)' });
|
|
73
|
+
}
|
|
74
|
+
const missing = [];
|
|
75
|
+
for (const ref of collectRefs(doc)) {
|
|
76
|
+
if (!ref.startsWith('#/')) problems.push({ level: 'warn', code: 'XO151', where: ref, message: 'external $ref is not resolved by xoong', fix: 'Bundle the document so all references are local (for example with redocly bundle)' });
|
|
77
|
+
else if (resolvePointer(doc, ref) === undefined) missing.push(ref);
|
|
78
|
+
}
|
|
79
|
+
for (const ref of missing) problems.push({ level: 'error', code: 'XO150', where: ref, message: `$ref points to something that does not exist`, fix: 'Define the target or remove the reference' });
|
|
80
|
+
const resolve = (s) => (s?.$ref ? schemas[refName(s.$ref)] : s);
|
|
81
|
+
|
|
82
|
+
// --- entity detection: component schemas with an identity property
|
|
83
|
+
/** schema name -> id property; schema name -> entity name (several schemas may be one entity) */
|
|
84
|
+
/** @type {Record<string, string>} */
|
|
85
|
+
const idKeys = {};
|
|
86
|
+
/** @type {Record<string, string>} */
|
|
87
|
+
const entityOf = {};
|
|
88
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
89
|
+
const props = collectProps(schema, schemas);
|
|
90
|
+
const explicit = schema['x-xoong-id'];
|
|
91
|
+
if (explicit) {
|
|
92
|
+
// One property, or several for a composite identity (Kubernetes: ["namespace", "name"]).
|
|
93
|
+
const absent = [].concat(explicit).filter((k) => !props[k]);
|
|
94
|
+
if (!absent.length) { idKeys[name] = explicit; entityOf[name] = schema['x-xoong-entity'] ?? name; }
|
|
95
|
+
else problems.push({ level: 'error', code: 'XO101', where: `#/components/schemas/${name}`, message: `x-xoong-id "${absent[0]}" is not a property of ${name}`, fix: 'Name existing properties or remove x-xoong-id' });
|
|
96
|
+
} else {
|
|
97
|
+
// Conventional identity: `id`, or the first property named in hints.idNames (for example "gid" or "pk").
|
|
98
|
+
const key = ['id', ...(hints?.idNames ?? [])].find((k) => props[k]);
|
|
99
|
+
if (key) { idKeys[name] = key; entityOf[name] = schema['x-xoong-entity'] ?? name; }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isScalarSchema = (s) => !!s && !s.$ref && !s.properties && !s.allOf && !s.oneOf && !s.anyOf && (['string', 'integer', 'number', 'boolean', 'null'].includes(s.type) || (Array.isArray(s.type) && s.type.every((t) => ['string', 'integer', 'number', 'boolean', 'null'].includes(t))));
|
|
104
|
+
const single = (p) => (p && 'const' in p ? p.const : p?.enum?.length === 1 ? p.enum[0] : undefined);
|
|
105
|
+
/**
|
|
106
|
+
* How to tell the members of a oneOf/anyOf apart: the OpenAPI `discriminator`, or a property that
|
|
107
|
+
* every member declares with one fixed value (`type: "file"`, Stripe's `object: "customer"`).
|
|
108
|
+
* Returns { key, values[] } aligned with `members`, or null.
|
|
109
|
+
*/
|
|
110
|
+
const unionTag = (schema, members) => {
|
|
111
|
+
const d = schema.discriminator;
|
|
112
|
+
if (d?.propertyName) {
|
|
113
|
+
const values = members.map((m) => {
|
|
114
|
+
if (!m.$ref) return undefined;
|
|
115
|
+
const mapped = Object.entries(d.mapping ?? {}).find(([, target]) => target === m.$ref || String(target).endsWith('/' + refName(m.$ref)));
|
|
116
|
+
return mapped ? mapped[0] : refName(m.$ref);
|
|
117
|
+
});
|
|
118
|
+
return values.every((v) => v !== undefined) ? { key: d.propertyName, values } : null;
|
|
119
|
+
}
|
|
120
|
+
const propsOf = members.map((m) => collectProps(m, schemas));
|
|
121
|
+
const fixed = (p, key) => single(p[key] && (p[key].$ref ? schemas[refName(p[key].$ref)] : p[key]));
|
|
122
|
+
// 1) a property every member fixes to a different value
|
|
123
|
+
for (const key of Object.keys(propsOf[0] ?? {})) {
|
|
124
|
+
const values = propsOf.map((p) => fixed(p, key));
|
|
125
|
+
if (values.every((v) => v !== undefined) && new Set(values).size === values.length) return { key, values };
|
|
126
|
+
}
|
|
127
|
+
// 2) a property that all but one member fixes (Stripe: `deleted: true` marks deleted_customer; plain customer lacks it)
|
|
128
|
+
const keys = new Set(propsOf.flatMap((p) => Object.keys(p)));
|
|
129
|
+
for (const key of keys) {
|
|
130
|
+
const values = propsOf.map((p) => fixed(p, key));
|
|
131
|
+
const untagged = values.map((v, i) => (v === undefined ? i : -1)).filter((i) => i >= 0);
|
|
132
|
+
const tagged = values.filter((v) => v !== undefined);
|
|
133
|
+
if (untagged.length === 1 && new Set(tagged).size === tagged.length) return { key, values, fallback: untagged[0] };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Names of the members of the first oneOf/anyOf of 2+ entities found in a response that xoong cannot tell apart (looking through refs, arrays and properties). */
|
|
139
|
+
const unresolvedUnion = (schema, seen = new Set(), depth = 0) => {
|
|
140
|
+
if (!schema || depth > 5) return undefined;
|
|
141
|
+
if (schema.$ref) {
|
|
142
|
+
const n = refName(schema.$ref);
|
|
143
|
+
if (seen.has(n)) return undefined;
|
|
144
|
+
return unresolvedUnion(schemas[n], new Set(seen).add(n), depth + 1);
|
|
145
|
+
}
|
|
146
|
+
const alt = (schema.oneOf ?? schema.anyOf)?.filter((s) => !isScalarSchema(s));
|
|
147
|
+
const entities = alt?.filter((s) => s.$ref && idKeys[refName(s.$ref)]) ?? [];
|
|
148
|
+
if (entities.length > 1 && !describe(schema)) return entities.map((s) => refName(s.$ref));
|
|
149
|
+
for (const child of [schema.items, ...(schema.allOf ?? []), ...Object.values(schema.properties ?? {})]) {
|
|
150
|
+
const found = unresolvedUnion(child, seen, depth + 1);
|
|
151
|
+
if (found) return found;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Schema -> runtime descriptor (null when it contains no entities). */
|
|
157
|
+
const describe = (schema, seen = new Set(), depth = 0) => {
|
|
158
|
+
if (!schema || depth > MAX_DEPTH) return null; // depth cap: YAML aliases can make a schema contain itself
|
|
159
|
+
if (schema.$ref) {
|
|
160
|
+
const name = refName(schema.$ref);
|
|
161
|
+
// `replace`: this schema is the entity's own full schema, so a response replaces the stored record (fields the
|
|
162
|
+
// server stopped sending disappear). Schemas that share an entity through hints, or are marked x-xoong-partial, merge.
|
|
163
|
+
if (idKeys[name]) return { t: 'entity', name: entityOf[name], ...(entityOf[name] === name && !schemas[name]['x-xoong-partial'] ? { replace: true } : {}) };
|
|
164
|
+
if (seen.has(name)) return null;
|
|
165
|
+
return describe(schemas[name], new Set(seen).add(name), depth + 1);
|
|
166
|
+
}
|
|
167
|
+
const alternatives = schema.oneOf ?? schema.anyOf;
|
|
168
|
+
if (alternatives) {
|
|
169
|
+
// Plain values (`string`, `null`) are ignored: Stripe-style "id or expanded object" is just the object.
|
|
170
|
+
const real = alternatives.filter((s) => !isScalarSchema(s));
|
|
171
|
+
if (real.length === 0) return null;
|
|
172
|
+
if (real.length === 1) return describe(real[0], seen, depth + 1);
|
|
173
|
+
// Several object shapes: usable when a tag property tells them apart, or each has a required property no other has.
|
|
174
|
+
const tag = unionTag(schema, real);
|
|
175
|
+
if (!tag) {
|
|
176
|
+
const sets = real.map((s) => {
|
|
177
|
+
const resolved = s.$ref ? schemas[refName(s.$ref)] : s;
|
|
178
|
+
return { props: new Set(Object.keys(collectProps(s, schemas))), required: [...new Set([...(resolved?.required ?? []), ...(resolved?.allOf ?? []).flatMap((p) => (p.$ref ? schemas[refName(p.$ref)] : p)?.required ?? [])])] };
|
|
179
|
+
});
|
|
180
|
+
const has = [];
|
|
181
|
+
for (let i = 0; i < real.length; i++) {
|
|
182
|
+
const prop = sets[i].required.find((k) => sets.every((o, j) => j === i || !o.props.has(k)));
|
|
183
|
+
const d = prop && describe(real[i], seen, depth + 1);
|
|
184
|
+
if (!prop || !d) return null; // one member cannot be recognised: leave the union alone (doctor reports XO160)
|
|
185
|
+
has.push([prop, d]);
|
|
186
|
+
}
|
|
187
|
+
return { t: 'union', has };
|
|
188
|
+
}
|
|
189
|
+
const map = {};
|
|
190
|
+
real.forEach((s, i) => {
|
|
191
|
+
const d = describe(s, seen, depth + 1);
|
|
192
|
+
if (d && tag.values[i] !== undefined) map[tag.values[i]] = d;
|
|
193
|
+
});
|
|
194
|
+
const fallback = tag.fallback !== undefined ? describe(real[tag.fallback], seen, depth + 1) : null;
|
|
195
|
+
if (!Object.keys(map).length && !fallback) return null;
|
|
196
|
+
return { t: 'union', key: tag.key, map, ...(fallback ? { default: fallback } : {}) };
|
|
197
|
+
}
|
|
198
|
+
if (schema.allOf) {
|
|
199
|
+
const props = {};
|
|
200
|
+
for (const part of schema.allOf) {
|
|
201
|
+
const d = describe(part, seen, depth + 1);
|
|
202
|
+
if (d?.t === 'object') Object.assign(props, d.props);
|
|
203
|
+
else if (d?.t === 'entity') return d;
|
|
204
|
+
}
|
|
205
|
+
return Object.keys(props).length ? { t: 'object', props } : null;
|
|
206
|
+
}
|
|
207
|
+
if (schema.type === 'array') {
|
|
208
|
+
const items = describe(schema.items, seen, depth + 1);
|
|
209
|
+
return items ? { t: 'array', items } : null;
|
|
210
|
+
}
|
|
211
|
+
if (schema.properties) {
|
|
212
|
+
const props = {};
|
|
213
|
+
for (const [k, v] of Object.entries(schema.properties)) {
|
|
214
|
+
const d = describe(v, seen, depth + 1);
|
|
215
|
+
if (d) props[k] = d;
|
|
216
|
+
}
|
|
217
|
+
return Object.keys(props).length ? { t: 'object', props } : null;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const entities = {};
|
|
223
|
+
for (const name of Object.keys(idKeys)) {
|
|
224
|
+
const entity = entityOf[name];
|
|
225
|
+
const props = { ...(entities[entity]?.props ?? {}) };
|
|
226
|
+
for (const [k, v] of Object.entries(collectProps(schemas[name], schemas))) {
|
|
227
|
+
const d = describe(v);
|
|
228
|
+
if (d) props[k] = d;
|
|
229
|
+
}
|
|
230
|
+
if (entities[entity] && JSON.stringify(entities[entity].id) !== JSON.stringify(idKeys[name])) {
|
|
231
|
+
problems.push({ level: 'error', code: 'XO102', where: `#/components/schemas/${name}`, message: `entity "${entity}" has conflicting id properties ("${entities[entity].id}" and "${idKeys[name]}")`, fix: 'Use the same id property for every schema mapped to one entity' });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
entities[entity] = { id: idKeys[name], ...(Object.keys(props).length ? { props } : {}) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- operations
|
|
238
|
+
const operations = {};
|
|
239
|
+
const used = new Set();
|
|
240
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
241
|
+
for (const method of METHODS) {
|
|
242
|
+
const o = item[method];
|
|
243
|
+
if (!o) continue;
|
|
244
|
+
let id = o.operationId;
|
|
245
|
+
if (!id) {
|
|
246
|
+
id = `${method}${path.replace(/[{}]/g, '').split(/[/\-_]+/).filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join('')}`;
|
|
247
|
+
problems.push({ level: 'warn', code: 'XO110', where: `${method.toUpperCase()} ${path}`, message: 'operation has no operationId', fix: `Add operationId (xoong would use "${id}")` });
|
|
248
|
+
}
|
|
249
|
+
if (used.has(id)) {
|
|
250
|
+
problems.push({ level: 'error', code: 'XO111', where: `${method.toUpperCase()} ${path}`, message: `duplicate operationId "${id}"`, fix: 'Make operationIds unique' });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
used.add(id);
|
|
254
|
+
const merged = new Map();
|
|
255
|
+
for (const raw of [...(item.parameters ?? []), ...(o.parameters ?? [])]) {
|
|
256
|
+
const p = raw?.$ref ? resolvePointer(doc, raw.$ref) : raw;
|
|
257
|
+
if (p?.name && p.in) merged.set(`${p.in}:${p.name}`, p); // operation-level overrides path-level
|
|
258
|
+
}
|
|
259
|
+
const params = [...merged.values()];
|
|
260
|
+
const pathParams = params.filter((p) => p.in === 'path');
|
|
261
|
+
const declared = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
|
|
262
|
+
for (const name of declared) if (!pathParams.some((p) => p.name === name)) {
|
|
263
|
+
problems.push({ level: 'warn', code: 'XO112', where: `${method.toUpperCase()} ${path}`, message: `path parameter "${name}" is not documented`, fix: 'Declare it under parameters with in: path' });
|
|
264
|
+
}
|
|
265
|
+
const okCode = Object.keys(o.responses ?? {}).find((c) => /^2\d\d$/.test(c));
|
|
266
|
+
const okContent = okCode ? resolve(o.responses[okCode])?.content : undefined;
|
|
267
|
+
const stream = !!okContent?.['text/event-stream'] && !okContent['application/json'];
|
|
268
|
+
let okSchema = okContent?.['application/json']?.schema;
|
|
269
|
+
let unwrap;
|
|
270
|
+
if (hints?.unwrap && okSchema && collectProps(resolve(okSchema), schemas)[hints.unwrap]) {
|
|
271
|
+
unwrap = hints.unwrap; // { code, message, data } envelopes: the screen sees `data`
|
|
272
|
+
okSchema = collectProps(resolve(okSchema), schemas)[unwrap];
|
|
273
|
+
}
|
|
274
|
+
const reqContent = o.requestBody?.content ?? {};
|
|
275
|
+
const multipart = !reqContent['application/json'] && !!reqContent['multipart/form-data'];
|
|
276
|
+
const bodySchema = (reqContent['application/json'] ?? (multipart ? reqContent['multipart/form-data'] : undefined))?.schema;
|
|
277
|
+
const uuid = hints?.idempotencyKeys?.[id];
|
|
278
|
+
if (uuid && !(bodySchema && collectProps(resolve(bodySchema), schemas)[uuid])) {
|
|
279
|
+
problems.push({ level: 'error', code: 'XO170', where: `${method.toUpperCase()} ${path}`, message: `hint idempotencyKeys.${id} names "${uuid}", which is not a field of the request body`, fix: 'Name an existing body property or remove the hint' });
|
|
280
|
+
}
|
|
281
|
+
const hasBody = !!o.requestBody;
|
|
282
|
+
const response = describe(okSchema);
|
|
283
|
+
if (method === 'get' && okCode && !okSchema && okCode !== '204' && !stream) {
|
|
284
|
+
problems.push({ level: 'warn', code: 'XO120', where: `GET ${path}`, message: 'success response has no application/json schema', fix: 'Describe the response schema so types and caching can be generated' });
|
|
285
|
+
}
|
|
286
|
+
const union = unresolvedUnion(okSchema);
|
|
287
|
+
if (union) {
|
|
288
|
+
const names = union.slice(0, 4).join(' | ') + (union.length > 4 ? ' | ...' : '');
|
|
289
|
+
problems.push({ level: 'warn', code: 'XO160', where: `${method.toUpperCase()} ${path}`, message: `the response contains a union of entities (${names}) with no way to tell them apart, so they are not stored in the shared entity store`, fix: 'Give the members a property with one fixed value each (for example type: "file") or an OpenAPI discriminator' });
|
|
290
|
+
}
|
|
291
|
+
const hasError = Object.keys(o.responses ?? {}).some((c) => /^[45]/.test(c) || c === 'default');
|
|
292
|
+
if (!hasError) problems.push({ level: 'warn', code: 'XO130', where: `${method.toUpperCase()} ${path}`, message: 'no error responses documented', fix: 'Document 4xx/5xx or default with application/problem+json' });
|
|
293
|
+
const lost = [];
|
|
294
|
+
const builtBodyNode = bodySchema && !multipart ? bodySchemaOf(bodySchema, schemas, hints, lost) : undefined;
|
|
295
|
+
const builtBody = () => builtBodyNode;
|
|
296
|
+
for (const where of new Set(lost)) problems.push({ level: 'warn', code: 'XO161', where: `${method.toUpperCase()} ${path}`, message: `a union in ${where} cannot be told apart, so a generated form draws it as free-form`, fix: 'Add a discriminator (propertyName) or give each member a property with one fixed value (const or a one-value enum)' });
|
|
297
|
+
operations[id] = {
|
|
298
|
+
id, method: method.toUpperCase(), path,
|
|
299
|
+
...(params.some((p) => p.in === 'query') ? { query: params.filter((p) => p.in === 'query').map((p) => p.name) } : {}),
|
|
300
|
+
...(hasBody ? { body: true } : {}),
|
|
301
|
+
...(multipart ? { multipart: true } : {}),
|
|
302
|
+
...(uuid ? { uuid: [uuid] } : {}),
|
|
303
|
+
...(unwrap ? { unwrap } : {}),
|
|
304
|
+
...(stream ? { stream: true } : {}),
|
|
305
|
+
...(bodyFieldsOf(bodySchema, schemas) ? { bodyFields: bodyFieldsOf(bodySchema, schemas) } : {}),
|
|
306
|
+
...(bodySkippedOf(bodySchema, schemas) ? { bodySkipped: bodySkippedOf(bodySchema, schemas) } : {}),
|
|
307
|
+
...(bodySchema && !multipart && builtBody(bodySchema) ? { bodySchema: builtBody(bodySchema) } : {}),
|
|
308
|
+
...(response ? { response } : {}),
|
|
309
|
+
};
|
|
310
|
+
operations[id]._types = { params, okSchema, bodySchema };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
315
|
+
const candidate = !idKeys[name] && schema.type === 'object' && !/Request$/.test(name) ? identityCandidate(name, schema, schemas, hints?.idNames ?? []) : undefined;
|
|
316
|
+
if (candidate) {
|
|
317
|
+
problems.push({ level: 'warn', code: 'XO140', where: `#/components/schemas/${name}`, message: `${name} has "${candidate}" but is not treated as an entity, so it is stored inline and not shared between screens`, fix: `If instances repeat across responses, add a hint { "schemas": { "${name}": { "id": "${candidate}" } } } (or "x-xoong-id" in the document); otherwise ignore` });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { operations, entities, schemas, problems };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The flat, primitive fields of a request body, for generated forms (`<xo-form>`) and typed form values.
|
|
325
|
+
* Nested objects and arrays are left out: a form for them is hand-written.
|
|
326
|
+
* @returns {{ name: string, type: string, required?: true, format?: string, enum?: any[], maxLength?: number, title?: string }[] | undefined}
|
|
327
|
+
*/
|
|
328
|
+
function bodyFieldsOf(schema, schemas) {
|
|
329
|
+
const root = schema?.$ref ? schemas[refName(schema.$ref)] : schema;
|
|
330
|
+
if (!root || root.type !== 'object' && !root.properties && !root.allOf) return undefined;
|
|
331
|
+
const required = new Set([...(root.required ?? []), ...(root.allOf ?? []).flatMap((p) => (p.$ref ? schemas[refName(p.$ref)] : p)?.required ?? [])]);
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const [name, raw] of Object.entries(collectProps(root, schemas))) {
|
|
334
|
+
const prop = raw?.$ref ? schemas[refName(raw.$ref)] : raw;
|
|
335
|
+
if (!prop || !['string', 'integer', 'number', 'boolean'].includes(prop.type) || prop.readOnly) continue;
|
|
336
|
+
const file = prop.type === 'string' && prop.format === 'binary';
|
|
337
|
+
out.push({
|
|
338
|
+
name, type: file ? 'file' : prop.type,
|
|
339
|
+
...(required.has(name) ? { required: true } : {}),
|
|
340
|
+
...(prop.format && !file ? { format: prop.format } : {}),
|
|
341
|
+
...(prop.enum ? { enum: prop.enum } : {}),
|
|
342
|
+
...(prop.maxLength ? { maxLength: prop.maxLength } : {}),
|
|
343
|
+
...(prop.title ? { title: prop.title } : {}),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return out.length ? out : undefined;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Request body properties that `bodyFieldsOf` leaves out (nested objects, arrays, unions), so tools can warn when a *required*
|
|
350
|
+
* one is missing from a generated form: that form can never succeed.
|
|
351
|
+
* @returns {{ name: string, type: string, required?: true }[] | undefined}
|
|
352
|
+
*/
|
|
353
|
+
function bodySkippedOf(schema, schemas) {
|
|
354
|
+
const root = schema?.$ref ? schemas[refName(schema.$ref)] : schema;
|
|
355
|
+
if (!root || root.type !== 'object' && !root.properties && !root.allOf) return undefined;
|
|
356
|
+
const required = new Set([...(root.required ?? []), ...(root.allOf ?? []).flatMap((p) => (p.$ref ? schemas[refName(p.$ref)] : p)?.required ?? [])]);
|
|
357
|
+
const out = [];
|
|
358
|
+
for (const [name, raw] of Object.entries(collectProps(root, schemas))) {
|
|
359
|
+
const prop = raw?.$ref ? schemas[refName(raw.$ref)] : raw;
|
|
360
|
+
if (!prop || prop.readOnly || ['string', 'integer', 'number', 'boolean'].includes(prop.type)) continue;
|
|
361
|
+
out.push({ name, type: prop.type ?? (prop.oneOf || prop.anyOf ? 'union' : 'object'), ...(required.has(name) ? { required: true } : {}) });
|
|
362
|
+
}
|
|
363
|
+
return out.length ? out : undefined;
|
|
364
|
+
}
|
|
365
|
+
function collectProps(schema, schemas, seen = new Set()) {
|
|
366
|
+
if (!schema) return {};
|
|
367
|
+
if (schema.$ref) {
|
|
368
|
+
const name = refName(schema.$ref);
|
|
369
|
+
if (seen.has(name)) return {}; // circular allOf
|
|
370
|
+
return collectProps(schemas[name], schemas, new Set(seen).add(name));
|
|
371
|
+
}
|
|
372
|
+
let props = { ...(schema.properties ?? {}) };
|
|
373
|
+
for (const part of schema.allOf ?? []) props = { ...collectProps(part, schemas, seen), ...props };
|
|
374
|
+
return props;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* A property that looks like the schema's own identity, excluding plain `id` (handled elsewhere):
|
|
378
|
+
* a conventional name (uuid, guid, gid, uid, pk) or `<stem>_id` / `<stem>Id` where the stem occurs in
|
|
379
|
+
* the schema name (session_id in SessionResponse). Foreign keys to other things (task_id in PendingAction) are ignored.
|
|
380
|
+
*/
|
|
381
|
+
function identityCandidate(name, schema, schemas, taken = []) {
|
|
382
|
+
const flat = name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
383
|
+
return Object.keys(collectProps(schema, schemas)).find((k) => {
|
|
384
|
+
if (k === 'id' || taken.includes(k)) return false;
|
|
385
|
+
if (/^(uuid|guid|gid|uid|pk)$/i.test(k)) return true;
|
|
386
|
+
const m = /^(.+?)(?:_id|Id)$/.exec(k);
|
|
387
|
+
return !!m && flat.includes(m[1].toLowerCase().replace(/[^a-z0-9]/g, ''));
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------- TypeScript
|
|
392
|
+
|
|
393
|
+
const ident = (s) => (/^[A-Za-z_$][\w$]*$/.test(s) ? s : JSON.stringify(s));
|
|
394
|
+
|
|
395
|
+
/** Words that cannot (or must not) be used as a generated type name. */
|
|
396
|
+
const RESERVED = new Set(('break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await async type any unknown never object string number boolean symbol bigint undefined Record Array Operations Partial Readonly Promise').split(' '));
|
|
397
|
+
|
|
398
|
+
/** A schema name as a valid TypeScript type name (Dto.User -> Dto_User, import -> import_). */
|
|
399
|
+
export function typeName(name) {
|
|
400
|
+
let t = name.replace(/[^A-Za-z0-9_$]/g, '_');
|
|
401
|
+
if (/^[0-9]/.test(t)) t = '_' + t;
|
|
402
|
+
return RESERVED.has(t) ? t + '_' : t;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Unique TypeScript names for every schema (two schemas never share a name). */
|
|
406
|
+
export function typeNames(schemas) {
|
|
407
|
+
const used = new Set();
|
|
408
|
+
const map = new Map();
|
|
409
|
+
for (const name of Object.keys(schemas)) {
|
|
410
|
+
let t = typeName(name);
|
|
411
|
+
while (used.has(t)) t += '_';
|
|
412
|
+
used.add(t);
|
|
413
|
+
map.set(name, t);
|
|
414
|
+
}
|
|
415
|
+
return map;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** JSON-Schema-ish -> TypeScript type text. `names` maps schema names to type names (refs to unknown schemas become `unknown`). */
|
|
419
|
+
export function tsType(schema, depth = 0, names = null, hops = 0) {
|
|
420
|
+
if (!schema || hops > MAX_DEPTH) return 'unknown'; // hops counts every nesting step (arrays and unions too), so a cycle ends
|
|
421
|
+
if (schema.$ref) {
|
|
422
|
+
const n = refName(schema.$ref);
|
|
423
|
+
if (names) return names.get(n) ?? 'unknown';
|
|
424
|
+
return typeName(n);
|
|
425
|
+
}
|
|
426
|
+
const nullable = schema.nullable ? ' | null' : '';
|
|
427
|
+
if ('const' in schema) return JSON.stringify(schema.const) + nullable;
|
|
428
|
+
if (schema.enum) return (schema.enum.length ? schema.enum.map((v) => JSON.stringify(v)).join(' | ') : 'never') + nullable;
|
|
429
|
+
if (schema.allOf) return schema.allOf.map((s) => tsType(s, depth, names, hops + 1)).join(' & ') + nullable;
|
|
430
|
+
if (schema.oneOf || schema.anyOf) return (schema.oneOf ?? schema.anyOf).map((s) => tsType(s, depth, names, hops + 1)).join(' | ') + nullable;
|
|
431
|
+
const type = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
432
|
+
const parts = type.map((t) => {
|
|
433
|
+
switch (t) {
|
|
434
|
+
case 'string': return 'string';
|
|
435
|
+
case 'integer': case 'number': return 'number';
|
|
436
|
+
case 'boolean': return 'boolean';
|
|
437
|
+
case 'null': return 'null';
|
|
438
|
+
case 'array': return `Array<${tsType(schema.items, depth, names, hops + 1)}>`;
|
|
439
|
+
case 'object': case undefined: {
|
|
440
|
+
const props = Object.entries(schema.properties ?? {});
|
|
441
|
+
if (!props.length) return schema.additionalProperties && schema.additionalProperties !== true ? `Record<string, ${tsType(schema.additionalProperties, depth, names, hops + 1)}>` : t === 'object' ? 'Record<string, unknown>' : 'unknown';
|
|
442
|
+
const pad = ' '.repeat(depth + 1);
|
|
443
|
+
const req = new Set(schema.required ?? []);
|
|
444
|
+
return `{\n${props.map(([k, v]) => `${pad}${ident(k)}${req.has(k) ? '' : '?'}: ${tsType(v, depth + 1, names, hops + 1)};`).join('\n')}\n${' '.repeat(depth)}}`;
|
|
445
|
+
}
|
|
446
|
+
default: return 'unknown';
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
return parts.join(' | ') + nullable;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** @param {ReturnType<typeof analyze>} a */
|
|
453
|
+
export function generateDts(a) {
|
|
454
|
+
const out = ['// Generated by `xoong sync`. Do not edit.\n'];
|
|
455
|
+
const names = typeNames(a.schemas);
|
|
456
|
+
for (const [name, schema] of Object.entries(a.schemas)) out.push(`export type ${names.get(name)} = ${tsType(schema, 0, names)};\n`);
|
|
457
|
+
out.push('export interface Operations {');
|
|
458
|
+
for (const o of Object.values(a.operations)) {
|
|
459
|
+
const t = o._types;
|
|
460
|
+
const seen = new Set();
|
|
461
|
+
const params = t.params.filter((p) => (p.in === 'path' || p.in === 'query') && !seen.has(p.name) && seen.add(p.name));
|
|
462
|
+
const paramsT = params.length ? `{ ${params.map((p) => `${ident(p.name)}${p.in === 'path' || p.required ? '' : '?'}: ${tsType(p.schema, 0, names)}`).join('; ')} }` : 'Record<string, never>';
|
|
463
|
+
out.push(` ${ident(o.id)}: { method: ${JSON.stringify(o.method)}; path: ${JSON.stringify(o.path)}; params: ${paramsT}; body: ${t.bodySchema ? tsType(t.bodySchema, 2, names) : 'never'}; response: ${t.okSchema ? tsType(t.okSchema, 2, names) : 'void'} };`);
|
|
464
|
+
}
|
|
465
|
+
out.push('}\n');
|
|
466
|
+
return out.join('\n');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Strip build-only data before writing operations.json. */
|
|
470
|
+
export function runtimeTable(a) {
|
|
471
|
+
const operations = {};
|
|
472
|
+
for (const [id, { _types, ...rest }] of Object.entries(a.operations)) operations[id] = rest;
|
|
473
|
+
return { version: 1, operations, entities: a.entities };
|
|
474
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mkdir, writeFile, copyFile, access } from 'node:fs/promises';
|
|
3
|
+
import { join, basename, resolve } from 'node:path';
|
|
4
|
+
import { analyze, runtimeTable, generateDts } from './openapi.js';
|
|
5
|
+
import { load } from './commands.js';
|
|
6
|
+
|
|
7
|
+
const SCALAR = new Set(['string', 'integer', 'number', 'boolean']);
|
|
8
|
+
const refName = (r) => r.slice(r.lastIndexOf('/') + 1);
|
|
9
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pick a first screen: a GET operation without path parameters whose response is (or contains) a list of objects.
|
|
13
|
+
* Returns { operation, listPath, columns } or null.
|
|
14
|
+
*/
|
|
15
|
+
export function pickListScreen(a) {
|
|
16
|
+
const deref = (s) => (s?.$ref ? a.schemas[refName(s.$ref)] : s);
|
|
17
|
+
const columnsOf = (item) => {
|
|
18
|
+
const schema = deref(item);
|
|
19
|
+
const props = { ...(schema?.properties ?? {}) };
|
|
20
|
+
for (const part of schema?.allOf ?? []) Object.assign(props, deref(part)?.properties ?? {});
|
|
21
|
+
return Object.entries(props).filter(([, v]) => SCALAR.has(deref(v)?.type)).map(([k]) => k).slice(0, 4);
|
|
22
|
+
};
|
|
23
|
+
for (const op of Object.values(a.operations)) {
|
|
24
|
+
if (op.method !== 'GET' || /\{/.test(op.path)) continue;
|
|
25
|
+
const root = deref(op._types.okSchema);
|
|
26
|
+
if (!root) continue;
|
|
27
|
+
if (root.type === 'array') { const columns = columnsOf(root.items); if (columns.length) return { operation: op, listPath: '.', columns }; }
|
|
28
|
+
for (const [key, v] of Object.entries(root.properties ?? {})) {
|
|
29
|
+
const prop = deref(v);
|
|
30
|
+
if (prop?.type === 'array') { const columns = columnsOf(prop.items); if (columns.length) return { operation: op, listPath: key, columns }; }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** HTML for the first screen. */
|
|
37
|
+
export function listScreenHtml(pick, title) {
|
|
38
|
+
let view = ' <p>No list operation found in the API; add a screen by hand.</p>';
|
|
39
|
+
if (pick) {
|
|
40
|
+
const heading = pick.columns.map((c) => `<th>${esc(c)}</th>`).join('');
|
|
41
|
+
const cells = pick.columns.map((c) => `<td>{${c}}</td>`).join('');
|
|
42
|
+
const op = pick.operation;
|
|
43
|
+
view = ` <xo-view get="${esc(op.method + ' ' + op.path)}">
|
|
44
|
+
<p xo-if="$loading">Loading…</p>
|
|
45
|
+
<p xo-if="$error" class="error">{$error.code} {$error.detail}</p>
|
|
46
|
+
<table>
|
|
47
|
+
<thead><tr>${heading}</tr></thead>
|
|
48
|
+
<tbody><tr xo-each="${pick.listPath}">${cells}</tr></tbody>
|
|
49
|
+
</table>
|
|
50
|
+
</xo-view>`;
|
|
51
|
+
}
|
|
52
|
+
return `<!doctype html>
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="utf-8">
|
|
56
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
57
|
+
<title>${esc(title)}</title>
|
|
58
|
+
<link rel="icon" href="data:,">
|
|
59
|
+
<link rel="stylesheet" href="/src/app.css">
|
|
60
|
+
<script type="module" src="/src/main.ts"></script>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<main>
|
|
64
|
+
<h1>${esc(title)}</h1>
|
|
65
|
+
${view}
|
|
66
|
+
</main>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a project folder from an OpenAPI document.
|
|
74
|
+
* @param {string} dir target folder (created; must not contain files we would overwrite)
|
|
75
|
+
* @param {{ spec: string, title?: string, link?: string }} options `link`: path to a checkout of xoong; the project then depends on its packages by path (they are not published yet)
|
|
76
|
+
*/
|
|
77
|
+
export async function scaffold(dir, { spec, title, link }) {
|
|
78
|
+
const target = resolve(dir);
|
|
79
|
+
try { await access(join(target, 'package.json')); throw new Error(`xoong: ${target} already contains a project (package.json exists)`); } catch (error) {
|
|
80
|
+
if (!/ENOENT/.test(String(/** @type {any} */ (error)?.code))) throw error;
|
|
81
|
+
}
|
|
82
|
+
const doc = await load(spec);
|
|
83
|
+
const a = analyze(doc);
|
|
84
|
+
const name = basename(target).toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'xoong-app';
|
|
85
|
+
const pick = pickListScreen(a);
|
|
86
|
+
const heading = title ?? doc.info?.title ?? name;
|
|
87
|
+
const pkg = (n) => (link ? `file:${resolve(link, 'packages', n)}` : '*');
|
|
88
|
+
|
|
89
|
+
await mkdir(join(target, 'src'), { recursive: true });
|
|
90
|
+
await mkdir(join(target, 'test'), { recursive: true });
|
|
91
|
+
await mkdir(join(target, '.xoong'), { recursive: true });
|
|
92
|
+
const files = {
|
|
93
|
+
'package.json': JSON.stringify({
|
|
94
|
+
name, version: '0.0.0', private: true, type: 'module',
|
|
95
|
+
scripts: { dev: 'xoong dev', sync: 'xoong sync openapi.json --hints xoong.hints.json', check: 'xoong check index.html', test: 'vitest run' },
|
|
96
|
+
dependencies: { '@xoong/core': pkg('core'), '@xoong/data': pkg('data'), '@xoong/ui': pkg('ui') },
|
|
97
|
+
devDependencies: { '@xoong/cli': pkg('cli'), '@xoong/test': pkg('test'), vitest: '*', 'happy-dom': '*' },
|
|
98
|
+
// The packages depend on each other by name: point those names at the checkout too.
|
|
99
|
+
...(link ? { overrides: Object.fromEntries(['core', 'data', 'forms', 'ui', 'cli', 'test', 'server'].map((n) => [`@xoong/${n}`, pkg(n)])) } : {}),
|
|
100
|
+
}, null, 2) + '\n',
|
|
101
|
+
'xoong.config.json': JSON.stringify({ port: 5173, proxy: { '/api': 'http://localhost:8080' } }, null, 2) + '\n',
|
|
102
|
+
'xoong.hints.json': JSON.stringify({ idNames: ['id'] }, null, 2) + '\n',
|
|
103
|
+
'index.html': listScreenHtml(pick, heading),
|
|
104
|
+
'src/main.ts': `import { configure } from '@xoong/data';
|
|
105
|
+
import '@xoong/data/view';
|
|
106
|
+
import operations from '/.xoong/operations.json' with { type: 'json' };
|
|
107
|
+
|
|
108
|
+
// The generated table describes every operation; requests go through the dev proxy (see xoong.config.json).
|
|
109
|
+
configure({ api: operations, baseUrl: '/api' });
|
|
110
|
+
|
|
111
|
+
// Mutation failures arrive as bubbling events; show them however you like.
|
|
112
|
+
document.addEventListener('xo-error', (event) => console.warn((event as CustomEvent).detail));
|
|
113
|
+
`,
|
|
114
|
+
'src/app.css': `:root { --space: 1rem; --border: #d0d7de; --accent: #0b5fff; }
|
|
115
|
+
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; }
|
|
116
|
+
main { max-width: 60rem; margin: 0 auto; padding: var(--space); }
|
|
117
|
+
table { width: 100%; border-collapse: collapse; }
|
|
118
|
+
th, td { text-align: left; padding: .5rem; border-bottom: 1px solid var(--border); }
|
|
119
|
+
.error { color: #b42318; }
|
|
120
|
+
`,
|
|
121
|
+
'vitest.config.mjs': `import { defineConfig } from 'vitest/config';
|
|
122
|
+
export default defineConfig({ test: { environment: 'happy-dom', include: ['test/**/*.spec.{mjs,js,ts}'] } });
|
|
123
|
+
`,
|
|
124
|
+
'test/index.spec.mjs': pick ? `import { it, expect, afterEach } from 'vitest';
|
|
125
|
+
import { readFileSync } from 'node:fs';
|
|
126
|
+
import { mount, mockApi } from '@xoong/test';
|
|
127
|
+
import operations from '../.xoong/operations.json';
|
|
128
|
+
|
|
129
|
+
let screen;
|
|
130
|
+
afterEach(() => screen?.unmount());
|
|
131
|
+
|
|
132
|
+
it('shows the ${esc(pick.operation.path)} list', async () => {
|
|
133
|
+
const api = mockApi(operations, { ${JSON.stringify(pick.operation.id)}: () => ${pick.listPath === '.' ? '[]' : `({ ${pick.listPath}: [] })`} });
|
|
134
|
+
const html = readFileSync('index.html', 'utf8').match(/<xo-view[\\s\\S]*<\\/xo-view>/)[0];
|
|
135
|
+
screen = await mount(html, { api });
|
|
136
|
+
expect(api.unhandled).toEqual([]);
|
|
137
|
+
expect(screen.errors).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
` : '',
|
|
140
|
+
'README.md': `# ${heading}
|
|
141
|
+
|
|
142
|
+
Generated by \`xoong new\` from \`openapi.json\`.
|
|
143
|
+
|
|
144
|
+
\`\`\`bash
|
|
145
|
+
npm install
|
|
146
|
+
npm run sync # regenerate .xoong/ after the API changes
|
|
147
|
+
npm run check # check the HTML screens against the API
|
|
148
|
+
npm run dev # http://localhost:5173 (proxies /api to xoong.config.json)
|
|
149
|
+
npm test
|
|
150
|
+
\`\`\`
|
|
151
|
+
`,
|
|
152
|
+
};
|
|
153
|
+
for (const [file, content] of Object.entries(files)) if (content) await writeFile(join(target, file), content);
|
|
154
|
+
await writeFile(join(target, 'openapi.json'), JSON.stringify(doc, null, 2) + '\n');
|
|
155
|
+
// Same output as `xoong sync` (kept in step by sharing the functions).
|
|
156
|
+
await writeFile(join(target, '.xoong', 'operations.json'), JSON.stringify(runtimeTable(a), null, 2) + '\n');
|
|
157
|
+
await writeFile(join(target, '.xoong', 'api.d.ts'), generateDts(a));
|
|
158
|
+
return { dir: target, screen: pick ? `${pick.operation.method} ${pick.operation.path}` : null, files: Object.keys(files).filter((f) => files[f]).concat('openapi.json', '.xoong/operations.json', '.xoong/api.d.ts') };
|
|
159
|
+
}
|