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.
- package/bin/lib/openapi/generate.mjs +90 -10
- package/bin/lib/openapi/generate.pagination.test.mjs +86 -0
- package/bin/lib/openapi/schema-to-zod.mjs +2 -2
- package/bin/lib/openapi/schema-to-zod.test.mjs +1 -1
- package/dist/tempest-react-sdk.cjs +3 -3
- package/dist/tempest-react-sdk.cjs.map +1 -1
- package/dist/tempest-react-sdk.d.ts +358 -1
- package/dist/tempest-react-sdk.js +2752 -2562
- package/dist/tempest-react-sdk.js.map +1 -1
- package/package.json +1 -1
|
@@ -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)
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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()]
|
|
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,
|
|
214
|
-
|
|
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
|
-
|
|
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 ? "" : "?"}: ${
|
|
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
|
});
|