tempest-react-sdk 0.9.0 → 0.10.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.
@@ -60,7 +60,10 @@ function tsType(schema) {
60
60
  if (!schema || typeof schema !== "object") return "unknown";
61
61
  if (schema.$ref) return refName(schema.$ref);
62
62
  if (schema.allOf) return schema.allOf.map(tsType).join(" & ");
63
- if (schema.anyOf || schema.oneOf) return (schema.anyOf ?? schema.oneOf).map(tsType).join(" | ");
63
+ if (schema.anyOf || schema.oneOf) {
64
+ const parts = (schema.anyOf ?? schema.oneOf).map(tsType);
65
+ return [...new Set(parts)].join(" | ");
66
+ }
64
67
  const t = Array.isArray(schema.type) ? schema.type.find((x) => x !== "null") : schema.type;
65
68
  const nul = (Array.isArray(schema.type) && schema.type.includes("null")) || schema.nullable;
66
69
  let base;
@@ -77,6 +80,8 @@ function tsType(schema) {
77
80
  case "boolean":
78
81
  base = "boolean";
79
82
  break;
83
+ case "null":
84
+ return "null";
80
85
  case "array":
81
86
  base = `${tsType(schema.items)}[]`;
82
87
  break;
@@ -87,6 +92,24 @@ function tsType(schema) {
87
92
  return nul ? `${base} | null` : base;
88
93
  }
89
94
 
95
+ /**
96
+ * TS type for a query-param value. Query params serialize to strings, so the
97
+ * client only accepts `string | number | boolean | null`. Anything richer
98
+ * (objects, arrays of objects) is narrowed to `string` to stay assignable.
99
+ */
100
+ function queryParamType(schema) {
101
+ const t = tsType(schema ?? { type: "string" });
102
+ const allowed = new Set(["string", "number", "boolean", "null"]);
103
+ const ok = t.split("|").every((part) => allowed.has(part.trim()));
104
+ return ok ? t : "string";
105
+ }
106
+
107
+ /** Turn a tag slug into a JS-safe identifier ("chat-messages" → "chatMessages"). */
108
+ function slugIdent(slug) {
109
+ const id = slug.replace(/[^a-zA-Z0-9]+(.)?/g, (_, ch) => (ch ? ch.toUpperCase() : ""));
110
+ return /^[a-zA-Z_]/.test(id) ? id : `_${id}`;
111
+ }
112
+
90
113
  /** Topologically sort schema names so dependencies are declared first. */
91
114
  function topoSort(names, schemas) {
92
115
  const sorted = [];
@@ -110,6 +133,39 @@ function topoSort(names, schemas) {
110
133
  return { sorted, cyclic };
111
134
  }
112
135
 
136
+ /** Follow a chain of $refs to the concrete schema node. */
137
+ function resolveSchema(schema, schemas) {
138
+ let node = schema;
139
+ let guard = 0;
140
+ while (node && node.$ref && guard++ < 16) node = schemas[refName(node.$ref)];
141
+ return node;
142
+ }
143
+
144
+ /**
145
+ * Detect a Tempest pagination envelope and return its kind + item type.
146
+ *
147
+ * Offset (fastapi-pagination `Page` / `BasePaginationSchema`):
148
+ * items + total + pages + (size | page_size).
149
+ * Cursor (`CursorPaginationSchema`): items + next_cursor + has_more.
150
+ *
151
+ * @returns {{ kind: "offset"|"cursor", itemType: string }|null}
152
+ */
153
+ function detectPage(schema, schemas) {
154
+ const node = resolveSchema(schema, schemas);
155
+ const props = node?.properties;
156
+ if (!props || !props.items || resolveSchema(props.items, schemas)?.type !== "array") {
157
+ return null;
158
+ }
159
+ const itemType = tsType(resolveSchema(props.items, schemas).items);
160
+ if ("total" in props && "pages" in props && ("size" in props || "page_size" in props)) {
161
+ return { kind: "offset", itemType };
162
+ }
163
+ if ("next_cursor" in props && "has_more" in props) {
164
+ return { kind: "cursor", itemType };
165
+ }
166
+ return null;
167
+ }
168
+
113
169
  /** Extract the success ($2xx) JSON response schema of an operation. */
114
170
  function successSchema(op) {
115
171
  const responses = op.responses ?? {};
@@ -181,20 +237,28 @@ export function generate(doc) {
181
237
  `import type { z } from "zod";\n\nimport * as S from "./schemas";\n\n${typeLines.join("\n")}\n`;
182
238
 
183
239
  // 4. service.ts
184
- const methods = ops.map(({ method, path, op }) => emitMethod(method, path, op, used));
240
+ const usedNames = new Set();
241
+ const methods = ops.map(({ method, path, op }) =>
242
+ emitMethod(method, path, op, schemas, usedNames),
243
+ );
185
244
  const usedTypeNames = [...used].sort();
186
245
  const typeImport = usedTypeNames.length
187
246
  ? `import type { ${usedTypeNames.join(", ")} } from "./types";\n`
188
247
  : "";
189
248
  const schemaImport = usedTypeNames.length ? `import * as S from "./schemas";\n` : "";
249
+ const body = methods.join("\n\n");
250
+ // Pagination envelopes are re-exported from the SDK; import what we used.
251
+ const sdkTypes = ["ApiClient"];
252
+ if (body.includes("OffsetPage<")) sdkTypes.push("OffsetPage");
253
+ if (body.includes("CursorPage<")) sdkTypes.push("CursorPage");
190
254
  files[`${slug}/service.ts`] =
191
- `import type { ApiClient } from "tempest-react-sdk";\n\n` +
255
+ `import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n\n` +
192
256
  schemaImport +
193
257
  typeImport +
194
258
  `\n/** Generated service for the "${tag}" routes. Inject an ApiClient (createApiClient). */\n` +
195
259
  `export class ${Class} {\n` +
196
260
  ` constructor(private readonly api: ApiClient) {}\n\n` +
197
- methods.join("\n\n") +
261
+ body +
198
262
  `\n}\n`;
199
263
 
200
264
  // 5. index.ts (re-export)
@@ -202,21 +266,37 @@ export function generate(doc) {
202
266
  `export * from "./schemas";\nexport * from "./types";\nexport { ${Class} } from "./service";\n`;
203
267
  }
204
268
 
205
- // Root barrel.
269
+ // Root barrel — namespaced per group so schema/type names shared across
270
+ // groups (e.g. UserResponseSchema in both `admin` and `auth`) don't collide.
206
271
  files["index.ts"] =
207
- [...groups.values()].map(({ slug }) => `export * from "./${slug}";`).join("\n") + "\n";
272
+ [...groups.values()]
273
+ .map(({ slug }) => `export * as ${slugIdent(slug)} from "./${slug}";`)
274
+ .join("\n") + "\n";
208
275
 
209
276
  return { files, tags };
210
277
  }
211
278
 
212
279
  /** Emit a single class method for one operation. */
213
- function emitMethod(method, path, op, used) {
214
- const name = methodName(op, method, path);
280
+ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
281
+ let name = methodName(op, method, path);
282
+ // Dedupe collisions within a group (e.g. operationIds "home__get" / "home_get").
283
+ if (usedNames.has(name)) {
284
+ let n = 2;
285
+ while (usedNames.has(`${name}${n}`)) n += 1;
286
+ name = `${name}${n}`;
287
+ }
288
+ usedNames.add(name);
215
289
  const pathParams = (op.parameters ?? []).filter((p) => p.in === "path");
216
290
  const queryParams = (op.parameters ?? []).filter((p) => p.in === "query");
217
291
  const body = bodySchema(op);
218
292
  const resp = successSchema(op);
219
- const retType = resp ? tsType(resp) : "void";
293
+ // Map a Tempest pagination envelope to OffsetPage<T>/CursorPage<T>.
294
+ const page = resp ? detectPage(resp, schemas) : null;
295
+ const retType = page
296
+ ? `${page.kind === "offset" ? "OffsetPage" : "CursorPage"}<${page.itemType}>`
297
+ : resp
298
+ ? tsType(resp)
299
+ : "void";
220
300
 
221
301
  const args = [];
222
302
  for (const p of pathParams) args.push(`${p.name}: ${tsType(p.schema ?? { type: "string" })}`);
@@ -225,7 +305,7 @@ function emitMethod(method, path, op, used) {
225
305
  const q = queryParams
226
306
  .map(
227
307
  (p) =>
228
- `${JSON.stringify(p.name)}${p.required ? "" : "?"}: ${tsType(p.schema ?? { type: "string" })}`,
308
+ `${JSON.stringify(p.name)}${p.required ? "" : "?"}: ${queryParamType(p.schema)}`,
229
309
  )
230
310
  .join("; ");
231
311
  args.push(`params: { ${q} }`);
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import { generate } from "./generate.mjs";
4
+
5
+ const SPEC = {
6
+ openapi: "3.1.0",
7
+ components: {
8
+ schemas: {
9
+ Post: {
10
+ type: "object",
11
+ required: ["id"],
12
+ properties: { id: { type: "integer" }, title: { type: "string" } },
13
+ },
14
+ PostPage: {
15
+ type: "object",
16
+ properties: {
17
+ items: { type: "array", items: { $ref: "#/components/schemas/Post" } },
18
+ total: { type: "integer" },
19
+ page: { type: "integer" },
20
+ page_size: { type: "integer" },
21
+ pages: { type: "integer" },
22
+ },
23
+ },
24
+ PostFeed: {
25
+ type: "object",
26
+ properties: {
27
+ items: { type: "array", items: { $ref: "#/components/schemas/Post" } },
28
+ next_cursor: { type: "string", nullable: true },
29
+ has_more: { type: "boolean" },
30
+ limit: { type: "integer" },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ paths: {
36
+ "/posts": {
37
+ get: {
38
+ tags: ["posts"],
39
+ operationId: "list_posts",
40
+ responses: {
41
+ 200: {
42
+ content: {
43
+ "application/json": {
44
+ schema: { $ref: "#/components/schemas/PostPage" },
45
+ },
46
+ },
47
+ },
48
+ },
49
+ },
50
+ },
51
+ "/posts/feed": {
52
+ get: {
53
+ tags: ["posts"],
54
+ operationId: "feed_posts",
55
+ responses: {
56
+ 200: {
57
+ content: {
58
+ "application/json": {
59
+ schema: { $ref: "#/components/schemas/PostFeed" },
60
+ },
61
+ },
62
+ },
63
+ },
64
+ },
65
+ },
66
+ },
67
+ };
68
+
69
+ describe("generate — pagination envelopes", () => {
70
+ const { files } = generate(SPEC);
71
+ const svc = files["posts/service.ts"];
72
+
73
+ it("maps the offset envelope to OffsetPage<Post>", () => {
74
+ expect(svc).toMatch(/async listPosts\(\): Promise<OffsetPage<Post>>/);
75
+ });
76
+
77
+ it("maps the cursor envelope to CursorPage<Post>", () => {
78
+ expect(svc).toMatch(/async feedPosts\(\): Promise<CursorPage<Post>>/);
79
+ });
80
+
81
+ it("imports the page types from the SDK", () => {
82
+ expect(svc).toContain(
83
+ 'import type { ApiClient, OffsetPage, CursorPage } from "tempest-react-sdk";',
84
+ );
85
+ });
86
+ });
@@ -112,12 +112,12 @@ export function schemaToZod(schema, resolveRef = (ref) => zodName(refName(ref)))
112
112
  }
113
113
  if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
114
114
  return withModifiers(
115
- `z.record(${schemaToZod(schema.additionalProperties, resolveRef)})`,
115
+ `z.record(z.string(), ${schemaToZod(schema.additionalProperties, resolveRef)})`,
116
116
  schema,
117
117
  );
118
118
  }
119
119
  // No type info → unknown record / passthrough object.
120
- return withModifiers("z.record(z.unknown())", schema);
120
+ return withModifiers("z.record(z.string(), z.unknown())", schema);
121
121
  }
122
122
  }
123
123
  }
@@ -61,7 +61,7 @@ describe("schemaToZod — array / object", () => {
61
61
  });
62
62
  it("record for additionalProperties schema", () => {
63
63
  expect(schemaToZod({ type: "object", additionalProperties: { type: "number" } })).toBe(
64
- "z.record(z.number())",
64
+ "z.record(z.string(), z.number())",
65
65
  );
66
66
  });
67
67
  });