tempest-react-sdk 0.77.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/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/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 +20 -6
- package/bin/lib/openapi/generate.encode.test.mjs +120 -0
- package/bin/lib/openapi/generate.hostile.test.mjs +38 -9
- package/bin/lib/openapi/generate.mjs +41 -6
- package/bin/lib/openapi/generate.test.mjs +7 -2
- package/bin/lib/openapi/schema-to-zod.mjs +5 -3
- package/bin/tempest-gen.test.mjs +188 -0
- package/bin/tempest.mjs +120 -44
- 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 +186 -113
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/br/pix.cjs +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js +30 -27
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +11 -4
- 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 +6 -6
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js +91 -74
- package/dist/components/Markdown/markdown-parse.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/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +32 -31
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/credential-headers.cjs.map +1 -1
- package/dist/sw/credential-headers.js.map +1 -1
- package/dist/sw.d.ts +46 -8
- 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 +1 -1
- package/dist/telemetry/app-error-reporter.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +214 -12
- package/dist/tempest-react-sdk.js +119 -119
- package/dist/utils/csv.cjs +3 -3
- package/dist/utils/csv.cjs.map +1 -1
- package/dist/utils/csv.js +11 -10
- package/dist/utils/csv.js.map +1 -1
- package/dist/{telemetry/app-error-owner.cjs → utils/owner-fingerprint.cjs} +2 -2
- package/dist/utils/owner-fingerprint.cjs.map +1 -0
- package/dist/{telemetry/app-error-owner.js → utils/owner-fingerprint.js} +3 -3
- package/dist/utils/owner-fingerprint.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 +69 -58
- package/dist/vite/tempest-csp.js.map +1 -1
- package/dist/vite.d.ts +5 -3
- package/package.json +1 -1
- package/dist/telemetry/app-error-owner.cjs.map +0 -1
- package/dist/telemetry/app-error-owner.js.map +0 -1
|
@@ -180,13 +180,23 @@ function templateText(text) {
|
|
|
180
180
|
*
|
|
181
181
|
* Static segments are escaped for the template literal; each `{name}` placeholder
|
|
182
182
|
* is matched by the spec's original name against the declared path parameters
|
|
183
|
-
* and becomes `${identifier}`, the variable that parameter was
|
|
184
|
-
* (`{user-id}` →
|
|
185
|
-
*
|
|
183
|
+
* and becomes `${encodeIdSegment(identifier)}`, the variable that parameter was
|
|
184
|
+
* mapped to passed through the SDK's segment encoder (`{user-id}` →
|
|
185
|
+
* `${encodeIdSegment(userId)}`). A path without placeholders is emitted as a
|
|
186
|
+
* JSON string literal.
|
|
187
|
+
*
|
|
188
|
+
* The encoder is what keeps a runtime value inside its own segment: interpolated
|
|
189
|
+
* raw, `"../../account"` turned `DELETE /api/comments/{id}` into
|
|
190
|
+
* `DELETE /account` carrying the Bearer, and `"7?role=admin"` injected a query
|
|
191
|
+
* string (#499 — the class #438 closed for the data provider). Encoded, `/`, `?`,
|
|
192
|
+
* `#` and `%` stay in the segment, and `null`, `undefined`, `""`, `"."` and
|
|
193
|
+
* `".."` throw before the request is built.
|
|
186
194
|
*
|
|
187
195
|
* @param {string} path - The OpenAPI path key ("/users/{user-id}").
|
|
188
|
-
* @param {Map<string, string>} declared - Original name →
|
|
189
|
-
* the operation's `in: "path"` params
|
|
196
|
+
* @param {Map<string, string>} declared - Original name → argument expression of
|
|
197
|
+
* each of the operation's `in: "path"` params: the identifier, or the
|
|
198
|
+
* identifier with a type assertion when its type is wider than
|
|
199
|
+
* `encodeIdSegment` accepts.
|
|
190
200
|
* @returns {string} A TypeScript expression evaluating to the URL.
|
|
191
201
|
* @throws {Error} When a placeholder is not a declared path parameter — there is
|
|
192
202
|
* no variable it could safely map to.
|
|
@@ -202,7 +212,11 @@ export function urlTemplate(path, declared) {
|
|
|
202
212
|
`OpenAPI path ${JSON.stringify(path)}: placeholder ${JSON.stringify(name)} is not a declared path parameter.`,
|
|
203
213
|
);
|
|
204
214
|
}
|
|
205
|
-
out +=
|
|
215
|
+
out +=
|
|
216
|
+
templateText(path.slice(last, match.index)) +
|
|
217
|
+
"${encodeIdSegment(" +
|
|
218
|
+
declared.get(name) +
|
|
219
|
+
")}";
|
|
206
220
|
last = match.index + match[0].length;
|
|
207
221
|
}
|
|
208
222
|
out += templateText(path.slice(last));
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { encodeIdSegment } from "../../../src/data";
|
|
5
|
+
import { createApiClient } from "../../../src/http";
|
|
6
|
+
import { generate } from "./generate.mjs";
|
|
7
|
+
|
|
8
|
+
const BASE = "https://api.example.test";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Transpile one generated `service.ts` and load it as CommonJS, resolving
|
|
12
|
+
* `tempest-react-sdk` to the SDK's real source — so the call goes through the
|
|
13
|
+
* genuine `encodeIdSegment` and `createApiClient`, not a stub of either.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} source - The generated `service.ts` contents.
|
|
16
|
+
* @returns {Record<string, unknown>} The module's exports.
|
|
17
|
+
*/
|
|
18
|
+
function load(source) {
|
|
19
|
+
const { outputText } = ts.transpileModule(source, {
|
|
20
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
21
|
+
});
|
|
22
|
+
const module = { exports: {} };
|
|
23
|
+
const require = (id) => {
|
|
24
|
+
if (id === "tempest-react-sdk") return { encodeIdSegment };
|
|
25
|
+
throw new Error(`unexpected import ${id}`);
|
|
26
|
+
};
|
|
27
|
+
new Function("exports", "require", "module", outputText)(module.exports, require, module);
|
|
28
|
+
return module.exports;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate a one-route `comments` service (`GET`/`DELETE /api/comments/{id}`)
|
|
33
|
+
* bound to a real `ApiClient` whose `fetcher` records every request.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} [schema] - The `id` path parameter's schema.
|
|
36
|
+
* @param {string} [baseURL] - The client's base URL.
|
|
37
|
+
* @returns {{ service: Record<string, Function>, seen: string[], source: string }}
|
|
38
|
+
*/
|
|
39
|
+
function commentsService(schema = { type: "string" }, baseURL = BASE) {
|
|
40
|
+
const op = (operationId) => ({
|
|
41
|
+
operationId,
|
|
42
|
+
tags: ["comments"],
|
|
43
|
+
parameters: [{ name: "id", in: "path", required: true, schema }],
|
|
44
|
+
responses: { 204: { description: "x" } },
|
|
45
|
+
});
|
|
46
|
+
const { files } = generate({
|
|
47
|
+
openapi: "3.1.0",
|
|
48
|
+
info: { title: "t", version: "1" },
|
|
49
|
+
paths: { "/api/comments/{id}": { get: op("getComment"), delete: op("deleteComment") } },
|
|
50
|
+
});
|
|
51
|
+
const source = files["comments/service.ts"];
|
|
52
|
+
const seen = [];
|
|
53
|
+
const api = createApiClient({
|
|
54
|
+
baseURL,
|
|
55
|
+
getToken: () => "DUMMY_TOKEN",
|
|
56
|
+
fetcher: async (url, init) => {
|
|
57
|
+
seen.push(`${init?.method ?? "GET"} ${String(url)}`);
|
|
58
|
+
return new Response(null, { status: 204 });
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const { CommentsService } = load(source);
|
|
62
|
+
return { service: new CommentsService(api), seen, source };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("generate — path params are encoded into one segment (#499, as #438 did for the data provider)", () => {
|
|
66
|
+
it.each([
|
|
67
|
+
["../../account", "..%2F..%2Faccount"],
|
|
68
|
+
["../x", "..%2Fx"],
|
|
69
|
+
["%2e%2e/%2e%2e/account", "%252e%252e%2F%252e%252e%2Faccount"],
|
|
70
|
+
["7?a=b", "7%3Fa%3Db"],
|
|
71
|
+
["7?role=admin", "7%3Frole%3Dadmin"],
|
|
72
|
+
["7#x", "7%23x"],
|
|
73
|
+
])("a hostile id %j stays one segment under /api/comments", async (id, segment) => {
|
|
74
|
+
const { service, seen } = commentsService();
|
|
75
|
+
await service.deleteComment(id);
|
|
76
|
+
expect(seen).toEqual([`DELETE ${BASE}/api/comments/${segment}`]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("a base URL prefix cannot be escaped with extra dot-segments", async () => {
|
|
80
|
+
const { service, seen } = commentsService({ type: "string" }, `${BASE}/v1`);
|
|
81
|
+
await service.deleteComment("../../../account");
|
|
82
|
+
expect(seen).toEqual([`DELETE ${BASE}/v1/api/comments/..%2F..%2F..%2Faccount`]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it.each(["", ".", ".."])("the dot-segment id %j throws before any request", async (id) => {
|
|
86
|
+
const { service, seen } = commentsService();
|
|
87
|
+
await expect(service.deleteComment(id)).rejects.toThrow(TypeError);
|
|
88
|
+
expect(seen).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("null and undefined from an untyped caller throw instead of becoming a literal segment", async () => {
|
|
92
|
+
const { service, seen } = commentsService();
|
|
93
|
+
await expect(service.getComment(null)).rejects.toThrow(TypeError);
|
|
94
|
+
await expect(service.getComment(undefined)).rejects.toThrow(TypeError);
|
|
95
|
+
expect(seen).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("a numeric id is still sent as its decimal segment", async () => {
|
|
99
|
+
const { service, seen, source } = commentsService({ type: "integer" });
|
|
100
|
+
expect(source).toContain("async getComment(id: number): Promise<void>");
|
|
101
|
+
await service.getComment(7);
|
|
102
|
+
expect(seen).toEqual([`GET ${BASE}/api/comments/7`]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
["a boolean", { type: "boolean" }, true, "true"],
|
|
107
|
+
["an array", { type: "array", items: { type: "string" } }, ["a", "b/c"], "a%2Cb%2Fc"],
|
|
108
|
+
["a nullable string", { type: ["string", "null"] }, "a/b", "a%2Fb"],
|
|
109
|
+
])("%s path param is encoded too", async (_kind, schema, value, segment) => {
|
|
110
|
+
const { service, seen } = commentsService(schema);
|
|
111
|
+
await service.getComment(value);
|
|
112
|
+
expect(seen).toEqual([`GET ${BASE}/api/comments/${segment}`]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("a nullable path param still rejects null at runtime", async () => {
|
|
116
|
+
const { service, seen } = commentsService({ type: ["string", "null"] });
|
|
117
|
+
await expect(service.getComment(null)).rejects.toThrow(TypeError);
|
|
118
|
+
expect(seen).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -6,6 +6,7 @@ import vm from "node:vm";
|
|
|
6
6
|
import ts from "typescript";
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
8
|
|
|
9
|
+
import { encodeIdSegment } from "../../../src/data";
|
|
9
10
|
import { generate } from "./generate.mjs";
|
|
10
11
|
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
@@ -14,7 +15,9 @@ const require = createRequire(import.meta.url);
|
|
|
14
15
|
*
|
|
15
16
|
* The context gets its own `globalThis`, so any `globalThis.PWNED_*` an injected
|
|
16
17
|
* statement sets lands on `context` and the test can read it back. `zod` is the
|
|
17
|
-
* real package
|
|
18
|
+
* real package, `tempest-react-sdk` exposes the real `encodeIdSegment` every path
|
|
19
|
+
* parameter goes through (#499); `./schemas` resolves to the module evaluated
|
|
20
|
+
* before it.
|
|
18
21
|
*
|
|
19
22
|
* @param {string} source - The generated `.ts` contents.
|
|
20
23
|
* @param {object} context - The VM context shared by the group's files.
|
|
@@ -33,7 +36,8 @@ function evaluate(source, context, modules) {
|
|
|
33
36
|
const module = { exports: {} };
|
|
34
37
|
const localRequire = (id) => {
|
|
35
38
|
if (id in modules) return modules[id];
|
|
36
|
-
if (id === "zod"
|
|
39
|
+
if (id === "zod") return require("zod");
|
|
40
|
+
if (id === "tempest-react-sdk") return { encodeIdSegment };
|
|
37
41
|
throw new Error(`unexpected import ${id}`);
|
|
38
42
|
};
|
|
39
43
|
vm.runInContext(`(function (exports, require, module) {\n${outputText}\n})`, context)(
|
|
@@ -398,7 +402,7 @@ const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
|
398
402
|
*
|
|
399
403
|
* Files live in a virtual folder under the repository so `zod` resolves from its
|
|
400
404
|
* `node_modules`; `tempest-react-sdk` is swapped for a stub with the three types
|
|
401
|
-
* the generator imports from it.
|
|
405
|
+
* and the `encodeIdSegment` value the generator imports from it.
|
|
402
406
|
*
|
|
403
407
|
* @param {Record<string, string>} files - The generator output.
|
|
404
408
|
* @param {string} slug - The group folder.
|
|
@@ -409,7 +413,7 @@ function typecheck(files, slug) {
|
|
|
409
413
|
const virtual = new Map(
|
|
410
414
|
["schemas.ts", "types.ts", "service.ts"].map((f) => [
|
|
411
415
|
join(root, f),
|
|
412
|
-
files[`${slug}/${f}`].
|
|
416
|
+
files[`${slug}/${f}`].replaceAll('from "tempest-react-sdk"', 'from "./sdk"'),
|
|
413
417
|
]),
|
|
414
418
|
);
|
|
415
419
|
virtual.set(
|
|
@@ -419,6 +423,7 @@ function typecheck(files, slug) {
|
|
|
419
423
|
"export type ApiClient = { get: Call; post: Call; put: Call; patch: Call; delete: Call };",
|
|
420
424
|
"export type OffsetPage<T> = { items: T[]; total: number };",
|
|
421
425
|
"export type CursorPage<T> = { items: T[]; next_cursor: string | null };",
|
|
426
|
+
"export declare function encodeIdSegment(id: string | number): string;",
|
|
422
427
|
].join("\n"),
|
|
423
428
|
);
|
|
424
429
|
const options = {
|
|
@@ -451,11 +456,11 @@ const JSON_BODY = (ref) => ({
|
|
|
451
456
|
const JSON_OK = (ref) => ({ 200: JSON_BODY(ref) });
|
|
452
457
|
|
|
453
458
|
describe("generate — names that collide with what the generator itself declares", () => {
|
|
454
|
-
it("a path parameter named body, params or
|
|
459
|
+
it("a path parameter named body, params, S or encodeIdSegment is renamed, not duplicated or shadowing", async () => {
|
|
455
460
|
const { files } = generate(
|
|
456
461
|
spec(
|
|
457
462
|
{
|
|
458
|
-
"/a/{body}/{params}/{S}": {
|
|
463
|
+
"/a/{body}/{params}/{S}/{encodeIdSegment}": {
|
|
459
464
|
post: {
|
|
460
465
|
tags: ["t"],
|
|
461
466
|
operationId: "send",
|
|
@@ -463,6 +468,7 @@ describe("generate — names that collide with what the generator itself declare
|
|
|
463
468
|
{ name: "body", in: "path", required: true },
|
|
464
469
|
{ name: "params", in: "path", required: true },
|
|
465
470
|
{ name: "S", in: "path", required: true },
|
|
471
|
+
{ name: "encodeIdSegment", in: "path", required: true },
|
|
466
472
|
{ name: "q", in: "query" },
|
|
467
473
|
],
|
|
468
474
|
requestBody: JSON_BODY("Req"),
|
|
@@ -479,8 +485,11 @@ describe("generate — names that collide with what the generator itself declare
|
|
|
479
485
|
const mod = evaluate(files["t/service.ts"], context, { "./schemas": S });
|
|
480
486
|
const calls = [];
|
|
481
487
|
const api = { post: async (url, opts) => calls.push({ url, opts }) };
|
|
482
|
-
await new mod.TService(api).send("b", "p", "s", { k: 1 }, { q: "x" });
|
|
483
|
-
expect(
|
|
488
|
+
await new mod.TService(api).send("b", "p", "s", "e/x", { k: 1 }, { q: "x" });
|
|
489
|
+
expect(files["t/service.ts"]).toContain("${encodeIdSegment(encodeIdSegmentParam)}");
|
|
490
|
+
expect(calls).toEqual([
|
|
491
|
+
{ url: "/a/b/p/s/e%2Fx", opts: { body: { k: 1 }, params: { q: "x" } } },
|
|
492
|
+
]);
|
|
484
493
|
});
|
|
485
494
|
|
|
486
495
|
it("an operationId of constructor or api becomes a distinct method", async () => {
|
|
@@ -541,7 +550,16 @@ describe("generate — names that collide with what the generator itself declare
|
|
|
541
550
|
size: { type: "integer" },
|
|
542
551
|
},
|
|
543
552
|
};
|
|
544
|
-
const names = [
|
|
553
|
+
const names = [
|
|
554
|
+
"ApiClient",
|
|
555
|
+
"OffsetPage",
|
|
556
|
+
"CursorPage",
|
|
557
|
+
"Promise",
|
|
558
|
+
"Record",
|
|
559
|
+
"z",
|
|
560
|
+
"S",
|
|
561
|
+
"encodeIdSegment",
|
|
562
|
+
];
|
|
545
563
|
const paths = Object.fromEntries(
|
|
546
564
|
names.map((n, i) => [
|
|
547
565
|
`/n${i}`,
|
|
@@ -556,10 +574,21 @@ describe("generate — names that collide with what the generator itself declare
|
|
|
556
574
|
},
|
|
557
575
|
},
|
|
558
576
|
};
|
|
577
|
+
paths["/p/{id}"] = {
|
|
578
|
+
get: {
|
|
579
|
+
tags: ["t"],
|
|
580
|
+
parameters: [{ name: "id", in: "path", required: true }],
|
|
581
|
+
responses: OK,
|
|
582
|
+
},
|
|
583
|
+
};
|
|
559
584
|
const schemas = Object.fromEntries(names.map((n) => [n, { type: "object" }]));
|
|
560
585
|
schemas.Page = page;
|
|
561
586
|
const { files } = generate(spec(paths, schemas));
|
|
562
587
|
expect(files["t/service.ts"]).toContain("OffsetPage<_Promise>");
|
|
588
|
+
expect(files["t/service.ts"]).toContain(
|
|
589
|
+
'import { encodeIdSegment } from "tempest-react-sdk";',
|
|
590
|
+
);
|
|
591
|
+
expect(files["t/types.ts"]).toContain("export type _encodeIdSegment =");
|
|
563
592
|
expect(typecheck(files, "t")).toEqual([]);
|
|
564
593
|
});
|
|
565
594
|
|
|
@@ -51,11 +51,34 @@ const CLASS_MEMBERS = ["constructor", "api"];
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Names a generated method body uses besides its path parameters: the `body` and
|
|
54
|
-
* `params` arguments
|
|
55
|
-
* of them becomes `<name>Param` (`{body}` →
|
|
56
|
-
* an argument nor shadows the import.
|
|
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
57
|
*/
|
|
58
|
-
const METHOD_LOCALS = new Set(["body", "params", "S"]);
|
|
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
|
+
}
|
|
59
82
|
|
|
60
83
|
/**
|
|
61
84
|
* Check every `pattern` in a schema compiles as a JavaScript regex.
|
|
@@ -311,8 +334,13 @@ export function generate(doc) {
|
|
|
311
334
|
const sdkTypes = ["ApiClient"];
|
|
312
335
|
if (body.includes("OffsetPage<")) sdkTypes.push("OffsetPage");
|
|
313
336
|
if (body.includes("CursorPage<")) sdkTypes.push("CursorPage");
|
|
337
|
+
const sdkValues = body.includes("encodeIdSegment(")
|
|
338
|
+
? `import { encodeIdSegment } from "tempest-react-sdk";\n`
|
|
339
|
+
: "";
|
|
314
340
|
files[`${slug}/service.ts`] =
|
|
315
|
-
`import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n
|
|
341
|
+
`import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n` +
|
|
342
|
+
sdkValues +
|
|
343
|
+
"\n" +
|
|
316
344
|
schemaImport +
|
|
317
345
|
typeImport +
|
|
318
346
|
`\n/** Generated service for the "${commentText(tag)}" routes. Inject an ApiClient (createApiClient). */\n` +
|
|
@@ -353,6 +381,13 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
353
381
|
return METHOD_LOCALS.has(id) ? `${id}Param` : id;
|
|
354
382
|
};
|
|
355
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
|
+
);
|
|
356
391
|
assertNoCollision(
|
|
357
392
|
[
|
|
358
393
|
...[...paramIds],
|
|
@@ -389,7 +424,7 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
389
424
|
args.push(`params: { ${q} }`);
|
|
390
425
|
}
|
|
391
426
|
|
|
392
|
-
const url = urlTemplate(path,
|
|
427
|
+
const url = urlTemplate(path, segmentArgs);
|
|
393
428
|
|
|
394
429
|
const callOpts = [];
|
|
395
430
|
if (body) callOpts.push("body");
|
|
@@ -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
|
});
|
|
@@ -14,9 +14,10 @@ export function refName(ref) {
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Names the generated files declare, import or rely on as globals next to the
|
|
17
|
-
* schema types: the SDK types
|
|
18
|
-
* the `
|
|
19
|
-
*
|
|
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.
|
|
20
21
|
*/
|
|
21
22
|
export const GENERATED_NAMES = new Set([
|
|
22
23
|
"ApiClient",
|
|
@@ -25,6 +26,7 @@ export const GENERATED_NAMES = new Set([
|
|
|
25
26
|
"Promise",
|
|
26
27
|
"Record",
|
|
27
28
|
"S",
|
|
29
|
+
"encodeIdSegment",
|
|
28
30
|
"z",
|
|
29
31
|
]);
|
|
30
32
|
|
|
@@ -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
|
+
});
|