tempest-react-sdk 0.8.0 → 0.9.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 +4 -4
- package/bin/lib/openapi/generate.mjs +259 -0
- package/bin/lib/openapi/generate.test.mjs +129 -0
- package/bin/lib/openapi/load.mjs +24 -0
- package/bin/lib/openapi/schema-to-zod.mjs +123 -0
- package/bin/lib/openapi/schema-to-zod.test.mjs +81 -0
- package/bin/tempest.mjs +84 -2
- package/dist/sw.cjs +1 -1
- package/dist/sw.cjs.map +1 -1
- package/dist/sw.d.ts +130 -0
- package/dist/sw.js +327 -61
- package/dist/sw.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +3 -3
- package/dist/tempest-react-sdk.d.ts +130 -0
- package/dist/tempest-react-sdk.js +36 -32
- package/dist/vite.cjs +3 -1
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.d.ts +136 -0
- package/dist/vite.js +253 -34
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
- package/template-pwa/index.html +1 -1
- package/template-pwa/package.json +3 -0
- package/template-pwa/public/manifest.webmanifest +6 -6
- package/template-pwa/src/main.tsx +17 -24
- package/template-pwa/src/sw.ts +44 -9
- package/template-pwa/vite.config.ts +21 -0
- package/template-pwa/public/icon-maskable.svg +0 -4
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,
|
|
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,259 @@
|
|
|
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) return (schema.anyOf ?? schema.oneOf).map(tsType).join(" | ");
|
|
64
|
+
const t = Array.isArray(schema.type) ? schema.type.find((x) => x !== "null") : schema.type;
|
|
65
|
+
const nul = (Array.isArray(schema.type) && schema.type.includes("null")) || schema.nullable;
|
|
66
|
+
let base;
|
|
67
|
+
switch (t) {
|
|
68
|
+
case "string":
|
|
69
|
+
base = Array.isArray(schema.enum)
|
|
70
|
+
? schema.enum.map((v) => JSON.stringify(v)).join(" | ")
|
|
71
|
+
: "string";
|
|
72
|
+
break;
|
|
73
|
+
case "integer":
|
|
74
|
+
case "number":
|
|
75
|
+
base = "number";
|
|
76
|
+
break;
|
|
77
|
+
case "boolean":
|
|
78
|
+
base = "boolean";
|
|
79
|
+
break;
|
|
80
|
+
case "array":
|
|
81
|
+
base = `${tsType(schema.items)}[]`;
|
|
82
|
+
break;
|
|
83
|
+
case "object":
|
|
84
|
+
default:
|
|
85
|
+
base = "Record<string, unknown>";
|
|
86
|
+
}
|
|
87
|
+
return nul ? `${base} | null` : base;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Topologically sort schema names so dependencies are declared first. */
|
|
91
|
+
function topoSort(names, schemas) {
|
|
92
|
+
const sorted = [];
|
|
93
|
+
const visited = new Set();
|
|
94
|
+
const onStack = new Set();
|
|
95
|
+
const cyclic = new Set();
|
|
96
|
+
function visit(name) {
|
|
97
|
+
if (visited.has(name)) return;
|
|
98
|
+
if (onStack.has(name)) {
|
|
99
|
+
cyclic.add(name);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
onStack.add(name);
|
|
103
|
+
const deps = collectRefs(schemas[name], schemas, new Set(), new Set([name]));
|
|
104
|
+
for (const d of deps) if (names.has(d) && d !== name) visit(d);
|
|
105
|
+
onStack.delete(name);
|
|
106
|
+
visited.add(name);
|
|
107
|
+
sorted.push(name);
|
|
108
|
+
}
|
|
109
|
+
for (const n of [...names].sort()) visit(n);
|
|
110
|
+
return { sorted, cyclic };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Extract the success ($2xx) JSON response schema of an operation. */
|
|
114
|
+
function successSchema(op) {
|
|
115
|
+
const responses = op.responses ?? {};
|
|
116
|
+
const code =
|
|
117
|
+
["200", "201", "202", "2XX"].find((c) => responses[c]) ??
|
|
118
|
+
Object.keys(responses).find((c) => c.startsWith("2"));
|
|
119
|
+
const content = code && responses[code]?.content?.["application/json"];
|
|
120
|
+
return content?.schema ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Extract the JSON request body schema of an operation. */
|
|
124
|
+
function bodySchema(op) {
|
|
125
|
+
return op.requestBody?.content?.["application/json"]?.schema ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Generate the per-tag files from a parsed OpenAPI document.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} doc - The parsed OpenAPI 3.x spec.
|
|
132
|
+
* @returns {{ files: Record<string, string>, tags: string[] }}
|
|
133
|
+
*/
|
|
134
|
+
export function generate(doc) {
|
|
135
|
+
const schemas = doc.components?.schemas ?? {};
|
|
136
|
+
const groups = new Map(); // slug → { tag, ops: [...] }
|
|
137
|
+
|
|
138
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
139
|
+
for (const method of HTTP_METHODS) {
|
|
140
|
+
const op = item[method];
|
|
141
|
+
if (!op) continue;
|
|
142
|
+
const tag = op.tags?.[0] ?? "default";
|
|
143
|
+
const slug = tagSlug(tag);
|
|
144
|
+
if (!groups.has(slug)) groups.set(slug, { tag, slug, ops: [] });
|
|
145
|
+
groups.get(slug).ops.push({ method, path, op });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const files = {};
|
|
150
|
+
const tags = [];
|
|
151
|
+
|
|
152
|
+
for (const { tag, slug, ops } of groups.values()) {
|
|
153
|
+
tags.push(tag);
|
|
154
|
+
const Class = `${pascal(slug)}Service`;
|
|
155
|
+
|
|
156
|
+
// 1. Which component schemas does this group touch (transitively)?
|
|
157
|
+
const used = new Set();
|
|
158
|
+
for (const { op } of ops) {
|
|
159
|
+
const b = bodySchema(op);
|
|
160
|
+
const r = successSchema(op);
|
|
161
|
+
if (b) collectRefs(b, schemas, used);
|
|
162
|
+
if (r) collectRefs(r, schemas, used);
|
|
163
|
+
}
|
|
164
|
+
const { sorted, cyclic } = topoSort(used, schemas);
|
|
165
|
+
|
|
166
|
+
// 2. schemas.ts
|
|
167
|
+
const schemaLines = sorted.map((name) => {
|
|
168
|
+
const expr = schemaToZod(schemas[name], (ref) => {
|
|
169
|
+
const n = refName(ref);
|
|
170
|
+
return cyclic.has(n) ? `z.lazy(() => ${zodName(n)})` : zodName(n);
|
|
171
|
+
});
|
|
172
|
+
return `export const ${zodName(name)} = ${expr};`;
|
|
173
|
+
});
|
|
174
|
+
files[`${slug}/schemas.ts`] = `import { z } from "zod";\n\n${schemaLines.join("\n\n")}\n`;
|
|
175
|
+
|
|
176
|
+
// 3. types.ts
|
|
177
|
+
const typeLines = sorted.map(
|
|
178
|
+
(name) => `export type ${name} = z.infer<typeof S.${zodName(name)}>;`,
|
|
179
|
+
);
|
|
180
|
+
files[`${slug}/types.ts`] =
|
|
181
|
+
`import type { z } from "zod";\n\nimport * as S from "./schemas";\n\n${typeLines.join("\n")}\n`;
|
|
182
|
+
|
|
183
|
+
// 4. service.ts
|
|
184
|
+
const methods = ops.map(({ method, path, op }) => emitMethod(method, path, op, used));
|
|
185
|
+
const usedTypeNames = [...used].sort();
|
|
186
|
+
const typeImport = usedTypeNames.length
|
|
187
|
+
? `import type { ${usedTypeNames.join(", ")} } from "./types";\n`
|
|
188
|
+
: "";
|
|
189
|
+
const schemaImport = usedTypeNames.length ? `import * as S from "./schemas";\n` : "";
|
|
190
|
+
files[`${slug}/service.ts`] =
|
|
191
|
+
`import type { ApiClient } from "tempest-react-sdk";\n\n` +
|
|
192
|
+
schemaImport +
|
|
193
|
+
typeImport +
|
|
194
|
+
`\n/** Generated service for the "${tag}" routes. Inject an ApiClient (createApiClient). */\n` +
|
|
195
|
+
`export class ${Class} {\n` +
|
|
196
|
+
` constructor(private readonly api: ApiClient) {}\n\n` +
|
|
197
|
+
methods.join("\n\n") +
|
|
198
|
+
`\n}\n`;
|
|
199
|
+
|
|
200
|
+
// 5. index.ts (re-export)
|
|
201
|
+
files[`${slug}/index.ts`] =
|
|
202
|
+
`export * from "./schemas";\nexport * from "./types";\nexport { ${Class} } from "./service";\n`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Root barrel.
|
|
206
|
+
files["index.ts"] =
|
|
207
|
+
[...groups.values()].map(({ slug }) => `export * from "./${slug}";`).join("\n") + "\n";
|
|
208
|
+
|
|
209
|
+
return { files, tags };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Emit a single class method for one operation. */
|
|
213
|
+
function emitMethod(method, path, op, used) {
|
|
214
|
+
const name = methodName(op, method, path);
|
|
215
|
+
const pathParams = (op.parameters ?? []).filter((p) => p.in === "path");
|
|
216
|
+
const queryParams = (op.parameters ?? []).filter((p) => p.in === "query");
|
|
217
|
+
const body = bodySchema(op);
|
|
218
|
+
const resp = successSchema(op);
|
|
219
|
+
const retType = resp ? tsType(resp) : "void";
|
|
220
|
+
|
|
221
|
+
const args = [];
|
|
222
|
+
for (const p of pathParams) args.push(`${p.name}: ${tsType(p.schema ?? { type: "string" })}`);
|
|
223
|
+
if (body) args.push(`body: ${tsType(body)}`);
|
|
224
|
+
if (queryParams.length) {
|
|
225
|
+
const q = queryParams
|
|
226
|
+
.map(
|
|
227
|
+
(p) =>
|
|
228
|
+
`${JSON.stringify(p.name)}${p.required ? "" : "?"}: ${tsType(p.schema ?? { type: "string" })}`,
|
|
229
|
+
)
|
|
230
|
+
.join("; ");
|
|
231
|
+
args.push(`params: { ${q} }`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Interpolate path params into a template literal.
|
|
235
|
+
const tpl = path.replace(/{([^}]+)}/g, (_, n) => "${" + n + "}");
|
|
236
|
+
const url = pathParams.length ? `\`${tpl}\`` : JSON.stringify(path);
|
|
237
|
+
|
|
238
|
+
const callOpts = [];
|
|
239
|
+
if (body) callOpts.push("body");
|
|
240
|
+
if (queryParams.length) callOpts.push("params");
|
|
241
|
+
const optsArg = callOpts.length ? `, { ${callOpts.join(", ")} }` : "";
|
|
242
|
+
|
|
243
|
+
// Zod input validation: validate the body when it references a known schema.
|
|
244
|
+
let validation = "";
|
|
245
|
+
if (body && body.$ref) {
|
|
246
|
+
const schemaConst = `S.${zodName(refName(body.$ref))}`;
|
|
247
|
+
validation = ` ${schemaConst}.parse(body);\n`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const ret = retType === "void" ? "Promise<void>" : `Promise<${retType}>`;
|
|
251
|
+
const generic = retType === "void" ? "" : `<${retType}>`;
|
|
252
|
+
return (
|
|
253
|
+
` /** \`${method.toUpperCase()} ${path}\`${op.summary ? ` — ${op.summary}` : ""} */\n` +
|
|
254
|
+
` async ${name}(${args.join(", ")}): ${ret} {\n` +
|
|
255
|
+
validation +
|
|
256
|
+
` return this.api.${method}${generic}(${url}${optsArg});\n` +
|
|
257
|
+
` }`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// JSON Schema (OpenAPI 3.0/3.1 subset) → Zod expression source string.
|
|
2
|
+
// Pure + deterministic so the generated output is stable and testable.
|
|
3
|
+
|
|
4
|
+
/** Convert a $ref like "#/components/schemas/User" to its schema name ("User"). */
|
|
5
|
+
export function refName(ref) {
|
|
6
|
+
return ref.slice(ref.lastIndexOf("/") + 1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The generated Zod const name for a schema name (e.g. "User" → "UserSchema"). */
|
|
10
|
+
export function zodName(name) {
|
|
11
|
+
return `${name}Schema`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isNullable(schema) {
|
|
15
|
+
if (schema.nullable === true) return true; // OpenAPI 3.0
|
|
16
|
+
if (Array.isArray(schema.type)) return schema.type.includes("null"); // 3.1
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function withModifiers(expr, schema) {
|
|
21
|
+
let out = expr;
|
|
22
|
+
if (isNullable(schema)) out += ".nullable()";
|
|
23
|
+
if (schema.default !== undefined) out += `.default(${JSON.stringify(schema.default)})`;
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pick the primary (non-null) type when 3.1 uses an array of types. */
|
|
28
|
+
function primaryType(schema) {
|
|
29
|
+
if (Array.isArray(schema.type)) return schema.type.find((t) => t !== "null");
|
|
30
|
+
return schema.type;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convert one schema node to a Zod expression string.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} schema - The (sub)schema.
|
|
37
|
+
* @param {(ref: string) => string} [resolveRef] - Maps a $ref to its Zod expr
|
|
38
|
+
* (defaults to the bare `${Name}Schema` const, used lazily).
|
|
39
|
+
* @returns {string} A Zod expression, e.g. `z.string().email()`.
|
|
40
|
+
*/
|
|
41
|
+
export function schemaToZod(schema, resolveRef = (ref) => zodName(refName(ref))) {
|
|
42
|
+
if (!schema || typeof schema !== "object") return "z.unknown()";
|
|
43
|
+
|
|
44
|
+
if (schema.$ref) return resolveRef(schema.$ref);
|
|
45
|
+
|
|
46
|
+
// Composition.
|
|
47
|
+
if (Array.isArray(schema.allOf)) {
|
|
48
|
+
const parts = schema.allOf.map((s) => schemaToZod(s, resolveRef));
|
|
49
|
+
if (parts.length === 1) return withModifiers(parts[0], schema);
|
|
50
|
+
return withModifiers(
|
|
51
|
+
parts.reduce((a, b) => `z.intersection(${a}, ${b})`),
|
|
52
|
+
schema,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)) {
|
|
56
|
+
const variants = (schema.anyOf ?? schema.oneOf).map((s) => schemaToZod(s, resolveRef));
|
|
57
|
+
const union = variants.length === 1 ? variants[0] : `z.union([${variants.join(", ")}])`;
|
|
58
|
+
return withModifiers(union, schema);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Standalone enum (no explicit object/array type).
|
|
62
|
+
if (Array.isArray(schema.enum)) {
|
|
63
|
+
const allStrings = schema.enum.every((v) => typeof v === "string");
|
|
64
|
+
const expr = allStrings
|
|
65
|
+
? `z.enum([${schema.enum.map((v) => JSON.stringify(v)).join(", ")}])`
|
|
66
|
+
: `z.union([${schema.enum.map((v) => `z.literal(${JSON.stringify(v)})`).join(", ")}])`;
|
|
67
|
+
return withModifiers(expr, schema);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
switch (primaryType(schema)) {
|
|
71
|
+
case "string": {
|
|
72
|
+
let e = "z.string()";
|
|
73
|
+
if (schema.format === "email") e += ".email()";
|
|
74
|
+
else if (schema.format === "uuid") e += ".uuid()";
|
|
75
|
+
else if (schema.format === "uri" || schema.format === "url") e += ".url()";
|
|
76
|
+
else if (schema.format === "date-time") e = "z.string().datetime({ offset: true })";
|
|
77
|
+
if (typeof schema.minLength === "number") e += `.min(${schema.minLength})`;
|
|
78
|
+
if (typeof schema.maxLength === "number") e += `.max(${schema.maxLength})`;
|
|
79
|
+
if (typeof schema.pattern === "string")
|
|
80
|
+
e += `.regex(/${schema.pattern.replace(/\//g, "\\/")}/)`;
|
|
81
|
+
return withModifiers(e, schema);
|
|
82
|
+
}
|
|
83
|
+
case "integer":
|
|
84
|
+
case "number": {
|
|
85
|
+
let e = "z.number()";
|
|
86
|
+
if (primaryType(schema) === "integer") e += ".int()";
|
|
87
|
+
if (typeof schema.minimum === "number") e += `.min(${schema.minimum})`;
|
|
88
|
+
if (typeof schema.maximum === "number") e += `.max(${schema.maximum})`;
|
|
89
|
+
return withModifiers(e, schema);
|
|
90
|
+
}
|
|
91
|
+
case "boolean":
|
|
92
|
+
return withModifiers("z.boolean()", schema);
|
|
93
|
+
case "null":
|
|
94
|
+
return "z.null()";
|
|
95
|
+
case "array": {
|
|
96
|
+
const items = schema.items ? schemaToZod(schema.items, resolveRef) : "z.unknown()";
|
|
97
|
+
return withModifiers(`z.array(${items})`, schema);
|
|
98
|
+
}
|
|
99
|
+
case "object":
|
|
100
|
+
default: {
|
|
101
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
102
|
+
const required = new Set(schema.required ?? []);
|
|
103
|
+
const entries = Object.entries(schema.properties).map(([key, prop]) => {
|
|
104
|
+
let expr = schemaToZod(prop, resolveRef);
|
|
105
|
+
if (!required.has(key)) expr += ".optional()";
|
|
106
|
+
return ` ${JSON.stringify(key)}: ${expr},`;
|
|
107
|
+
});
|
|
108
|
+
let e = entries.length ? `z.object({\n${entries.join("\n")}\n})` : "z.object({})";
|
|
109
|
+
// additionalProperties → passthrough / record.
|
|
110
|
+
if (schema.additionalProperties === true) e += ".passthrough()";
|
|
111
|
+
return withModifiers(e, schema);
|
|
112
|
+
}
|
|
113
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
114
|
+
return withModifiers(
|
|
115
|
+
`z.record(${schemaToZod(schema.additionalProperties, resolveRef)})`,
|
|
116
|
+
schema,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// No type info → unknown record / passthrough object.
|
|
120
|
+
return withModifiers("z.record(z.unknown())", schema);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { refName, zodName, schemaToZod } from "./schema-to-zod.mjs";
|
|
3
|
+
|
|
4
|
+
describe("refName / zodName", () => {
|
|
5
|
+
it("extracts the schema name from a $ref", () => {
|
|
6
|
+
expect(refName("#/components/schemas/User")).toBe("User");
|
|
7
|
+
});
|
|
8
|
+
it("builds the zod const name", () => {
|
|
9
|
+
expect(zodName("User")).toBe("UserSchema");
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("schemaToZod — primitives", () => {
|
|
14
|
+
it("string with format", () => {
|
|
15
|
+
expect(schemaToZod({ type: "string" })).toBe("z.string()");
|
|
16
|
+
expect(schemaToZod({ type: "string", format: "email" })).toBe("z.string().email()");
|
|
17
|
+
expect(schemaToZod({ type: "string", format: "uuid" })).toBe("z.string().uuid()");
|
|
18
|
+
expect(schemaToZod({ type: "string", format: "date-time" })).toBe(
|
|
19
|
+
"z.string().datetime({ offset: true })",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
it("integer vs number", () => {
|
|
23
|
+
expect(schemaToZod({ type: "integer" })).toBe("z.number().int()");
|
|
24
|
+
expect(schemaToZod({ type: "number", minimum: 0 })).toBe("z.number().min(0)");
|
|
25
|
+
});
|
|
26
|
+
it("boolean", () => {
|
|
27
|
+
expect(schemaToZod({ type: "boolean" })).toBe("z.boolean()");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("schemaToZod — $ref / nullable / enum", () => {
|
|
32
|
+
it("$ref → ZodName", () => {
|
|
33
|
+
expect(schemaToZod({ $ref: "#/components/schemas/User" })).toBe("UserSchema");
|
|
34
|
+
});
|
|
35
|
+
it("nullable (3.0)", () => {
|
|
36
|
+
expect(schemaToZod({ type: "string", nullable: true })).toBe("z.string().nullable()");
|
|
37
|
+
});
|
|
38
|
+
it("nullable (3.1 type array)", () => {
|
|
39
|
+
expect(schemaToZod({ type: ["string", "null"] })).toBe("z.string().nullable()");
|
|
40
|
+
});
|
|
41
|
+
it("string enum", () => {
|
|
42
|
+
expect(schemaToZod({ type: "string", enum: ["a", "b"] })).toBe('z.enum(["a", "b"])');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("schemaToZod — array / object", () => {
|
|
47
|
+
it("array of refs", () => {
|
|
48
|
+
expect(schemaToZod({ type: "array", items: { $ref: "#/components/schemas/User" } })).toBe(
|
|
49
|
+
"z.array(UserSchema)",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
it("object with required + optional", () => {
|
|
53
|
+
const out = schemaToZod({
|
|
54
|
+
type: "object",
|
|
55
|
+
required: ["id"],
|
|
56
|
+
properties: { id: { type: "integer" }, name: { type: "string" } },
|
|
57
|
+
});
|
|
58
|
+
expect(out).toContain('"id": z.number().int(),');
|
|
59
|
+
expect(out).toContain('"name": z.string().optional(),');
|
|
60
|
+
expect(out.startsWith("z.object({")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it("record for additionalProperties schema", () => {
|
|
63
|
+
expect(schemaToZod({ type: "object", additionalProperties: { type: "number" } })).toBe(
|
|
64
|
+
"z.record(z.number())",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("schemaToZod — composition", () => {
|
|
70
|
+
it("anyOf → union", () => {
|
|
71
|
+
expect(schemaToZod({ anyOf: [{ type: "string" }, { type: "number" }] })).toBe(
|
|
72
|
+
"z.union([z.string(), z.number()])",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
it("allOf → intersection", () => {
|
|
76
|
+
const out = schemaToZod({
|
|
77
|
+
allOf: [{ $ref: "#/components/schemas/A" }, { $ref: "#/components/schemas/B" }],
|
|
78
|
+
});
|
|
79
|
+
expect(out).toBe("z.intersection(ASchema, BSchema)");
|
|
80
|
+
});
|
|
81
|
+
});
|