tempest-react-sdk 0.76.0 → 0.78.0
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/README.md +2 -2
- package/bin/create-tempest-app.e2e.test.mjs +112 -0
- package/bin/create-tempest-app.mjs +69 -28
- package/bin/lib/alias/collect.mjs +20 -3
- package/bin/lib/alias/index.mjs +12 -1
- package/bin/lib/alias/index.test.mjs +24 -0
- package/bin/lib/component/generate.mjs +191 -0
- package/bin/lib/component/generate.test.mjs +336 -0
- package/bin/lib/css/analyze.test.mjs +21 -1
- package/bin/lib/css/collect.mjs +14 -0
- package/bin/lib/css/extract.mjs +63 -5
- package/bin/lib/css/extract.test.mjs +110 -1
- package/bin/lib/css/fix.e2e.test.mjs +50 -0
- package/bin/lib/css/index.mjs +6 -1
- package/bin/lib/fs/contain.mjs +50 -0
- package/bin/lib/icons/generate.mjs +28 -3
- package/bin/lib/icons/generate.test.mjs +57 -1
- package/bin/lib/openapi/escape.mjs +224 -0
- package/bin/lib/openapi/generate.encode.test.mjs +120 -0
- package/bin/lib/openapi/generate.hostile.test.mjs +621 -0
- package/bin/lib/openapi/generate.mjs +129 -18
- package/bin/lib/openapi/generate.test.mjs +7 -2
- package/bin/lib/openapi/schema-to-zod.mjs +43 -4
- package/bin/tempest-gen.test.mjs +188 -0
- package/bin/tempest.mjs +263 -6
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +203 -58
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/refresh-queue.cjs +1 -1
- package/dist/auth/refresh-queue.cjs.map +1 -1
- package/dist/auth/refresh-queue.js +13 -6
- package/dist/auth/refresh-queue.js.map +1 -1
- package/dist/br/pix.cjs +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js +85 -72
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +19 -4
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +46 -38
- package/dist/components/Chat/ChatAttachments.js.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.cjs +1 -1
- package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.js +1 -1
- package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
- package/dist/components/CodeBlock/tokenize.cjs +1 -1
- package/dist/components/CodeBlock/tokenize.cjs.map +1 -1
- package/dist/components/CodeBlock/tokenize.js +54 -25
- package/dist/components/CodeBlock/tokenize.js.map +1 -1
- package/dist/components/Markdown/markdown-parse.cjs +7 -5
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js +171 -90
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/components/Toast/Toast.module.cjs +1 -1
- package/dist/components/Toast/Toast.module.cjs.map +1 -1
- package/dist/components/Toast/Toast.module.js +7 -3
- package/dist/components/Toast/Toast.module.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js +77 -51
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +32 -21
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +81 -73
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/errors.cjs +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js +39 -35
- package/dist/http/errors.js.map +1 -1
- package/dist/http/resumable-upload.cjs +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js +141 -105
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/http/retry.cjs +1 -1
- package/dist/http/retry.cjs.map +1 -1
- package/dist/http/retry.js +5 -6
- package/dist/http/retry.js.map +1 -1
- package/dist/oauth/callback.cjs +2 -0
- package/dist/oauth/callback.cjs.map +1 -0
- package/dist/oauth/callback.js +42 -0
- package/dist/oauth/callback.js.map +1 -0
- package/dist/oauth/use-oauth-callback.cjs +1 -1
- package/dist/oauth/use-oauth-callback.cjs.map +1 -1
- package/dist/oauth/use-oauth-callback.js +13 -20
- package/dist/oauth/use-oauth-callback.js.map +1 -1
- package/dist/push/use-push-subscription.cjs +1 -1
- package/dist/push/use-push-subscription.cjs.map +1 -1
- package/dist/push/use-push-subscription.js +42 -42
- package/dist/push/use-push-subscription.js.map +1 -1
- package/dist/push/web-push-client.cjs +1 -1
- package/dist/push/web-push-client.cjs.map +1 -1
- package/dist/push/web-push-client.js +23 -3
- package/dist/push/web-push-client.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +68 -23
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/styles/component/Toast.css +6 -1
- package/dist/styles/feedback.css +6 -1
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +272 -60
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache-inspect.cjs.map +1 -1
- package/dist/sw/cache-inspect.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +91 -64
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/credential-headers.cjs +2 -0
- package/dist/sw/credential-headers.cjs.map +1 -0
- package/dist/sw/credential-headers.js +17 -0
- package/dist/sw/credential-headers.js.map +1 -0
- package/dist/sw/session-data.cjs +2 -0
- package/dist/sw/session-data.cjs.map +1 -0
- package/dist/sw/session-data.js +17 -0
- package/dist/sw/session-data.js.map +1 -0
- package/dist/sw.cjs +1 -1
- package/dist/sw.d.ts +309 -23
- package/dist/sw.js +7 -5
- package/dist/telemetry/app-error-queue.cjs +1 -1
- package/dist/telemetry/app-error-queue.cjs.map +1 -1
- package/dist/telemetry/app-error-queue.js +19 -14
- package/dist/telemetry/app-error-queue.js.map +1 -1
- package/dist/telemetry/app-error-reporter.cjs +1 -1
- package/dist/telemetry/app-error-reporter.cjs.map +1 -1
- package/dist/telemetry/app-error-reporter.js +75 -34
- package/dist/telemetry/app-error-reporter.js.map +1 -1
- package/dist/telemetry/app-error-transport.cjs +1 -1
- package/dist/telemetry/app-error-transport.cjs.map +1 -1
- package/dist/telemetry/app-error-transport.js +16 -3
- package/dist/telemetry/app-error-transport.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +1183 -64
- package/dist/tempest-react-sdk.js +160 -156
- package/dist/utils/csv.cjs +3 -3
- package/dist/utils/csv.cjs.map +1 -1
- package/dist/utils/csv.js +18 -14
- package/dist/utils/csv.js.map +1 -1
- package/dist/utils/owner-fingerprint.cjs +2 -0
- package/dist/utils/owner-fingerprint.cjs.map +1 -0
- package/dist/utils/owner-fingerprint.js +18 -0
- package/dist/utils/owner-fingerprint.js.map +1 -0
- package/dist/utils/safe-url.cjs +2 -0
- package/dist/utils/safe-url.cjs.map +1 -0
- package/dist/utils/safe-url.js +39 -0
- package/dist/utils/safe-url.js.map +1 -0
- package/dist/vite/tempest-csp.cjs +2 -2
- package/dist/vite/tempest-csp.cjs.map +1 -1
- package/dist/vite/tempest-csp.js +130 -64
- package/dist/vite/tempest-csp.js.map +1 -1
- package/dist/vite.d.ts +23 -5
- package/dist/webrtc/peer-link.cjs +1 -1
- package/dist/webrtc/peer-link.cjs.map +1 -1
- package/dist/webrtc/peer-link.js +2 -10
- package/dist/webrtc/peer-link.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +110 -72
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/package.json +1 -1
- package/template-pwa/src/pwa.tsx +22 -0
- package/template-pwa/src/sw.ts +27 -14
- package/dist/components/Markdown/markdown-url.cjs +0 -2
- package/dist/components/Markdown/markdown-url.cjs.map +0 -1
- package/dist/components/Markdown/markdown-url.js +0 -28
- package/dist/components/Markdown/markdown-url.js.map +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// OpenAPI 3.x → per-tag { schemas.ts (Zod), types.ts, service.ts (class) }.
|
|
2
2
|
// Pure: takes the parsed spec object, returns a { path: contents } map. No I/O.
|
|
3
|
-
import {
|
|
3
|
+
import { assertNoCollision, commentText, toIdentifier, urlTemplate } from "./escape.mjs";
|
|
4
|
+
import { refName, schemaToZod, typeName, zodName } from "./schema-to-zod.mjs";
|
|
4
5
|
|
|
5
6
|
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
6
7
|
|
|
@@ -20,19 +21,91 @@ function pascal(s) {
|
|
|
20
21
|
return s.replace(/(^|[^a-zA-Z0-9])([a-zA-Z0-9])/g, (_, _b, ch) => ch.toUpperCase());
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* camelCase method name from operationId or method+path.
|
|
26
|
+
*
|
|
27
|
+
* The result only ever holds `[a-zA-Z0-9]`, so it cannot carry code; an
|
|
28
|
+
* operationId that sanitizes to nothing, or to a leading digit, falls back to
|
|
29
|
+
* the method+path name and is otherwise prefixed with `_` to stay an identifier.
|
|
30
|
+
*/
|
|
24
31
|
function methodName(op, method, path) {
|
|
25
|
-
if (op.operationId) {
|
|
32
|
+
if (typeof op.operationId === "string") {
|
|
26
33
|
const id = op.operationId.replace(/[^a-zA-Z0-9]+(.)?/g, (_, ch) =>
|
|
27
34
|
ch ? ch.toUpperCase() : "",
|
|
28
35
|
);
|
|
29
|
-
return id.charAt(0).toLowerCase() + id.slice(1);
|
|
36
|
+
if (/^[a-zA-Z]/.test(id)) return id.charAt(0).toLowerCase() + id.slice(1);
|
|
30
37
|
}
|
|
31
38
|
const parts = path
|
|
32
39
|
.split("/")
|
|
33
40
|
.filter(Boolean)
|
|
34
|
-
.map((p) => p.replace(/[
|
|
35
|
-
return method + parts.
|
|
41
|
+
.map((p) => pascal(p).replace(/[^a-zA-Z0-9]/g, ""));
|
|
42
|
+
return method + parts.join("");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Member names every generated service class already has: the constructor and the
|
|
47
|
+
* injected `api` property. An operation whose method name lands on one of them is
|
|
48
|
+
* deduplicated like any other collision (`constructor2`, `api2`).
|
|
49
|
+
*/
|
|
50
|
+
const CLASS_MEMBERS = ["constructor", "api"];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Names a generated method body uses besides its path parameters: the `body` and
|
|
54
|
+
* `params` arguments, the `S` schemas import and the `encodeIdSegment` import. A
|
|
55
|
+
* path parameter that maps to one of them becomes `<name>Param` (`{body}` →
|
|
56
|
+
* `bodyParam`), so it neither duplicates an argument nor shadows the import.
|
|
57
|
+
*/
|
|
58
|
+
const METHOD_LOCALS = new Set(["body", "params", "S", "encodeIdSegment"]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a path parameter's schema only ever produces a `string` or a `number` —
|
|
62
|
+
* the types `encodeIdSegment(id: string | number)` accepts — so the generated call
|
|
63
|
+
* can pass the argument as is.
|
|
64
|
+
*
|
|
65
|
+
* `$ref` chains are followed, and `anyOf`/`oneOf`/single-member `allOf` qualify
|
|
66
|
+
* when every branch does. `null`, `boolean`, arrays, objects and anything
|
|
67
|
+
* unrecognised do not.
|
|
68
|
+
*
|
|
69
|
+
* @param {object | undefined} schema - The parameter's schema.
|
|
70
|
+
* @param {Record<string, object>} schemas - `components.schemas`.
|
|
71
|
+
* @param {number} [depth] - Recursion guard for self-referencing unions.
|
|
72
|
+
* @returns {boolean} `true` when the TypeScript type is assignable to `string | number`.
|
|
73
|
+
*/
|
|
74
|
+
function isSegmentScalar(schema, schemas, depth = 0) {
|
|
75
|
+
const node = resolveSchema(schema, schemas);
|
|
76
|
+
if (!node || typeof node !== "object" || depth > 16) return false;
|
|
77
|
+
const branches = node.anyOf ?? node.oneOf ?? (node.allOf?.length === 1 ? node.allOf : null);
|
|
78
|
+
if (branches) return branches.every((b) => isSegmentScalar(b, schemas, depth + 1));
|
|
79
|
+
if (node.nullable || Array.isArray(node.type)) return false;
|
|
80
|
+
return node.type === "string" || node.type === "integer" || node.type === "number";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check every `pattern` in a schema compiles as a JavaScript regex.
|
|
85
|
+
*
|
|
86
|
+
* The generated Zod schema builds it with `new RegExp(...)` when the module is
|
|
87
|
+
* imported, so an invalid one would only surface in the app at runtime — a blank
|
|
88
|
+
* screen. Failing here names the schema and where in it the pattern sits.
|
|
89
|
+
*
|
|
90
|
+
* @param {unknown} node - The schema (sub)node.
|
|
91
|
+
* @param {string} schema - The component schema name, for the error.
|
|
92
|
+
* @param {string[]} [at] - Keys from the schema root to `node`.
|
|
93
|
+
* @throws {Error} When a pattern does not compile.
|
|
94
|
+
*/
|
|
95
|
+
function assertPatterns(node, schema, at = []) {
|
|
96
|
+
if (!node || typeof node !== "object") return;
|
|
97
|
+
if (typeof node.pattern === "string") {
|
|
98
|
+
try {
|
|
99
|
+
new RegExp(node.pattern);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`OpenAPI schema ${JSON.stringify(schema)} at ${at.join(".") || "(root)"}: pattern ${JSON.stringify(node.pattern)} is not a valid JavaScript regular expression (${err.message}).`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const [key, value] of Object.entries(node)) {
|
|
107
|
+
if (key !== "pattern") assertPatterns(value, schema, [...at, key]);
|
|
108
|
+
}
|
|
36
109
|
}
|
|
37
110
|
|
|
38
111
|
/** Collect component-schema names referenced (transitively) by a schema node. */
|
|
@@ -58,7 +131,7 @@ function collectRefs(node, schemas, acc = new Set(), seen = new Set()) {
|
|
|
58
131
|
/** TS type expression for a schema node (uses generated type names for $refs). */
|
|
59
132
|
function tsType(schema) {
|
|
60
133
|
if (!schema || typeof schema !== "object") return "unknown";
|
|
61
|
-
if (schema.$ref) return refName(schema.$ref);
|
|
134
|
+
if (schema.$ref) return typeName(refName(schema.$ref));
|
|
62
135
|
if (schema.allOf) return schema.allOf.map(tsType).join(" & ");
|
|
63
136
|
if (schema.anyOf || schema.oneOf) {
|
|
64
137
|
const parts = (schema.anyOf ?? schema.oneOf).map(tsType);
|
|
@@ -189,6 +262,10 @@ function bodySchema(op) {
|
|
|
189
262
|
*/
|
|
190
263
|
export function generate(doc) {
|
|
191
264
|
const schemas = doc.components?.schemas ?? {};
|
|
265
|
+
assertNoCollision(
|
|
266
|
+
Object.keys(schemas).map((n) => [n, typeName(n)]),
|
|
267
|
+
"schema names",
|
|
268
|
+
);
|
|
192
269
|
const groups = new Map(); // slug → { tag, ops: [...] }
|
|
193
270
|
|
|
194
271
|
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
@@ -202,12 +279,17 @@ export function generate(doc) {
|
|
|
202
279
|
}
|
|
203
280
|
}
|
|
204
281
|
|
|
282
|
+
assertNoCollision(
|
|
283
|
+
[...groups.keys()].map((slug) => [slug, slugIdent(slug)]),
|
|
284
|
+
"tags (as barrel names)",
|
|
285
|
+
);
|
|
286
|
+
|
|
205
287
|
const files = {};
|
|
206
288
|
const tags = [];
|
|
207
289
|
|
|
208
290
|
for (const { tag, slug, ops } of groups.values()) {
|
|
209
291
|
tags.push(tag);
|
|
210
|
-
const Class = `${pascal(slug)}Service`;
|
|
292
|
+
const Class = `${slugIdent(pascal(slug))}Service`;
|
|
211
293
|
|
|
212
294
|
// 1. Which component schemas does this group touch (transitively)?
|
|
213
295
|
const used = new Set();
|
|
@@ -218,6 +300,7 @@ export function generate(doc) {
|
|
|
218
300
|
if (r) collectRefs(r, schemas, used);
|
|
219
301
|
}
|
|
220
302
|
const { sorted, cyclic } = topoSort(used, schemas);
|
|
303
|
+
for (const name of sorted) assertPatterns(schemas[name], name);
|
|
221
304
|
|
|
222
305
|
// 2. schemas.ts
|
|
223
306
|
const schemaLines = sorted.map((name) => {
|
|
@@ -231,17 +314,17 @@ export function generate(doc) {
|
|
|
231
314
|
|
|
232
315
|
// 3. types.ts
|
|
233
316
|
const typeLines = sorted.map(
|
|
234
|
-
(name) => `export type ${name} = z.infer<typeof S.${zodName(name)}>;`,
|
|
317
|
+
(name) => `export type ${typeName(name)} = z.infer<typeof S.${zodName(name)}>;`,
|
|
235
318
|
);
|
|
236
319
|
files[`${slug}/types.ts`] =
|
|
237
320
|
`import type { z } from "zod";\n\nimport * as S from "./schemas";\n\n${typeLines.join("\n")}\n`;
|
|
238
321
|
|
|
239
322
|
// 4. service.ts
|
|
240
|
-
const usedNames = new Set();
|
|
323
|
+
const usedNames = new Set(CLASS_MEMBERS);
|
|
241
324
|
const methods = ops.map(({ method, path, op }) =>
|
|
242
325
|
emitMethod(method, path, op, schemas, usedNames),
|
|
243
326
|
);
|
|
244
|
-
const usedTypeNames = [...used].sort();
|
|
327
|
+
const usedTypeNames = [...used].map(typeName).sort();
|
|
245
328
|
const typeImport = usedTypeNames.length
|
|
246
329
|
? `import type { ${usedTypeNames.join(", ")} } from "./types";\n`
|
|
247
330
|
: "";
|
|
@@ -251,11 +334,16 @@ export function generate(doc) {
|
|
|
251
334
|
const sdkTypes = ["ApiClient"];
|
|
252
335
|
if (body.includes("OffsetPage<")) sdkTypes.push("OffsetPage");
|
|
253
336
|
if (body.includes("CursorPage<")) sdkTypes.push("CursorPage");
|
|
337
|
+
const sdkValues = body.includes("encodeIdSegment(")
|
|
338
|
+
? `import { encodeIdSegment } from "tempest-react-sdk";\n`
|
|
339
|
+
: "";
|
|
254
340
|
files[`${slug}/service.ts`] =
|
|
255
|
-
`import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n
|
|
341
|
+
`import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n` +
|
|
342
|
+
sdkValues +
|
|
343
|
+
"\n" +
|
|
256
344
|
schemaImport +
|
|
257
345
|
typeImport +
|
|
258
|
-
`\n/** Generated service for the "${tag}" routes. Inject an ApiClient (createApiClient). */\n` +
|
|
346
|
+
`\n/** Generated service for the "${commentText(tag)}" routes. Inject an ApiClient (createApiClient). */\n` +
|
|
259
347
|
`export class ${Class} {\n` +
|
|
260
348
|
` constructor(private readonly api: ApiClient) {}\n\n` +
|
|
261
349
|
body +
|
|
@@ -287,6 +375,29 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
287
375
|
}
|
|
288
376
|
usedNames.add(name);
|
|
289
377
|
const pathParams = (op.parameters ?? []).filter((p) => p.in === "path");
|
|
378
|
+
const where = `${method.toUpperCase()} ${path}`;
|
|
379
|
+
const paramId = (name) => {
|
|
380
|
+
const id = toIdentifier(name, "camel", `path parameter in ${where}`);
|
|
381
|
+
return METHOD_LOCALS.has(id) ? `${id}Param` : id;
|
|
382
|
+
};
|
|
383
|
+
const paramIds = new Map(pathParams.map((p) => [p.name, paramId(p.name)]));
|
|
384
|
+
const segmentArgs = new Map(
|
|
385
|
+
pathParams.map((p) => {
|
|
386
|
+
const id = paramIds.get(p.name);
|
|
387
|
+
const schema = p.schema ?? { type: "string" };
|
|
388
|
+
return [p.name, isSegmentScalar(schema, schemas) ? id : `${id} as never`];
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
assertNoCollision(
|
|
392
|
+
[
|
|
393
|
+
...[...paramIds],
|
|
394
|
+
...(bodySchema(op) ? [["the request body argument", "body"]] : []),
|
|
395
|
+
...((op.parameters ?? []).some((p) => p.in === "query")
|
|
396
|
+
? [["the query argument", "params"]]
|
|
397
|
+
: []),
|
|
398
|
+
],
|
|
399
|
+
`method arguments of ${where}`,
|
|
400
|
+
);
|
|
290
401
|
const queryParams = (op.parameters ?? []).filter((p) => p.in === "query");
|
|
291
402
|
const body = bodySchema(op);
|
|
292
403
|
const resp = successSchema(op);
|
|
@@ -299,7 +410,9 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
299
410
|
: "void";
|
|
300
411
|
|
|
301
412
|
const args = [];
|
|
302
|
-
for (const p of pathParams)
|
|
413
|
+
for (const p of pathParams) {
|
|
414
|
+
args.push(`${paramIds.get(p.name)}: ${tsType(p.schema ?? { type: "string" })}`);
|
|
415
|
+
}
|
|
303
416
|
if (body) args.push(`body: ${tsType(body)}`);
|
|
304
417
|
if (queryParams.length) {
|
|
305
418
|
const q = queryParams
|
|
@@ -311,9 +424,7 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
311
424
|
args.push(`params: { ${q} }`);
|
|
312
425
|
}
|
|
313
426
|
|
|
314
|
-
|
|
315
|
-
const tpl = path.replace(/{([^}]+)}/g, (_, n) => "${" + n + "}");
|
|
316
|
-
const url = pathParams.length ? `\`${tpl}\`` : JSON.stringify(path);
|
|
427
|
+
const url = urlTemplate(path, segmentArgs);
|
|
317
428
|
|
|
318
429
|
const callOpts = [];
|
|
319
430
|
if (body) callOpts.push("body");
|
|
@@ -330,7 +441,7 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
330
441
|
const ret = retType === "void" ? "Promise<void>" : `Promise<${retType}>`;
|
|
331
442
|
const generic = retType === "void" ? "" : `<${retType}>`;
|
|
332
443
|
return (
|
|
333
|
-
` /** \`${method.toUpperCase()} ${path}\`${op.summary ? ` — ${op.summary}` : ""} */\n` +
|
|
444
|
+
` /** \`${method.toUpperCase()} ${commentText(path)}\`${op.summary ? ` — ${commentText(op.summary)}` : ""} */\n` +
|
|
334
445
|
` async ${name}(${args.join(", ")}): ${ret} {\n` +
|
|
335
446
|
validation +
|
|
336
447
|
` return this.api.${method}${generic}(${url}${optsArg});\n` +
|
|
@@ -122,8 +122,13 @@ describe("generate — FastAPI-style spec", () => {
|
|
|
122
122
|
// create with Zod input validation
|
|
123
123
|
expect(svc).toContain("S.UserCreateSchema.parse(body);");
|
|
124
124
|
expect(svc).toMatch(/async createUser\(body: UserCreate\): Promise<User>/);
|
|
125
|
-
// path param interpolation
|
|
126
125
|
expect(svc).toMatch(/async getUser\(id: number\): Promise<User>/);
|
|
127
|
-
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("wraps every path param in encodeIdSegment — the raw `${id}` this used to pin sent '../../account' to /account with the Bearer (#499, the class #438 closed)", () => {
|
|
129
|
+
const svc = files["users/service.ts"];
|
|
130
|
+
expect(svc).not.toContain("`/users/${id}`");
|
|
131
|
+
expect(svc).toContain("return this.api.get<User>(`/users/${encodeIdSegment(id)}`);");
|
|
132
|
+
expect(svc).toContain('import { encodeIdSegment } from "tempest-react-sdk";');
|
|
128
133
|
});
|
|
129
134
|
});
|
|
@@ -1,14 +1,53 @@
|
|
|
1
1
|
// JSON Schema (OpenAPI 3.0/3.1 subset) → Zod expression source string.
|
|
2
2
|
// Pure + deterministic so the generated output is stable and testable.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { toIdentifier } from "./escape.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a $ref like "#/components/schemas/User" to its spec schema name
|
|
8
|
+
* ("User"). This is the lookup key into `components.schemas`, not yet an
|
|
9
|
+
* identifier: pass it through {@link typeName} before emitting it.
|
|
10
|
+
*/
|
|
5
11
|
export function refName(ref) {
|
|
6
12
|
return ref.slice(ref.lastIndexOf("/") + 1);
|
|
7
13
|
}
|
|
8
14
|
|
|
9
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Names the generated files declare, import or rely on as globals next to the
|
|
17
|
+
* schema types: the SDK types and the `encodeIdSegment` value `service.ts`
|
|
18
|
+
* imports, the `z` and `S` imports, and the `Promise` / `Record` globals the
|
|
19
|
+
* emitted signatures use. A schema with one of these names would collide with
|
|
20
|
+
* the import or shadow the global.
|
|
21
|
+
*/
|
|
22
|
+
export const GENERATED_NAMES = new Set([
|
|
23
|
+
"ApiClient",
|
|
24
|
+
"CursorPage",
|
|
25
|
+
"OffsetPage",
|
|
26
|
+
"Promise",
|
|
27
|
+
"Record",
|
|
28
|
+
"S",
|
|
29
|
+
"encodeIdSegment",
|
|
30
|
+
"z",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The TypeScript type name for a spec schema name (`user.Response` →
|
|
35
|
+
* `UserResponse`; a valid identifier is kept as is). A name in
|
|
36
|
+
* {@link GENERATED_NAMES} gets a `_` prefix (`Promise` → `_Promise`).
|
|
37
|
+
*
|
|
38
|
+
* @throws {Error} When the name has nothing to build an identifier from.
|
|
39
|
+
*/
|
|
40
|
+
export function typeName(name) {
|
|
41
|
+
const id = toIdentifier(name, "pascal", "schema name");
|
|
42
|
+
return GENERATED_NAMES.has(id) ? `_${id}` : id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The generated Zod const name for a spec schema name
|
|
47
|
+
* (`User` → `UserSchema`, `user.Response` → `UserResponseSchema`).
|
|
48
|
+
*/
|
|
10
49
|
export function zodName(name) {
|
|
11
|
-
return `${name}Schema`;
|
|
50
|
+
return `${typeName(name)}Schema`;
|
|
12
51
|
}
|
|
13
52
|
|
|
14
53
|
function isNullable(schema) {
|
|
@@ -77,7 +116,7 @@ export function schemaToZod(schema, resolveRef = (ref) => zodName(refName(ref)))
|
|
|
77
116
|
if (typeof schema.minLength === "number") e += `.min(${schema.minLength})`;
|
|
78
117
|
if (typeof schema.maxLength === "number") e += `.max(${schema.maxLength})`;
|
|
79
118
|
if (typeof schema.pattern === "string")
|
|
80
|
-
e += `.regex(
|
|
119
|
+
e += `.regex(new RegExp(${JSON.stringify(schema.pattern)}))`;
|
|
81
120
|
return withModifiers(e, schema);
|
|
82
121
|
}
|
|
83
122
|
case "integer":
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
17
|
+
|
|
18
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "tempest.mjs");
|
|
19
|
+
|
|
20
|
+
const SPEC = {
|
|
21
|
+
openapi: "3.0.0",
|
|
22
|
+
info: { title: "t", version: "1" },
|
|
23
|
+
paths: {
|
|
24
|
+
"/users": {
|
|
25
|
+
get: {
|
|
26
|
+
tags: ["users"],
|
|
27
|
+
operationId: "listUsers",
|
|
28
|
+
responses: { 200: { description: "ok" } },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every file under `dir`, relative, sorted — `[]` when nothing was written.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} dir - Absolute folder.
|
|
38
|
+
* @returns {string[]}
|
|
39
|
+
*/
|
|
40
|
+
function filesUnder(dir) {
|
|
41
|
+
return readdirSync(dir, { recursive: true }).sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("tempest gen api / gen icons — write containment (#484)", () => {
|
|
45
|
+
let cwd;
|
|
46
|
+
let outside;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
cwd = mkdtempSync(join(tmpdir(), "tempest-gen-cli-"));
|
|
50
|
+
outside = mkdtempSync(join(tmpdir(), "tempest-gen-outside-"));
|
|
51
|
+
mkdirSync(join(cwd, "src"));
|
|
52
|
+
writeFileSync(join(cwd, "spec.json"), JSON.stringify(SPEC));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
57
|
+
rmSync(outside, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run the `tempest` CLI in the temporary project.
|
|
62
|
+
*
|
|
63
|
+
* @param {string[]} args - CLI arguments.
|
|
64
|
+
* @returns {{ status: number | null, out: string }}
|
|
65
|
+
*/
|
|
66
|
+
function tempest(...args) {
|
|
67
|
+
const result = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8" });
|
|
68
|
+
return { status: result.status, out: result.stdout + result.stderr };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
it("gen api still writes into a regular src/api", () => {
|
|
72
|
+
const { status } = tempest("gen", "api", "spec.json");
|
|
73
|
+
expect(status).toBe(0);
|
|
74
|
+
expect(existsSync(join(cwd, "src", "api", "users", "service.ts"))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("gen api refuses a src/api that is a symlink leaving the project", () => {
|
|
78
|
+
symlinkSync(outside, join(cwd, "src", "api"));
|
|
79
|
+
|
|
80
|
+
const { status, out } = tempest("gen", "api", "spec.json");
|
|
81
|
+
|
|
82
|
+
expect(status).toBe(1);
|
|
83
|
+
expect(out).toMatch(/src\/api is a symlink/);
|
|
84
|
+
expect(filesUnder(outside)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("gen api refuses a symlinked --out even when it points inside the project", () => {
|
|
88
|
+
mkdirSync(join(cwd, "elsewhere"));
|
|
89
|
+
symlinkSync(join(cwd, "elsewhere"), join(cwd, "src", "api"));
|
|
90
|
+
|
|
91
|
+
const { status, out } = tempest("gen", "api", "spec.json");
|
|
92
|
+
|
|
93
|
+
expect(status).toBe(1);
|
|
94
|
+
expect(out).toMatch(/symlink/);
|
|
95
|
+
expect(filesUnder(join(cwd, "elsewhere"))).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("gen api refuses a dangling symlink, which writeFile would follow and create", () => {
|
|
99
|
+
symlinkSync(join(outside, "not-yet"), join(cwd, "src", "api"));
|
|
100
|
+
|
|
101
|
+
const { status, out } = tempest("gen", "api", "spec.json");
|
|
102
|
+
|
|
103
|
+
expect(status).toBe(1);
|
|
104
|
+
expect(out).toMatch(/symlink/);
|
|
105
|
+
expect(existsSync(join(outside, "not-yet"))).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("gen api refuses a symlinked route-group folder below --out, before writing anything", () => {
|
|
109
|
+
mkdirSync(join(cwd, "src", "api"));
|
|
110
|
+
symlinkSync(outside, join(cwd, "src", "api", "users"));
|
|
111
|
+
|
|
112
|
+
const { status, out } = tempest("gen", "api", "spec.json");
|
|
113
|
+
|
|
114
|
+
expect(status).toBe(1);
|
|
115
|
+
expect(out).toMatch(/src\/api\/users is a symlink/);
|
|
116
|
+
expect(filesUnder(outside)).toEqual([]);
|
|
117
|
+
expect(filesUnder(join(cwd, "src", "api"))).toEqual(["users"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("gen api refuses a generated file that is a symlink", () => {
|
|
121
|
+
mkdirSync(join(cwd, "src", "api"));
|
|
122
|
+
const victim = join(outside, "victim.ts");
|
|
123
|
+
writeFileSync(victim, "SENTINEL");
|
|
124
|
+
symlinkSync(victim, join(cwd, "src", "api", "index.ts"));
|
|
125
|
+
|
|
126
|
+
const { status, out } = tempest("gen", "api", "spec.json");
|
|
127
|
+
|
|
128
|
+
expect(status).toBe(1);
|
|
129
|
+
expect(out).toMatch(/src\/api\/index\.ts is a symlink/);
|
|
130
|
+
expect(readFileSync(victim, "utf8")).toBe("SENTINEL");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("gen api refuses an --out whose existing ancestor is a symlink leaving the project", () => {
|
|
134
|
+
symlinkSync(outside, join(cwd, "gen"));
|
|
135
|
+
|
|
136
|
+
const { status, out } = tempest("gen", "api", "spec.json", "--out", "gen/api");
|
|
137
|
+
|
|
138
|
+
expect(status).toBe(1);
|
|
139
|
+
expect(out).toMatch(/outside the project/);
|
|
140
|
+
expect(filesUnder(outside)).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("gen api refuses an --out outside the project", () => {
|
|
144
|
+
const { status, out } = tempest("gen", "api", "spec.json", "--out", outside);
|
|
145
|
+
expect(status).toBe(1);
|
|
146
|
+
expect(out).toMatch(/outside the project/);
|
|
147
|
+
expect(filesUnder(outside)).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("gen icons refuses a src/icons.generated.ts that is a symlink", () => {
|
|
151
|
+
const victim = join(outside, "victim.ts");
|
|
152
|
+
writeFileSync(victim, "SENTINEL");
|
|
153
|
+
symlinkSync(victim, join(cwd, "src", "icons.generated.ts"));
|
|
154
|
+
|
|
155
|
+
const { status, out } = tempest("gen", "icons");
|
|
156
|
+
|
|
157
|
+
expect(status).toBe(1);
|
|
158
|
+
expect(out).toMatch(/src\/icons\.generated\.ts is a symlink/);
|
|
159
|
+
expect(readFileSync(victim, "utf8")).toBe("SENTINEL");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("gen icons refuses a dangling symlink at the output", () => {
|
|
163
|
+
symlinkSync(join(outside, "not-yet.ts"), join(cwd, "src", "icons.generated.ts"));
|
|
164
|
+
|
|
165
|
+
const { status, out } = tempest("gen", "icons");
|
|
166
|
+
|
|
167
|
+
expect(status).toBe(1);
|
|
168
|
+
expect(out).toMatch(/symlink/);
|
|
169
|
+
expect(existsSync(join(outside, "not-yet.ts"))).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("gen icons refuses an --out whose folder is a symlink leaving the project", () => {
|
|
173
|
+
symlinkSync(outside, join(cwd, "gen"));
|
|
174
|
+
|
|
175
|
+
const { status, out } = tempest("gen", "icons", "--out", "gen/icons.ts");
|
|
176
|
+
|
|
177
|
+
expect(status).toBe(1);
|
|
178
|
+
expect(out).toMatch(/gen is a symlink/);
|
|
179
|
+
expect(filesUnder(outside)).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("gen icons refuses an --out outside the project", () => {
|
|
183
|
+
const { status, out } = tempest("gen", "icons", "--out", join(outside, "icons.ts"));
|
|
184
|
+
expect(status).toBe(1);
|
|
185
|
+
expect(out).toMatch(/outside the project/);
|
|
186
|
+
expect(filesUnder(outside)).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|