tempest-react-sdk 0.79.0 → 0.80.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.
@@ -84,6 +84,8 @@ function spec(paths, schemas = {}) {
84
84
  }
85
85
 
86
86
  const OK = { 200: { description: "ok" } };
87
+ const schemaRef = (name) => ({ $ref: `#/components/schemas/${name}` });
88
+ const json = (schema) => ({ content: { "application/json": { schema } } });
87
89
 
88
90
  describe("generate — hostile spec text never becomes code (#434)", () => {
89
91
  it("a tag that closes the class doc comment stays inside it", async () => {
@@ -233,12 +235,45 @@ describe("generate — hostile spec text never becomes code (#434)", () => {
233
235
  await runGroup(files, "t");
234
236
  });
235
237
 
238
+ /**
239
+ * Rewritten for #516: this used to pass with `spec({}, …)`, two schemas no
240
+ * route reaches. The check now runs over the schemas a group emits, so the
241
+ * pair has to be referenced to collide — and still is refused, naming both.
242
+ */
236
243
  it("two schema names that map to the same identifier are refused, naming both", () => {
244
+ const both = {
245
+ 200: json({ anyOf: [schemaRef("User-Out"), schemaRef("user.Out")] }),
246
+ };
237
247
  expect(() =>
238
- generate(spec({}, { "User-Out": { type: "object" }, "user.Out": { type: "object" } })),
248
+ generate(
249
+ spec(
250
+ { "/a": { get: { tags: ["t"], responses: both } } },
251
+ { "User-Out": { type: "object" }, "user.Out": { type: "object" } },
252
+ ),
253
+ ),
239
254
  ).toThrow(/"User-Out" and "user\.Out" both map to the TypeScript identifier "UserOut"/);
240
255
  });
241
256
 
257
+ it("schema names no route reaches are neither emitted nor checked, so an orphan cannot abort the run (#516)", () => {
258
+ const { files } = generate(
259
+ spec(
260
+ {
261
+ "/a": {
262
+ get: { tags: ["t"], responses: { 200: json(schemaRef("MediaType")) } },
263
+ },
264
+ },
265
+ {
266
+ MediaType: { type: "string", enum: ["image/png"] },
267
+ "Media-Type": { type: "string", enum: ["image/png", "image/webp"] },
268
+ "***": { type: "object" },
269
+ },
270
+ ),
271
+ );
272
+ expect(files["t/types.ts"]).toContain("export type MediaType = ");
273
+ expect(files["t/schemas.ts"]).not.toContain("image/webp");
274
+ expect(files["t/types.ts"].match(/export type/g)).toHaveLength(1);
275
+ });
276
+
242
277
  it("a path parameter that maps onto another method argument is refused", () => {
243
278
  expect(() =>
244
279
  generate(
@@ -258,10 +293,16 @@ describe("generate — hostile spec text never becomes code (#434)", () => {
258
293
  ).toThrow(/"user-id" and "userId" both map to the TypeScript identifier "userId"/);
259
294
  });
260
295
 
296
+ /** Rewritten for #516 like the collision above: the schema is now referenced. */
261
297
  it("a name with nothing to build an identifier from is refused", () => {
262
- expect(() => generate(spec({}, { "***": { type: "object" } }))).toThrow(
263
- /schema name "\*\*\*" has no letter or digit/,
264
- );
298
+ expect(() =>
299
+ generate(
300
+ spec(
301
+ { "/a": { get: { tags: ["t"], responses: { 200: json(schemaRef("***")) } } } },
302
+ { "***": { type: "object" } },
303
+ ),
304
+ ),
305
+ ).toThrow(/schema name "\*\*\*" has no letter or digit/);
265
306
  });
266
307
 
267
308
  it("a regex pattern with an escaped slash or a line break compiles to the same regex", () => {
@@ -50,12 +50,88 @@ function methodName(op, method, path) {
50
50
  const CLASS_MEMBERS = ["constructor", "api"];
51
51
 
52
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.
53
+ * Names a generated method body uses besides its path parameters: the `body`,
54
+ * `params` and `headers` arguments, the `S` schemas import, the `encodeIdSegment`
55
+ * import and the module-level `headerRecord` helper. A path parameter that maps to
56
+ * one of them becomes `<name>Param` (`{body}` → `bodyParam`), so it neither
57
+ * duplicates an argument nor shadows the import.
57
58
  */
58
- const METHOD_LOCALS = new Set(["body", "params", "S", "encodeIdSegment"]);
59
+ const METHOD_LOCALS = new Set([
60
+ "body",
61
+ "params",
62
+ "headers",
63
+ "S",
64
+ "encodeIdSegment",
65
+ "headerRecord",
66
+ ]);
67
+
68
+ /**
69
+ * Header parameter names OpenAPI 3 says a generator SHALL ignore: the client sets
70
+ * `Accept` and `Content-Type` from the request it builds and `Authorization` from
71
+ * its `getToken`, so a spec that also declares them must not open a second way to
72
+ * send them. Compared case-insensitively, as HTTP header names are.
73
+ */
74
+ const IGNORED_HEADERS = new Set(["accept", "content-type", "authorization"]);
75
+
76
+ /**
77
+ * Module-level helper a `service.ts` declares when one of its methods sends header
78
+ * parameters. `fetch` turns a header value of `undefined` into the literal text
79
+ * `"undefined"`, and `RequestOptions.headers` only takes strings, so an optional
80
+ * header left out is dropped and every other value goes through `String()`.
81
+ */
82
+ const HEADER_RECORD_HELPER = [
83
+ "/** Header parameters as strings, without the ones left out (`fetch` would send `undefined` as text). */",
84
+ "function headerRecord(",
85
+ " values: Record<string, string | number | boolean | null | undefined>,",
86
+ "): Record<string, string> {",
87
+ " const out: Record<string, string> = {};",
88
+ " for (const [name, value] of Object.entries(values)) {",
89
+ " if (value !== undefined && value !== null) out[name] = String(value);",
90
+ " }",
91
+ " return out;",
92
+ "}",
93
+ ].join("\n");
94
+
95
+ /**
96
+ * Follow a `#/components/parameters/...` `$ref` chain to the parameter object.
97
+ *
98
+ * @param {object | undefined} param - A parameter object or a reference to one.
99
+ * @param {Record<string, object>} components - `components.parameters`.
100
+ * @returns {object | undefined} The parameter object, or `undefined` when the
101
+ * chain does not resolve.
102
+ */
103
+ function resolveParameter(param, components) {
104
+ let node = param;
105
+ let guard = 0;
106
+ while (node && node.$ref && guard++ < 16) node = components[refName(node.$ref)];
107
+ return node && !node.$ref ? node : undefined;
108
+ }
109
+
110
+ /**
111
+ * The parameters that apply to one operation: the path item's `parameters`
112
+ * merged with the operation's, the operation winning on the same `(name, in)` —
113
+ * the rule OpenAPI 3 sets for the two levels.
114
+ *
115
+ * Before #509 only `operation.parameters` was read, so a `{id}` declared on the
116
+ * path item reached the URL literally (`"/items/{id}"`, measured), and a path item
117
+ * declaring one placeholder while the operation declared the other refused to
118
+ * generate. `$ref`s into `components.parameters` are resolved here too: they were
119
+ * dropped the same way, because a reference object has no `in`.
120
+ *
121
+ * @param {object} item - The path item.
122
+ * @param {object} op - The operation.
123
+ * @param {Record<string, object>} components - `components.parameters`.
124
+ * @returns {object[]} The effective parameter objects, path-item order first.
125
+ */
126
+ function operationParameters(item, op, components) {
127
+ const merged = new Map();
128
+ for (const raw of [...(item.parameters ?? []), ...(op.parameters ?? [])]) {
129
+ const param = resolveParameter(raw, components);
130
+ if (!param || typeof param.name !== "string" || typeof param.in !== "string") continue;
131
+ merged.set(`${param.in}\u0000${param.name}`, param);
132
+ }
133
+ return [...merged.values()];
134
+ }
59
135
 
60
136
  /**
61
137
  * Whether a path parameter's schema only ever produces a `string` or a `number` —
@@ -208,16 +284,17 @@ function isQueryScalar(schema, schemas, depth = 0) {
208
284
  }
209
285
 
210
286
  /**
211
- * TS type for a query-param value. Query params serialize to strings, so the
212
- * client only accepts `string | number | boolean | null`. Anything richer
213
- * (objects, arrays) is narrowed to `string` to stay assignable.
287
+ * TS type for a query- or header-param value. Both serialize to strings, so the
288
+ * client's `params` and the generated `headerRecord` only accept
289
+ * `string | number | boolean | null`. Anything richer (objects, arrays) is
290
+ * narrowed to `string` to stay assignable.
214
291
  *
215
292
  * @param {object | undefined} schema - The parameter's schema.
216
293
  * @param {Record<string, object>} schemas - `components.schemas`.
217
294
  * @param {Set<string>} refs - Collects the schemas the type names.
218
295
  * @returns {string} The TypeScript type expression.
219
296
  */
220
- function queryParamType(schema, schemas, refs) {
297
+ function scalarParamType(schema, schemas, refs) {
221
298
  const node = schema ?? { type: "string" };
222
299
  return isQueryScalar(node, schemas) ? tsType(node, refs) : "string";
223
300
  }
@@ -343,16 +420,25 @@ function compareNames(a, b) {
343
420
  * template's ESLint enforces, so the output passes its lint and `tsc` with
344
421
  * `noUnusedLocals` as generated (#504, #505).
345
422
  *
423
+ * A component schema no route reaches is never emitted — FastAPI publishes an
424
+ * unreferenced enum for every `Enum`-annotated field (`ImageMediaType`, #516),
425
+ * and it lands in no group. Its name is not checked either: the identifier
426
+ * collision check runs per group over the schemas that group emits, so an orphan
427
+ * named `Media-Type` next to a referenced `MediaType` no longer aborts the whole
428
+ * run for a type nobody would see.
429
+ *
430
+ * Parameters are read from the path item and the operation, merged by
431
+ * `(name, in)` (see {@link operationParameters}); `in: "cookie"` is skipped,
432
+ * because a browser `fetch` cannot set `Cookie` — the browser sends its own
433
+ * cookies with `credentials`, which is how a cookie parameter reaches the server.
434
+ *
346
435
  * @param {object} doc - The parsed OpenAPI 3.x spec.
347
436
  * @returns {{ files: Record<string, string>, tags: string[] }}
348
437
  */
349
438
  export function generate(doc) {
350
439
  const schemas = doc.components?.schemas ?? {};
351
- assertNoCollision(
352
- Object.keys(schemas).map((n) => [n, typeName(n)]),
353
- "schema names",
354
- );
355
- const groups = new Map(); // slug → { tag, ops: [...] }
440
+ const parameterComponents = doc.components?.parameters ?? {};
441
+ const groups = new Map();
356
442
 
357
443
  for (const [path, item] of Object.entries(doc.paths ?? {})) {
358
444
  for (const method of HTTP_METHODS) {
@@ -361,7 +447,8 @@ export function generate(doc) {
361
447
  const tag = op.tags?.[0] ?? "default";
362
448
  const slug = tagSlug(tag);
363
449
  if (!groups.has(slug)) groups.set(slug, { tag, slug, ops: [] });
364
- groups.get(slug).ops.push({ method, path, op });
450
+ const params = operationParameters(item, op, parameterComponents);
451
+ groups.get(slug).ops.push({ method, path, op, params });
365
452
  }
366
453
  }
367
454
 
@@ -378,9 +465,9 @@ export function generate(doc) {
378
465
  const Class = `${slugIdent(pascal(slug))}Service`;
379
466
 
380
467
  const usedNames = new Set(CLASS_MEMBERS);
381
- const signature = { refs: new Set(), validates: false };
382
- const methods = ops.map(({ method, path, op }) =>
383
- emitMethod(method, path, op, schemas, usedNames, signature),
468
+ const signature = { refs: new Set(), validates: false, sendsHeaders: false };
469
+ const methods = ops.map(({ method, path, op, params }) =>
470
+ emitMethod(method, path, op, params, schemas, usedNames, signature),
384
471
  );
385
472
 
386
473
  const used = new Set();
@@ -392,6 +479,10 @@ export function generate(doc) {
392
479
  }
393
480
  for (const name of signature.refs) collectRefs({ $ref: name }, schemas, used);
394
481
  const { sorted, cyclic } = topoSort(used, schemas);
482
+ assertNoCollision(
483
+ sorted.map((n) => [n, typeName(n)]),
484
+ "schema names",
485
+ );
395
486
  for (const name of sorted) assertPatterns(schemas[name], name);
396
487
 
397
488
  const schemaLines = sorted.map((name) => {
@@ -433,6 +524,7 @@ export function generate(doc) {
433
524
  .filter((group) => group.length)
434
525
  .map((group) => group.join("\n"))
435
526
  .join("\n\n") +
527
+ (signature.sendsHeaders ? `\n\n${HEADER_RECORD_HELPER}` : "") +
436
528
  `\n\n/** Generated service for the "${commentText(tag)}" routes. Inject an ApiClient (createApiClient). */\n` +
437
529
  `export class ${Class} {\n` +
438
530
  ` constructor(private readonly api: ApiClient) {}\n\n` +
@@ -461,16 +553,27 @@ export function generate(doc) {
461
553
  * pagination envelope maps to `OffsetPage<T>` / `CursorPage<T>`, and a body typed
462
554
  * by a `$ref` is validated with its Zod schema before the call.
463
555
  *
556
+ * Arguments come in a fixed order: path parameters, `body`, `params` (query),
557
+ * `headers`. Header parameters are typed like query parameters and sent through
558
+ * the request's `headers`, via `headerRecord`; the `headers` argument defaults to
559
+ * `{}` when none of them is required, so a spec gaining an optional header does
560
+ * not break the call sites. `Accept`, `Content-Type` and `Authorization` are left
561
+ * out ({@link IGNORED_HEADERS}), and so is every `in: "cookie"` parameter: a
562
+ * browser `fetch` cannot set `Cookie` (forbidden header name), the cookie jar
563
+ * sends it with `credentials`.
564
+ *
464
565
  * @param {string} method - The HTTP method, lower case.
465
566
  * @param {string} path - The OpenAPI path template.
466
567
  * @param {object} op - The operation object.
568
+ * @param {object[]} parameters - The effective parameters, from {@link operationParameters}.
467
569
  * @param {Record<string, object>} schemas - `components.schemas`.
468
570
  * @param {Set<string>} usedNames - Member names already taken in the class.
469
- * @param {{ refs: Set<string>, validates: boolean }} signature - Collects the
470
- * component schemas the emitted types name, and whether `S` was used.
571
+ * @param {{ refs: Set<string>, validates: boolean, sendsHeaders: boolean }} signature -
572
+ * Collects the component schemas the emitted types name, whether `S` was used
573
+ * and whether `headerRecord` was.
471
574
  * @returns {string} The method source.
472
575
  */
473
- function emitMethod(method, path, op, schemas, usedNames, signature) {
576
+ function emitMethod(method, path, op, parameters, schemas, usedNames, signature) {
474
577
  let name = methodName(op, method, path);
475
578
  if (usedNames.has(name)) {
476
579
  let n = 2;
@@ -478,7 +581,11 @@ function emitMethod(method, path, op, schemas, usedNames, signature) {
478
581
  name = `${name}${n}`;
479
582
  }
480
583
  usedNames.add(name);
481
- const pathParams = (op.parameters ?? []).filter((p) => p.in === "path");
584
+ const pathParams = parameters.filter((p) => p.in === "path");
585
+ const queryParams = parameters.filter((p) => p.in === "query");
586
+ const headerParams = parameters.filter(
587
+ (p) => p.in === "header" && !IGNORED_HEADERS.has(p.name.toLowerCase()),
588
+ );
482
589
  const where = `${method.toUpperCase()} ${path}`;
483
590
  const paramId = (name) => {
484
591
  const id = toIdentifier(name, "camel", `path parameter in ${where}`);
@@ -496,13 +603,11 @@ function emitMethod(method, path, op, schemas, usedNames, signature) {
496
603
  [
497
604
  ...[...paramIds],
498
605
  ...(bodySchema(op) ? [["the request body argument", "body"]] : []),
499
- ...((op.parameters ?? []).some((p) => p.in === "query")
500
- ? [["the query argument", "params"]]
501
- : []),
606
+ ...(queryParams.length ? [["the query argument", "params"]] : []),
607
+ ...(headerParams.length ? [["the headers argument", "headers"]] : []),
502
608
  ],
503
609
  `method arguments of ${where}`,
504
610
  );
505
- const queryParams = (op.parameters ?? []).filter((p) => p.in === "query");
506
611
  const body = bodySchema(op);
507
612
  const resp = successSchema(op);
508
613
  const page = resp ? detectPage(resp, schemas, signature.refs) : null;
@@ -512,6 +617,14 @@ function emitMethod(method, path, op, schemas, usedNames, signature) {
512
617
  ? tsType(resp, signature.refs)
513
618
  : "void";
514
619
 
620
+ const fields = (list) =>
621
+ list
622
+ .map(
623
+ (p) =>
624
+ `${JSON.stringify(p.name)}${p.required ? "" : "?"}: ${scalarParamType(p.schema, schemas, signature.refs)}`,
625
+ )
626
+ .join("; ");
627
+
515
628
  const args = [];
516
629
  for (const p of pathParams) {
517
630
  args.push(
@@ -519,14 +632,11 @@ function emitMethod(method, path, op, schemas, usedNames, signature) {
519
632
  );
520
633
  }
521
634
  if (body) args.push(`body: ${tsType(body, signature.refs)}`);
522
- if (queryParams.length) {
523
- const q = queryParams
524
- .map(
525
- (p) =>
526
- `${JSON.stringify(p.name)}${p.required ? "" : "?"}: ${queryParamType(p.schema, schemas, signature.refs)}`,
527
- )
528
- .join("; ");
529
- args.push(`params: { ${q} }`);
635
+ if (queryParams.length) args.push(`params: { ${fields(queryParams)} }`);
636
+ if (headerParams.length) {
637
+ const optional = headerParams.every((p) => !p.required);
638
+ args.push(`headers: { ${fields(headerParams)} }${optional ? " = {}" : ""}`);
639
+ signature.sendsHeaders = true;
530
640
  }
531
641
 
532
642
  const url = urlTemplate(path, segmentArgs);
@@ -534,6 +644,7 @@ function emitMethod(method, path, op, schemas, usedNames, signature) {
534
644
  const callOpts = [];
535
645
  if (body) callOpts.push("body");
536
646
  if (queryParams.length) callOpts.push("params");
647
+ if (headerParams.length) callOpts.push("headers: headerRecord(headers)");
537
648
  const optsArg = callOpts.length ? `, { ${callOpts.join(", ")} }` : "";
538
649
 
539
650
  let validation = "";
@@ -6,6 +6,7 @@ import { ESLint } from "eslint";
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 SPEC = {
@@ -175,7 +176,7 @@ function templateTsc(files) {
175
176
  virtual.set(
176
177
  join(OUT, "node_modules", "tempest-react-sdk", "index.d.ts"),
177
178
  [
178
- "type Call = <T = unknown>(url: string, opts?: { body?: unknown; params?: Record<string, string | number | boolean | undefined | null> }) => Promise<T>;",
179
+ "type Call = <T = unknown>(url: string, opts?: { body?: unknown; params?: Record<string, string | number | boolean | undefined | null>; headers?: Record<string, string> }) => Promise<T>;",
179
180
  "export type ApiClient = { get: Call; post: Call; put: Call; patch: Call; delete: Call };",
180
181
  "export type OffsetPage<T> = { items: T[]; total: number };",
181
182
  "export type CursorPage<T> = { items: T[]; next_cursor: string | null };",
@@ -244,8 +245,8 @@ const json = (schema) => ({ content: { "application/json": { schema } } });
244
245
  * | every `types.ts` | `import * as S` used only in `typeof` — `@typescript-eslint/consistent-type-imports` |
245
246
  * | every `index.ts` + root barrel | `simple-import-sort/exports` |
246
247
  *
247
- * Header parameters and path-item-level `parameters` are not read by the
248
- * generator at all, so they cannot produce an import.
248
+ * The `X-Trace` header parameter was not read at all then; since #509 it is, and
249
+ * its `Code` type is imported like any other signature type.
249
250
  */
250
251
  const EVERY_POSITION = {
251
252
  openapi: "3.1.0",
@@ -361,7 +362,7 @@ describe("generate — output passes the create-tempest-app template's tsc and E
361
362
 
362
363
  it("service.ts imports only the types its signatures name, and S only when a body is validated", () => {
363
364
  expect(files["items/service.ts"]).toContain(
364
- 'import type { Item, ItemCreate, Kind, Sort } from "./types";',
365
+ 'import type { Code, Item, ItemCreate, Kind, Sort } from "./types";',
365
366
  );
366
367
  expect(files["items/service.ts"]).not.toContain('import * as S from "./schemas";');
367
368
  expect(files["reports/service.ts"]).not.toContain("./schemas");
@@ -394,3 +395,201 @@ describe("generate — output passes the create-tempest-app template's tsc and E
394
395
  expect(await templateLint(out)).toEqual([]);
395
396
  });
396
397
  });
398
+
399
+ /**
400
+ * Transpile a generated `service.ts` and instantiate its class with a recording
401
+ * `ApiClient`, to read back the URL and options each call actually sends.
402
+ * `encodeIdSegment` is the SDK's real one.
403
+ *
404
+ * @param {string} source - The generated `service.ts`.
405
+ * @returns {{ service: object, calls: Array<[string, object | undefined]> }}
406
+ */
407
+ function instantiate(source) {
408
+ const { outputText } = ts.transpileModule(source, {
409
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
410
+ });
411
+ const module = { exports: {} };
412
+ const requireStub = (id) => {
413
+ if (id === "tempest-react-sdk") return { encodeIdSegment };
414
+ throw new Error(`unexpected import ${id}`);
415
+ };
416
+ new Function("exports", "require", "module", outputText)(module.exports, requireStub, module);
417
+ const calls = [];
418
+ const api = new Proxy(
419
+ {},
420
+ {
421
+ get: () => async (url, opts) => {
422
+ calls.push([url, opts]);
423
+ },
424
+ },
425
+ );
426
+ const Class = Object.values(module.exports).find((v) => typeof v === "function");
427
+ return { service: new Class(api), calls };
428
+ }
429
+
430
+ /**
431
+ * Measured on `main` before #509 with minimal specs run through `generate`:
432
+ *
433
+ * | Spec | Output before the fix |
434
+ * | --- | --- |
435
+ * | `{id}` declared only on the path item | `getItem(): Promise<void>` calling `"/items/{id}"` — placeholder sent literally |
436
+ * | path item declares `{x}`, operation declares `{y}` | refused: `placeholder "x" is not a declared path parameter` |
437
+ * | required `in: "header"` | `list()` — no argument, nothing sent |
438
+ * | `in: "cookie"` | ignored (stays ignored, on purpose: `fetch` cannot set `Cookie`) |
439
+ * | path-item query + operation override | the path item's `q` lost; the override only looked right because the path item was never read |
440
+ * | parameter `$ref` into `components.parameters` | dropped like the path-item case: `"/items/{id}"` literal |
441
+ */
442
+ const PARAMS = {
443
+ openapi: "3.1.0",
444
+ components: {
445
+ schemas: { Region: { type: "string", enum: ["br", "us"] } },
446
+ parameters: {
447
+ Version: { name: "v", in: "path", required: true, schema: { type: "integer" } },
448
+ },
449
+ },
450
+ paths: {
451
+ "/items/{id}": {
452
+ parameters: [
453
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
454
+ { name: "X-Tenant-Id", in: "header", required: true, schema: { type: "string" } },
455
+ { name: "limit", in: "query", schema: { type: "string" } },
456
+ ],
457
+ get: {
458
+ tags: ["items"],
459
+ operationId: "get_item",
460
+ parameters: [
461
+ { name: "limit", in: "query", required: true, schema: { type: "integer" } },
462
+ { name: "X-Region", in: "header", schema: ref("Region") },
463
+ { name: "Retry-Count", in: "header", schema: { type: "integer" } },
464
+ { name: "session", in: "cookie", schema: { type: "string" } },
465
+ { name: "Authorization", in: "header", schema: { type: "string" } },
466
+ ],
467
+ responses: { 200: { description: "ok" } },
468
+ },
469
+ delete: {
470
+ tags: ["items"],
471
+ operationId: "delete_item",
472
+ parameters: [
473
+ {
474
+ name: "X-Tenant-Id",
475
+ in: "header",
476
+ required: false,
477
+ schema: { type: "string" },
478
+ },
479
+ ],
480
+ responses: { 204: { description: "gone" } },
481
+ },
482
+ },
483
+ "/v{v}/items": {
484
+ post: {
485
+ tags: ["items"],
486
+ operationId: "create_item",
487
+ parameters: [
488
+ { $ref: "#/components/parameters/Version" },
489
+ { name: "Idempotency-Key", in: "header", schema: { type: "string" } },
490
+ ],
491
+ responses: { 201: { description: "ok" } },
492
+ },
493
+ },
494
+ },
495
+ };
496
+
497
+ describe("generate — path-item, header and $ref parameters (#509)", () => {
498
+ const { files } = generate(PARAMS);
499
+ const svc = files["items/service.ts"];
500
+
501
+ it("a path parameter declared only on the path item is an argument and goes through encodeIdSegment", () => {
502
+ expect(svc).not.toContain('"/items/{id}"');
503
+ expect(svc).toContain("`/items/${encodeIdSegment(id)}`");
504
+ });
505
+
506
+ it("path-item and operation parameters merge, the operation winning on the same (name, in)", () => {
507
+ expect(svc).toContain(
508
+ 'async getItem(id: string, params: { "limit": number }, headers: { "X-Tenant-Id": string; "X-Region"?: Region; "Retry-Count"?: number }): Promise<void>',
509
+ );
510
+ expect(svc).toContain(
511
+ 'async deleteItem(id: string, params: { "limit"?: string }, headers: { "X-Tenant-Id"?: string } = {}): Promise<void>',
512
+ );
513
+ });
514
+
515
+ it("a parameter $ref into components.parameters is resolved", () => {
516
+ expect(svc).toContain("async createItem(v: number, headers: ");
517
+ expect(svc).toContain("`/v${encodeIdSegment(v)}/items`");
518
+ });
519
+
520
+ it("cookie parameters and the Accept/Content-Type/Authorization headers are not emitted", () => {
521
+ expect(svc).not.toContain("session");
522
+ expect(svc).not.toContain("Authorization");
523
+ });
524
+
525
+ it("header values are sent as strings, and an optional header left out is not sent as 'undefined'", async () => {
526
+ const { service, calls } = instantiate(svc);
527
+ await service.getItem("a/b", { limit: 5 }, { "X-Tenant-Id": "t1", "Retry-Count": 3 });
528
+ await service.deleteItem("7", {});
529
+ await service.createItem(2);
530
+ expect(calls).toEqual([
531
+ [
532
+ "/items/a%2Fb",
533
+ { params: { limit: 5 }, headers: { "X-Tenant-Id": "t1", "Retry-Count": "3" } },
534
+ ],
535
+ ["/items/7", { params: {}, headers: {} }],
536
+ ["/v2/items", { headers: {} }],
537
+ ]);
538
+ });
539
+
540
+ it("type-checks and lints clean under the create-tempest-app template", async () => {
541
+ expect(templateTsc(files)).toEqual([]);
542
+ expect(await templateLint(files)).toEqual([]);
543
+ });
544
+ });
545
+
546
+ /**
547
+ * FastAPI publishes a component for every `Enum`-annotated field even when the
548
+ * field's own schema carries a narrower inline enum, so `ImageMediaType` appears
549
+ * with no `$ref` pointing at it. Measured on `main` before #516: it was already
550
+ * left out of every group, because a group only emits the schemas its routes
551
+ * reach. What did break was the name check, which ran over every
552
+ * `components.schemas` entry — an orphan whose name collided with another
553
+ * (`ImageMediaType` / `Image-Media-Type`) aborted the whole run.
554
+ */
555
+ const ORPHAN_ENUM = {
556
+ openapi: "3.1.0",
557
+ components: {
558
+ schemas: {
559
+ ImageMediaType: { type: "string", enum: ["image/png", "image/jpeg", "image/webp"] },
560
+ "Image-Media-Type": { type: "string", enum: ["image/gif"] },
561
+ Kind: { type: "string", enum: ["photo", "avatar"] },
562
+ Upload: {
563
+ type: "object",
564
+ properties: {
565
+ kind: ref("Kind"),
566
+ media_type: { type: "string", enum: ["image/png", "image/jpeg"] },
567
+ },
568
+ },
569
+ },
570
+ },
571
+ paths: {
572
+ "/uploads": {
573
+ post: {
574
+ tags: ["uploads"],
575
+ operationId: "create_upload",
576
+ requestBody: json(ref("Upload")),
577
+ responses: { 201: json(ref("Upload")) },
578
+ },
579
+ },
580
+ },
581
+ };
582
+
583
+ describe("generate — enum schemas no $ref reaches (#516)", () => {
584
+ it("keeps an enum reached transitively and drops the orphan, even when the orphan's name collides", async () => {
585
+ const { files } = generate(ORPHAN_ENUM);
586
+ expect(files["uploads/types.ts"]).toContain("export type Kind = ");
587
+ expect(files["uploads/schemas.ts"]).toContain(
588
+ 'export const KindSchema = z.enum(["photo", "avatar"]);',
589
+ );
590
+ expect(Object.values(files).join("\n")).not.toContain("ImageMediaType");
591
+ expect(Object.values(files).join("\n")).not.toContain("image/webp");
592
+ expect(templateTsc(files)).toEqual([]);
593
+ expect(await templateLint(files)).toEqual([]);
594
+ });
595
+ });
@@ -15,9 +15,9 @@ export function refName(ref) {
15
15
  /**
16
16
  * Names the generated files declare, import or rely on as globals next to the
17
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.
18
+ * imports, the `headerRecord` helper it declares, the `z` and `S` imports, and
19
+ * the `Promise` / `Record` globals the emitted signatures use. A schema with one
20
+ * of these names would collide with the import or shadow the global.
21
21
  */
22
22
  export const GENERATED_NAMES = new Set([
23
23
  "ApiClient",
@@ -27,6 +27,7 @@ export const GENERATED_NAMES = new Set([
27
27
  "Record",
28
28
  "S",
29
29
  "encodeIdSegment",
30
+ "headerRecord",
30
31
  "z",
31
32
  ]);
32
33
 
@@ -1,2 +1,2 @@
1
- const e=require("../utils/ids.cjs"),t=require("./build-url.cjs"),n=require("./credential-scope.cjs"),r=require("./csrf.cjs"),i=require("./decode-response.cjs"),a=require("./errors.cjs"),o=require("./retry.cjs"),s=require("./timeout.cjs");var c=new Set([`GET`,`HEAD`,`OPTIONS`]);function l(e,t){return!c.has(t)||!(e instanceof a.TempestApiError)?!1:a.isRetriableStatus(e.status)}function u(e){return e?e===!0?{}:e:null}var d=15e3,f=3e5;function p(e){return typeof FormData<`u`&&e instanceof FormData}function m(e){return Math.round(performance.now()-e)}async function h(e,t){let n;try{n=await e.clone().json()}catch{try{n=await e.text()}catch{n=null}}return new a.TempestApiError(a.buildApiError(e.status,n,e.headers,t))}function g(c){let g=c.fetcher??globalThis.fetch.bind(globalThis);function _(e,t){if(t)return{};let r=c.getToken?.();return r?n.isTrustedCredentialTarget(e,c.baseURL,c.trustedOrigins)?{Authorization:`Bearer ${r}`}:(n.reportSuppressedCredential(e,c.baseURL),{}):{}}async function v(e,n,i,o){let{body:l,params:u,headers:m,signal:h,timeout:v,...y}=n,b=p(l),x=b?c.uploadTimeout:c.timeout,S=v===void 0?x===void 0?b?f:d:x:v,C=t.buildApiUrl(c.baseURL,e,{prefix:c.prefix,params:u}),w=m,T={...b?{}:{"Content-Type":`application/json`},...i?{"X-Request-ID":i}:{},...c.headers,..._(C,o),...r.csrfHeaders({method:y.method??`GET`,url:C,reference:c.baseURL,csrf:c.csrf,trustedOrigins:c.trustedOrigins,headers:{...c.headers,...w}}),...w},E=s.withTimeout(h,S),D={...y,signal:E.signal,headers:T,credentials:c.withCredentials?`include`:y.credentials,body:l==null?void 0:b?l:JSON.stringify(l)};try{return await g(C,D)}catch(e){throw E.timedOut()?new a.TempestApiError({status:0,detail:`A requisição excedeu ${S}ms e foi abandonada.`}):e}finally{E.dispose()}}async function y(e,t,n,r,i){let a=c.logger;if(!a)return v(e,t,n,i);let o=performance.now();try{let s=await v(e,t,n,i),c={requestId:n,status:s.status,ms:m(o)},l=`${r} ${e} → ${s.status}`;return s.status>=400?a.warn(l,c):a.debug(l,c),s}catch(t){throw a.warn(`${r} ${e} → no response`,{requestId:n,ms:m(o),error:t}),t}}async function b(e,t,n){if(c.onUnauthorized){c.logger?.warn(`unauthorized — calling onUnauthorized`,{requestId:t,status:e.status});try{await c.onUnauthorized(e,n)}catch(n){if(n instanceof a.SessionSupersededError)throw n;c.logger?.warn(`onUnauthorized threw — keeping the original response error`,{requestId:t,status:e.status,error:n})}}}function x(e){return{token:e?null:c.getToken?.()??null,epoch:c.getSessionEpoch?.()??null}}async function S(t,n,r){let{skipAuth:i=!1,skipAuthRetry:o=!1,...s}=n,l=c.requestId?c.requestId():e.randomId(),u=(s.method??`GET`).toUpperCase(),d=x(i),f=await y(t,s,l,u,i);if(f.status===401&&!i&&!o){if(c.refresh){let e;try{await c.refresh(d),e=x(i),f=await y(t,s,l,u,i)}catch(e){throw e instanceof a.SessionSupersededError?e:(await b(f,l,d),await h(f,l))}f.status===401&&await b(f,l,e)}else await b(f,l,d)}if(!f.ok)throw await h(f,l);return r(f)}async function C(e,t,n){let{retry:r,...i}=t,a=u(r===void 0?c.retry:r);if(!a)return S(e,i,n);let s=(i.method??`GET`).toUpperCase();return o.retry(()=>S(e,i,n),{...a,shouldRetry:a.shouldRetry??(e=>l(e,s))})}async function w(e,t={}){return C(e,t,i.decodeByContentType)}async function T(e,t,n=`POST`,r){return w(e,{...r,method:n,body:t})}return{request:w,get:(e,t)=>w(e,{...t,method:`GET`}),post:(e,t)=>w(e,{...t,method:`POST`}),put:(e,t)=>w(e,{...t,method:`PUT`}),patch:(e,t)=>w(e,{...t,method:`PATCH`}),delete:(e,t)=>w(e,{...t,method:`DELETE`}),blob:(e,t)=>C(e,{...t},e=>e.blob()),arrayBuffer:(e,t)=>C(e,{...t},e=>e.arrayBuffer()),upload:T}}exports.createApiClient=g;
1
+ const e=require("../utils/ids.cjs"),t=require("./build-url.cjs"),n=require("./credential-scope.cjs"),r=require("./csrf.cjs"),i=require("./decode-response.cjs"),a=require("./errors.cjs"),o=require("./retry.cjs"),s=require("./timeout.cjs");var c=new Set([`GET`,`HEAD`,`OPTIONS`]);function l(e,t){return!c.has(t)||!(e instanceof a.TempestApiError)?!1:a.isRetriableStatus(e.status)}function u(e){return e?e===!0?{}:e:null}var d=15e3,f=3e5;function p(e){return typeof FormData<`u`&&e instanceof FormData}function m(e){return Math.round(performance.now()-e)}async function h(e,t){let n;try{n=await e.clone().json()}catch{try{n=await e.text()}catch{n=null}}return new a.TempestApiError(a.buildApiError(e.status,n,e.headers,t))}function g(c){let g=c.fetcher??globalThis.fetch.bind(globalThis);function _(e,t){if(t)return{};let r=c.getToken?.();return r?n.isTrustedCredentialTarget(e,c.baseURL,c.trustedOrigins)?{Authorization:`Bearer ${r}`}:(n.reportSuppressedCredential(e,c.baseURL),{}):{}}async function v(e,n,i,o){let{body:l,params:u,headers:m,signal:h,timeout:v,...y}=n,b=p(l),x=b?c.uploadTimeout:c.timeout,S=v===void 0?x===void 0?b?f:d:x:v,C=t.buildApiUrl(c.baseURL,e,{prefix:c.prefix,params:u}),w=m,T={...b?{}:{"Content-Type":`application/json`},...i?{"X-Request-ID":i}:{},...c.headers,..._(C,o),...r.csrfHeaders({method:y.method??`GET`,url:C,reference:c.baseURL,csrf:c.csrf,trustedOrigins:c.trustedOrigins,headers:{...c.headers,...w}}),...w},E=s.withTimeout(h,S),D={...y,signal:E.signal,headers:T,credentials:c.withCredentials?`include`:y.credentials,body:l==null?void 0:b?l:JSON.stringify(l)};try{return await g(C,D)}catch(e){throw E.timedOut()?new a.TempestApiError({status:0,detail:`A requisição excedeu ${S}ms e foi abandonada.`}):e}finally{E.dispose()}}async function y(e,t,n,r,i){let a=c.logger;if(!a)return v(e,t,n,i);let o=performance.now();try{let s=await v(e,t,n,i),c={requestId:n,status:s.status,ms:m(o)},l=`${r} ${e} → ${s.status}`;return s.status>=400?a.warn(l,c):a.debug(l,c),s}catch(t){throw a.warn(`${r} ${e} → no response`,{requestId:n,ms:m(o),error:t}),t}}async function b(e,t,n){if(c.onUnauthorized){c.logger?.warn(`unauthorized — calling onUnauthorized`,{requestId:t,status:e.status});try{await c.onUnauthorized(e,n)}catch(n){if(n instanceof a.SessionSupersededError)throw n;c.logger?.warn(`onUnauthorized threw — keeping the original response error`,{requestId:t,status:e.status,error:n})}}}function x(e){return{token:e?null:c.getToken?.()??null,epoch:c.getSessionEpoch?.()??null}}async function S(t,n,r){let{skipAuth:i=!1,skipAuthRetry:o=!1,...s}=n,l=c.requestId?c.requestId():e.randomId(),u=(s.method??`GET`).toUpperCase(),d=x(i),f=await y(t,s,l,u,i);if(f.status===401&&!i&&!o){if(c.refresh){let e;try{await c.refresh(d),e=x(i),f=await y(t,s,l,u,i)}catch(e){throw e instanceof a.SessionSupersededError?e:(await b(f,l,d),await h(f,l))}f.status===401&&await b(f,l,e)}else await b(f,l,d)}if(!f.ok)throw await h(f,l);return r(f)}async function C(e,t,n){let{retry:r,...i}=t,a=u(r===void 0?c.retry:r);if(!a)return S(e,i,n);let s=(i.method??`GET`).toUpperCase();return o.retry(()=>S(e,i,n),{...a,shouldRetry:a.shouldRetry??(e=>l(e,s))})}async function w(e,t={}){return C(e,t,i.decodeByContentType)}async function T(e,t,n=`POST`,r){return w(e,{...r,method:n,body:t})}return{request:w,get:(e,t)=>w(e,{...t,method:`GET`}),post:(e,t)=>w(e,{...t,method:`POST`}),put:(e,t)=>w(e,{...t,method:`PUT`}),patch:(e,t)=>w(e,{...t,method:`PATCH`}),delete:(e,t)=>w(e,{...t,method:`DELETE`}),blob:(e,t)=>C(e,{...t},e=>e.blob()),arrayBuffer:(e,t)=>C(e,{...t},e=>e.arrayBuffer()),upload:T,url:(e,n)=>t.buildApiUrl(c.baseURL,e,{prefix:c.prefix,params:n})}}exports.createApiClient=g;
2
2
  //# sourceMappingURL=api-client.cjs.map