tempest-react-sdk 0.8.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/README.md CHANGED
@@ -96,11 +96,11 @@ cd my-app
96
96
  npm install
97
97
  npm run dev
98
98
 
99
- # want it installable + web-push ready? add --pwa
99
+ # want it installable + web-push + offline ready? add --pwa
100
100
  npx -p tempest-react-sdk create-tempest-app my-app --pwa
101
101
  ```
102
102
 
103
- The `--pwa` flag overlays a manifest, a service worker built from `tempest-react-sdk/sw`, and push wiring (`usePushSubscription` + `useBeforeInstallPrompt`) on top of the base app — no `vite-plugin-pwa`. See [Scaffold › PWA mode](https://mauriciobenjamin700.github.io/tempest-react-sdk/scaffold/#modo-pwa-pwa).
103
+ The `--pwa` flag overlays a manifest, install prompt (`useBeforeInstallPrompt`), push wiring (`usePushSubscription`), **offline caching** (app-shell precache + runtime caching), **generated icons** (`tempestPwaIcons`, via `sharp`) and a **dev-mode service worker** (`tempestPwaDevSw`) on top of the base app — full `vite-plugin-pwa` parity for the common case, built from `tempest-react-sdk/sw` + `tempest-react-sdk/vite`, with no `vite-plugin-pwa`. See [Scaffold › PWA mode](https://mauriciobenjamin700.github.io/tempest-react-sdk/scaffold/#modo-pwa-pwa).
104
104
 
105
105
  Already have a project? Install the SDK, then scaffold `src/` + configs into it:
106
106
 
@@ -203,12 +203,12 @@ Every module is re-exported from the package root — `import { Button, useDebou
203
203
  | `router` _(dep: `react-router-dom`)_ | `defineRoutes`, `AppRouter`, `RouteGuard`, + re-exports (`Link`, `NavLink`, `Outlet`, `Navigate`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`, `useMatch`, `useRouteError`, `redirect`, `BrowserRouter`/`HashRouter`/`MemoryRouter`/`Routes`/`Route`), types: `TempestRouteObject`, `RouterKind`, `AppRouterProps`, `RouteGuardProps` |
204
204
  | `store` _(dep: `zustand`)_ | `createStore`, `createSelectors`, types: `CreateStoreOptions`, `CreateStorePersistOptions`, `WithSelectors` |
205
205
  | `app` | `AppProviders` (composes `ErrorBoundary` → `QueryProvider` → `ThemeProvider` → `I18nProvider`), type: `AppProvidersProps` |
206
- | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig` |
206
+ | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev`), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `AppleSplashSpec`, `TempestVitePlugin` |
207
207
  | `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
208
208
  | `sse` | `createEventStream`, `useEventStream` |
209
209
  | `ws` | `createWebSocket`, `useWebSocket` |
210
210
  | `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
211
- | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker`, `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener` — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
211
+ | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker`, `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
212
212
  | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio` |
213
213
  | `offline` _(peer: `dexie`)_ | `createOfflineStore`, types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions` |
214
214
  | `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
@@ -0,0 +1,339 @@
1
+ // OpenAPI 3.x → per-tag { schemas.ts (Zod), types.ts, service.ts (class) }.
2
+ // Pure: takes the parsed spec object, returns a { path: contents } map. No I/O.
3
+ import { refName, zodName, schemaToZod } from "./schema-to-zod.mjs";
4
+
5
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
6
+
7
+ /** Slug a tag into a folder/identifier-safe base ("User Profiles" → "user-profiles"). */
8
+ function tagSlug(tag) {
9
+ return (
10
+ tag
11
+ .trim()
12
+ .replace(/[^a-zA-Z0-9]+/g, "-")
13
+ .replace(/^-+|-+$/g, "")
14
+ .toLowerCase() || "default"
15
+ );
16
+ }
17
+
18
+ /** PascalCase for class names ("user-profiles" → "UserProfiles"). */
19
+ function pascal(s) {
20
+ return s.replace(/(^|[^a-zA-Z0-9])([a-zA-Z0-9])/g, (_, _b, ch) => ch.toUpperCase());
21
+ }
22
+
23
+ /** camelCase method name from operationId or method+path. */
24
+ function methodName(op, method, path) {
25
+ if (op.operationId) {
26
+ const id = op.operationId.replace(/[^a-zA-Z0-9]+(.)?/g, (_, ch) =>
27
+ ch ? ch.toUpperCase() : "",
28
+ );
29
+ return id.charAt(0).toLowerCase() + id.slice(1);
30
+ }
31
+ const parts = path
32
+ .split("/")
33
+ .filter(Boolean)
34
+ .map((p) => p.replace(/[{}]/g, ""));
35
+ return method + parts.map((p) => pascal(p)).join("");
36
+ }
37
+
38
+ /** Collect component-schema names referenced (transitively) by a schema node. */
39
+ function collectRefs(node, schemas, acc = new Set(), seen = new Set()) {
40
+ if (!node || typeof node !== "object") return acc;
41
+ if (node.$ref) {
42
+ const name = refName(node.$ref);
43
+ if (!acc.has(name)) {
44
+ acc.add(name);
45
+ if (!seen.has(name)) {
46
+ seen.add(name);
47
+ collectRefs(schemas[name], schemas, acc, seen);
48
+ }
49
+ }
50
+ return acc;
51
+ }
52
+ for (const v of Object.values(node)) {
53
+ if (v && typeof v === "object") collectRefs(v, schemas, acc, seen);
54
+ }
55
+ return acc;
56
+ }
57
+
58
+ /** TS type expression for a schema node (uses generated type names for $refs). */
59
+ function tsType(schema) {
60
+ if (!schema || typeof schema !== "object") return "unknown";
61
+ if (schema.$ref) return refName(schema.$ref);
62
+ if (schema.allOf) return schema.allOf.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
+ }
67
+ const t = Array.isArray(schema.type) ? schema.type.find((x) => x !== "null") : schema.type;
68
+ const nul = (Array.isArray(schema.type) && schema.type.includes("null")) || schema.nullable;
69
+ let base;
70
+ switch (t) {
71
+ case "string":
72
+ base = Array.isArray(schema.enum)
73
+ ? schema.enum.map((v) => JSON.stringify(v)).join(" | ")
74
+ : "string";
75
+ break;
76
+ case "integer":
77
+ case "number":
78
+ base = "number";
79
+ break;
80
+ case "boolean":
81
+ base = "boolean";
82
+ break;
83
+ case "null":
84
+ return "null";
85
+ case "array":
86
+ base = `${tsType(schema.items)}[]`;
87
+ break;
88
+ case "object":
89
+ default:
90
+ base = "Record<string, unknown>";
91
+ }
92
+ return nul ? `${base} | null` : base;
93
+ }
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
+
113
+ /** Topologically sort schema names so dependencies are declared first. */
114
+ function topoSort(names, schemas) {
115
+ const sorted = [];
116
+ const visited = new Set();
117
+ const onStack = new Set();
118
+ const cyclic = new Set();
119
+ function visit(name) {
120
+ if (visited.has(name)) return;
121
+ if (onStack.has(name)) {
122
+ cyclic.add(name);
123
+ return;
124
+ }
125
+ onStack.add(name);
126
+ const deps = collectRefs(schemas[name], schemas, new Set(), new Set([name]));
127
+ for (const d of deps) if (names.has(d) && d !== name) visit(d);
128
+ onStack.delete(name);
129
+ visited.add(name);
130
+ sorted.push(name);
131
+ }
132
+ for (const n of [...names].sort()) visit(n);
133
+ return { sorted, cyclic };
134
+ }
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
+
169
+ /** Extract the success ($2xx) JSON response schema of an operation. */
170
+ function successSchema(op) {
171
+ const responses = op.responses ?? {};
172
+ const code =
173
+ ["200", "201", "202", "2XX"].find((c) => responses[c]) ??
174
+ Object.keys(responses).find((c) => c.startsWith("2"));
175
+ const content = code && responses[code]?.content?.["application/json"];
176
+ return content?.schema ?? null;
177
+ }
178
+
179
+ /** Extract the JSON request body schema of an operation. */
180
+ function bodySchema(op) {
181
+ return op.requestBody?.content?.["application/json"]?.schema ?? null;
182
+ }
183
+
184
+ /**
185
+ * Generate the per-tag files from a parsed OpenAPI document.
186
+ *
187
+ * @param {object} doc - The parsed OpenAPI 3.x spec.
188
+ * @returns {{ files: Record<string, string>, tags: string[] }}
189
+ */
190
+ export function generate(doc) {
191
+ const schemas = doc.components?.schemas ?? {};
192
+ const groups = new Map(); // slug → { tag, ops: [...] }
193
+
194
+ for (const [path, item] of Object.entries(doc.paths ?? {})) {
195
+ for (const method of HTTP_METHODS) {
196
+ const op = item[method];
197
+ if (!op) continue;
198
+ const tag = op.tags?.[0] ?? "default";
199
+ const slug = tagSlug(tag);
200
+ if (!groups.has(slug)) groups.set(slug, { tag, slug, ops: [] });
201
+ groups.get(slug).ops.push({ method, path, op });
202
+ }
203
+ }
204
+
205
+ const files = {};
206
+ const tags = [];
207
+
208
+ for (const { tag, slug, ops } of groups.values()) {
209
+ tags.push(tag);
210
+ const Class = `${pascal(slug)}Service`;
211
+
212
+ // 1. Which component schemas does this group touch (transitively)?
213
+ const used = new Set();
214
+ for (const { op } of ops) {
215
+ const b = bodySchema(op);
216
+ const r = successSchema(op);
217
+ if (b) collectRefs(b, schemas, used);
218
+ if (r) collectRefs(r, schemas, used);
219
+ }
220
+ const { sorted, cyclic } = topoSort(used, schemas);
221
+
222
+ // 2. schemas.ts
223
+ const schemaLines = sorted.map((name) => {
224
+ const expr = schemaToZod(schemas[name], (ref) => {
225
+ const n = refName(ref);
226
+ return cyclic.has(n) ? `z.lazy(() => ${zodName(n)})` : zodName(n);
227
+ });
228
+ return `export const ${zodName(name)} = ${expr};`;
229
+ });
230
+ files[`${slug}/schemas.ts`] = `import { z } from "zod";\n\n${schemaLines.join("\n\n")}\n`;
231
+
232
+ // 3. types.ts
233
+ const typeLines = sorted.map(
234
+ (name) => `export type ${name} = z.infer<typeof S.${zodName(name)}>;`,
235
+ );
236
+ files[`${slug}/types.ts`] =
237
+ `import type { z } from "zod";\n\nimport * as S from "./schemas";\n\n${typeLines.join("\n")}\n`;
238
+
239
+ // 4. service.ts
240
+ const usedNames = new Set();
241
+ const methods = ops.map(({ method, path, op }) =>
242
+ emitMethod(method, path, op, schemas, usedNames),
243
+ );
244
+ const usedTypeNames = [...used].sort();
245
+ const typeImport = usedTypeNames.length
246
+ ? `import type { ${usedTypeNames.join(", ")} } from "./types";\n`
247
+ : "";
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");
254
+ files[`${slug}/service.ts`] =
255
+ `import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n\n` +
256
+ schemaImport +
257
+ typeImport +
258
+ `\n/** Generated service for the "${tag}" routes. Inject an ApiClient (createApiClient). */\n` +
259
+ `export class ${Class} {\n` +
260
+ ` constructor(private readonly api: ApiClient) {}\n\n` +
261
+ body +
262
+ `\n}\n`;
263
+
264
+ // 5. index.ts (re-export)
265
+ files[`${slug}/index.ts`] =
266
+ `export * from "./schemas";\nexport * from "./types";\nexport { ${Class} } from "./service";\n`;
267
+ }
268
+
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.
271
+ files["index.ts"] =
272
+ [...groups.values()]
273
+ .map(({ slug }) => `export * as ${slugIdent(slug)} from "./${slug}";`)
274
+ .join("\n") + "\n";
275
+
276
+ return { files, tags };
277
+ }
278
+
279
+ /** Emit a single class method for one operation. */
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);
289
+ const pathParams = (op.parameters ?? []).filter((p) => p.in === "path");
290
+ const queryParams = (op.parameters ?? []).filter((p) => p.in === "query");
291
+ const body = bodySchema(op);
292
+ const resp = successSchema(op);
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";
300
+
301
+ const args = [];
302
+ for (const p of pathParams) args.push(`${p.name}: ${tsType(p.schema ?? { type: "string" })}`);
303
+ if (body) args.push(`body: ${tsType(body)}`);
304
+ if (queryParams.length) {
305
+ const q = queryParams
306
+ .map(
307
+ (p) =>
308
+ `${JSON.stringify(p.name)}${p.required ? "" : "?"}: ${queryParamType(p.schema)}`,
309
+ )
310
+ .join("; ");
311
+ args.push(`params: { ${q} }`);
312
+ }
313
+
314
+ // Interpolate path params into a template literal.
315
+ const tpl = path.replace(/{([^}]+)}/g, (_, n) => "${" + n + "}");
316
+ const url = pathParams.length ? `\`${tpl}\`` : JSON.stringify(path);
317
+
318
+ const callOpts = [];
319
+ if (body) callOpts.push("body");
320
+ if (queryParams.length) callOpts.push("params");
321
+ const optsArg = callOpts.length ? `, { ${callOpts.join(", ")} }` : "";
322
+
323
+ // Zod input validation: validate the body when it references a known schema.
324
+ let validation = "";
325
+ if (body && body.$ref) {
326
+ const schemaConst = `S.${zodName(refName(body.$ref))}`;
327
+ validation = ` ${schemaConst}.parse(body);\n`;
328
+ }
329
+
330
+ const ret = retType === "void" ? "Promise<void>" : `Promise<${retType}>`;
331
+ const generic = retType === "void" ? "" : `<${retType}>`;
332
+ return (
333
+ ` /** \`${method.toUpperCase()} ${path}\`${op.summary ? ` — ${op.summary}` : ""} */\n` +
334
+ ` async ${name}(${args.join(", ")}): ${ret} {\n` +
335
+ validation +
336
+ ` return this.api.${method}${generic}(${url}${optsArg});\n` +
337
+ ` }`
338
+ );
339
+ }
@@ -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
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generate } from "./generate.mjs";
3
+
4
+ const SPEC = {
5
+ openapi: "3.1.0",
6
+ components: {
7
+ schemas: {
8
+ User: {
9
+ type: "object",
10
+ required: ["id", "email"],
11
+ properties: {
12
+ id: { type: "integer" },
13
+ email: { type: "string", format: "email" },
14
+ name: { type: "string" },
15
+ },
16
+ },
17
+ UserCreate: {
18
+ type: "object",
19
+ required: ["email"],
20
+ properties: {
21
+ email: { type: "string", format: "email" },
22
+ name: { type: "string" },
23
+ },
24
+ },
25
+ },
26
+ },
27
+ paths: {
28
+ "/users": {
29
+ get: {
30
+ tags: ["users"],
31
+ operationId: "list_users",
32
+ parameters: [
33
+ { name: "limit", in: "query", required: false, schema: { type: "integer" } },
34
+ ],
35
+ responses: {
36
+ 200: {
37
+ content: {
38
+ "application/json": {
39
+ schema: {
40
+ type: "array",
41
+ items: { $ref: "#/components/schemas/User" },
42
+ },
43
+ },
44
+ },
45
+ },
46
+ },
47
+ },
48
+ post: {
49
+ tags: ["users"],
50
+ operationId: "create_user",
51
+ requestBody: {
52
+ content: {
53
+ "application/json": { schema: { $ref: "#/components/schemas/UserCreate" } },
54
+ },
55
+ },
56
+ responses: {
57
+ 201: {
58
+ content: {
59
+ "application/json": { schema: { $ref: "#/components/schemas/User" } },
60
+ },
61
+ },
62
+ },
63
+ },
64
+ },
65
+ "/users/{id}": {
66
+ get: {
67
+ tags: ["users"],
68
+ operationId: "get_user",
69
+ parameters: [
70
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
71
+ ],
72
+ responses: {
73
+ 200: {
74
+ content: {
75
+ "application/json": { schema: { $ref: "#/components/schemas/User" } },
76
+ },
77
+ },
78
+ },
79
+ },
80
+ },
81
+ },
82
+ };
83
+
84
+ describe("generate — FastAPI-style spec", () => {
85
+ const { files, tags } = generate(SPEC);
86
+
87
+ it("groups by tag", () => {
88
+ expect(tags).toContain("users");
89
+ expect(Object.keys(files)).toEqual(
90
+ expect.arrayContaining([
91
+ "users/schemas.ts",
92
+ "users/types.ts",
93
+ "users/service.ts",
94
+ "users/index.ts",
95
+ "index.ts",
96
+ ]),
97
+ );
98
+ });
99
+
100
+ it("emits Zod schemas for referenced models", () => {
101
+ const s = files["users/schemas.ts"];
102
+ expect(s).toContain('import { z } from "zod";');
103
+ expect(s).toContain("export const UserSchema = z.object({");
104
+ expect(s).toContain("export const UserCreateSchema = z.object({");
105
+ expect(s).toContain('"email": z.string().email()');
106
+ });
107
+
108
+ it("emits inferred types", () => {
109
+ const t = files["users/types.ts"];
110
+ expect(t).toContain("export type User = z.infer<typeof S.UserSchema>;");
111
+ expect(t).toContain("export type UserCreate = z.infer<typeof S.UserCreateSchema>;");
112
+ });
113
+
114
+ it("emits a service class with one method per route", () => {
115
+ const svc = files["users/service.ts"];
116
+ expect(svc).toContain("export class UsersService {");
117
+ expect(svc).toContain("constructor(private readonly api: ApiClient) {}");
118
+ // list with query params
119
+ expect(svc).toMatch(
120
+ /async listUsers\(params: \{ "limit"\?: number \}\): Promise<User\[\]>/,
121
+ );
122
+ // create with Zod input validation
123
+ expect(svc).toContain("S.UserCreateSchema.parse(body);");
124
+ expect(svc).toMatch(/async createUser\(body: UserCreate\): Promise<User>/);
125
+ // path param interpolation
126
+ expect(svc).toMatch(/async getUser\(id: number\): Promise<User>/);
127
+ expect(svc).toContain("return this.api.get<User>(`/users/${id}`);");
128
+ });
129
+ });
@@ -0,0 +1,24 @@
1
+ // Load an OpenAPI spec from a local file path or an http(s) URL → parsed object.
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ /**
5
+ * @param {string} source - File path or http(s) URL to an openapi.json.
6
+ * @returns {Promise<object>} The parsed OpenAPI document.
7
+ */
8
+ export async function loadSpec(source) {
9
+ let raw;
10
+ if (/^https?:\/\//.test(source)) {
11
+ const res = await fetch(source);
12
+ if (!res.ok) throw new Error(`Failed to fetch ${source} — HTTP ${res.status}`);
13
+ raw = await res.text();
14
+ } else {
15
+ raw = await readFile(source, "utf8");
16
+ }
17
+ try {
18
+ return JSON.parse(raw);
19
+ } catch {
20
+ throw new Error(
21
+ `Could not parse ${source} as JSON. Only openapi.json (JSON) is supported for now — point at the FastAPI /openapi.json endpoint.`,
22
+ );
23
+ }
24
+ }