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.
Files changed (173) hide show
  1. package/README.md +2 -2
  2. package/bin/create-tempest-app.e2e.test.mjs +112 -0
  3. package/bin/create-tempest-app.mjs +69 -28
  4. package/bin/lib/alias/collect.mjs +20 -3
  5. package/bin/lib/alias/index.mjs +12 -1
  6. package/bin/lib/alias/index.test.mjs +24 -0
  7. package/bin/lib/component/generate.mjs +191 -0
  8. package/bin/lib/component/generate.test.mjs +336 -0
  9. package/bin/lib/css/analyze.test.mjs +21 -1
  10. package/bin/lib/css/collect.mjs +14 -0
  11. package/bin/lib/css/extract.mjs +63 -5
  12. package/bin/lib/css/extract.test.mjs +110 -1
  13. package/bin/lib/css/fix.e2e.test.mjs +50 -0
  14. package/bin/lib/css/index.mjs +6 -1
  15. package/bin/lib/fs/contain.mjs +50 -0
  16. package/bin/lib/icons/generate.mjs +28 -3
  17. package/bin/lib/icons/generate.test.mjs +57 -1
  18. package/bin/lib/openapi/escape.mjs +224 -0
  19. package/bin/lib/openapi/generate.encode.test.mjs +120 -0
  20. package/bin/lib/openapi/generate.hostile.test.mjs +621 -0
  21. package/bin/lib/openapi/generate.mjs +129 -18
  22. package/bin/lib/openapi/generate.test.mjs +7 -2
  23. package/bin/lib/openapi/schema-to-zod.mjs +43 -4
  24. package/bin/tempest-gen.test.mjs +188 -0
  25. package/bin/tempest.mjs +263 -6
  26. package/dist/auth/create-tempest-auth.cjs +1 -1
  27. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  28. package/dist/auth/create-tempest-auth.js +203 -58
  29. package/dist/auth/create-tempest-auth.js.map +1 -1
  30. package/dist/auth/refresh-queue.cjs +1 -1
  31. package/dist/auth/refresh-queue.cjs.map +1 -1
  32. package/dist/auth/refresh-queue.js +13 -6
  33. package/dist/auth/refresh-queue.js.map +1 -1
  34. package/dist/br/pix.cjs +1 -1
  35. package/dist/br/pix.cjs.map +1 -1
  36. package/dist/br/pix.js +85 -72
  37. package/dist/br/pix.js.map +1 -1
  38. package/dist/br.d.ts +19 -4
  39. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  40. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  41. package/dist/components/Chat/ChatAttachments.js +46 -38
  42. package/dist/components/Chat/ChatAttachments.js.map +1 -1
  43. package/dist/components/CodeBlock/CodeBlock.cjs +1 -1
  44. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
  45. package/dist/components/CodeBlock/CodeBlock.js +1 -1
  46. package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
  47. package/dist/components/CodeBlock/tokenize.cjs +1 -1
  48. package/dist/components/CodeBlock/tokenize.cjs.map +1 -1
  49. package/dist/components/CodeBlock/tokenize.js +54 -25
  50. package/dist/components/CodeBlock/tokenize.js.map +1 -1
  51. package/dist/components/Markdown/markdown-parse.cjs +7 -5
  52. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  53. package/dist/components/Markdown/markdown-parse.js +171 -90
  54. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  55. package/dist/components/Toast/Toast.module.cjs +1 -1
  56. package/dist/components/Toast/Toast.module.cjs.map +1 -1
  57. package/dist/components/Toast/Toast.module.js +7 -3
  58. package/dist/components/Toast/Toast.module.js.map +1 -1
  59. package/dist/components/Toast/ToastProvider.cjs +1 -1
  60. package/dist/components/Toast/ToastProvider.cjs.map +1 -1
  61. package/dist/components/Toast/ToastProvider.js +77 -51
  62. package/dist/components/Toast/ToastProvider.js.map +1 -1
  63. package/dist/data/create-data-provider.cjs +1 -1
  64. package/dist/data/create-data-provider.cjs.map +1 -1
  65. package/dist/data/create-data-provider.js +32 -21
  66. package/dist/data/create-data-provider.js.map +1 -1
  67. package/dist/http/api-client.cjs +1 -1
  68. package/dist/http/api-client.cjs.map +1 -1
  69. package/dist/http/api-client.js +81 -73
  70. package/dist/http/api-client.js.map +1 -1
  71. package/dist/http/errors.cjs +1 -1
  72. package/dist/http/errors.cjs.map +1 -1
  73. package/dist/http/errors.js +39 -35
  74. package/dist/http/errors.js.map +1 -1
  75. package/dist/http/resumable-upload.cjs +1 -1
  76. package/dist/http/resumable-upload.cjs.map +1 -1
  77. package/dist/http/resumable-upload.js +141 -105
  78. package/dist/http/resumable-upload.js.map +1 -1
  79. package/dist/http/retry.cjs +1 -1
  80. package/dist/http/retry.cjs.map +1 -1
  81. package/dist/http/retry.js +5 -6
  82. package/dist/http/retry.js.map +1 -1
  83. package/dist/oauth/callback.cjs +2 -0
  84. package/dist/oauth/callback.cjs.map +1 -0
  85. package/dist/oauth/callback.js +42 -0
  86. package/dist/oauth/callback.js.map +1 -0
  87. package/dist/oauth/use-oauth-callback.cjs +1 -1
  88. package/dist/oauth/use-oauth-callback.cjs.map +1 -1
  89. package/dist/oauth/use-oauth-callback.js +13 -20
  90. package/dist/oauth/use-oauth-callback.js.map +1 -1
  91. package/dist/push/use-push-subscription.cjs +1 -1
  92. package/dist/push/use-push-subscription.cjs.map +1 -1
  93. package/dist/push/use-push-subscription.js +42 -42
  94. package/dist/push/use-push-subscription.js.map +1 -1
  95. package/dist/push/web-push-client.cjs +1 -1
  96. package/dist/push/web-push-client.cjs.map +1 -1
  97. package/dist/push/web-push-client.js +23 -3
  98. package/dist/push/web-push-client.js.map +1 -1
  99. package/dist/query/offline-persistence.cjs +1 -1
  100. package/dist/query/offline-persistence.cjs.map +1 -1
  101. package/dist/query/offline-persistence.js +68 -23
  102. package/dist/query/offline-persistence.js.map +1 -1
  103. package/dist/styles/component/Toast.css +6 -1
  104. package/dist/styles/feedback.css +6 -1
  105. package/dist/styles.css +1 -1
  106. package/dist/sw/background-sync.cjs +1 -1
  107. package/dist/sw/background-sync.cjs.map +1 -1
  108. package/dist/sw/background-sync.js +272 -60
  109. package/dist/sw/background-sync.js.map +1 -1
  110. package/dist/sw/cache-inspect.cjs.map +1 -1
  111. package/dist/sw/cache-inspect.js.map +1 -1
  112. package/dist/sw/cache.cjs +1 -1
  113. package/dist/sw/cache.cjs.map +1 -1
  114. package/dist/sw/cache.js +91 -64
  115. package/dist/sw/cache.js.map +1 -1
  116. package/dist/sw/credential-headers.cjs +2 -0
  117. package/dist/sw/credential-headers.cjs.map +1 -0
  118. package/dist/sw/credential-headers.js +17 -0
  119. package/dist/sw/credential-headers.js.map +1 -0
  120. package/dist/sw/session-data.cjs +2 -0
  121. package/dist/sw/session-data.cjs.map +1 -0
  122. package/dist/sw/session-data.js +17 -0
  123. package/dist/sw/session-data.js.map +1 -0
  124. package/dist/sw.cjs +1 -1
  125. package/dist/sw.d.ts +309 -23
  126. package/dist/sw.js +7 -5
  127. package/dist/telemetry/app-error-queue.cjs +1 -1
  128. package/dist/telemetry/app-error-queue.cjs.map +1 -1
  129. package/dist/telemetry/app-error-queue.js +19 -14
  130. package/dist/telemetry/app-error-queue.js.map +1 -1
  131. package/dist/telemetry/app-error-reporter.cjs +1 -1
  132. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  133. package/dist/telemetry/app-error-reporter.js +75 -34
  134. package/dist/telemetry/app-error-reporter.js.map +1 -1
  135. package/dist/telemetry/app-error-transport.cjs +1 -1
  136. package/dist/telemetry/app-error-transport.cjs.map +1 -1
  137. package/dist/telemetry/app-error-transport.js +16 -3
  138. package/dist/telemetry/app-error-transport.js.map +1 -1
  139. package/dist/tempest-react-sdk.cjs +1 -1
  140. package/dist/tempest-react-sdk.d.ts +1183 -64
  141. package/dist/tempest-react-sdk.js +160 -156
  142. package/dist/utils/csv.cjs +3 -3
  143. package/dist/utils/csv.cjs.map +1 -1
  144. package/dist/utils/csv.js +18 -14
  145. package/dist/utils/csv.js.map +1 -1
  146. package/dist/utils/owner-fingerprint.cjs +2 -0
  147. package/dist/utils/owner-fingerprint.cjs.map +1 -0
  148. package/dist/utils/owner-fingerprint.js +18 -0
  149. package/dist/utils/owner-fingerprint.js.map +1 -0
  150. package/dist/utils/safe-url.cjs +2 -0
  151. package/dist/utils/safe-url.cjs.map +1 -0
  152. package/dist/utils/safe-url.js +39 -0
  153. package/dist/utils/safe-url.js.map +1 -0
  154. package/dist/vite/tempest-csp.cjs +2 -2
  155. package/dist/vite/tempest-csp.cjs.map +1 -1
  156. package/dist/vite/tempest-csp.js +130 -64
  157. package/dist/vite/tempest-csp.js.map +1 -1
  158. package/dist/vite.d.ts +23 -5
  159. package/dist/webrtc/peer-link.cjs +1 -1
  160. package/dist/webrtc/peer-link.cjs.map +1 -1
  161. package/dist/webrtc/peer-link.js +2 -10
  162. package/dist/webrtc/peer-link.js.map +1 -1
  163. package/dist/webrtc/peer-mesh.cjs +1 -1
  164. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  165. package/dist/webrtc/peer-mesh.js +110 -72
  166. package/dist/webrtc/peer-mesh.js.map +1 -1
  167. package/package.json +1 -1
  168. package/template-pwa/src/pwa.tsx +22 -0
  169. package/template-pwa/src/sw.ts +27 -14
  170. package/dist/components/Markdown/markdown-url.cjs +0 -2
  171. package/dist/components/Markdown/markdown-url.cjs.map +0 -1
  172. package/dist/components/Markdown/markdown-url.js +0 -28
  173. 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 { refName, zodName, schemaToZod } from "./schema-to-zod.mjs";
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
- /** camelCase method name from operationId or method+path. */
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(/[{}]/g, ""));
35
- return method + parts.map((p) => pascal(p)).join("");
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\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) args.push(`${p.name}: ${tsType(p.schema ?? { type: "string" })}`);
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
- // Interpolate path params into a template literal.
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
- expect(svc).toContain("return this.api.get<User>(`/users/${id}`);");
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
- /** Convert a $ref like "#/components/schemas/User" to its schema name ("User"). */
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
- /** The generated Zod const name for a schema name (e.g. "User" → "UserSchema"). */
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(/${schema.pattern.replace(/\//g, "\\/")}/)`;
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
+ });