@suluk/sdk 0.1.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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@suluk/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Generate a COMPLETE, intuitive TypeScript SDK from a v4 'Suluk' contract — built on ofetch, entity-grouped, fully typed from the schemas, auth wired (bearer/session) via interceptors, and the v4 superpowers (declared cost + access) surfaced as typed metadata. Not a bag of functions: a library a developer downloads and uses straight away. CANDIDATE tooling.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/sdk"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "dependencies": {
22
+ "@suluk/core": "^0.1.7"
23
+ },
24
+ "peerDependencies": {
25
+ "ofetch": "^1.5.1",
26
+ "@cfworker/json-schema": "^4.1.1"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@cfworker/json-schema": {
30
+ "optional": false
31
+ }
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest"
35
+ },
36
+ "scripts": {
37
+ "test": "bun test",
38
+ "typecheck": "tsc --noEmit -p ."
39
+ }
40
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Generate a complete TypeScript SDK from a v4 "Suluk" document. The output is one self-contained .ts file:
3
+ * • the contract's input JSON Schemas shipped AS DATA — NOT transpiled into any one validator's source.
4
+ * v4 stores JSON Schema 2020-12 (the lossless, portable interchange); the SDK validates THAT, directly.
5
+ * • each input is exposed as a STANDARD SCHEMA (`~standard`, the validator-agnostic interface from the
6
+ * Zod/Valibot/ArkType authors) backed by a generic, eval-free engine (@cfworker/json-schema, Workers-native).
7
+ * So `.input` plugs straight into react-hook-form / TanStack Form / tRPC — not locked to one library.
8
+ * • an ofetch-based createClient(config) factory — auth wired via an onRequest interceptor (bearer / cookie)
9
+ * • methods grouped intuitively: CRUD by entity (client.product.create), custom ops by path (client.checkout.order)
10
+ * • the v4 SUPERPOWERS surfaced as TYPED METADATA on each method: `.cost` (µ$), `.requires` (access), `.input`
11
+ * (the Standard Schema). Metadata + a client-side guard, not enforcement — the server is the boundary (C022).
12
+ * Static TS types come from the SAME JSON Schema (tsType), so the body is typed AND validated from one source.
13
+ */
14
+ import type { OpenAPIv4Document } from "@suluk/core";
15
+ import { isReference } from "@suluk/core";
16
+
17
+ const reserved = new Set(["delete", "new", "function", "default", "return", "class", "in", "for"]);
18
+ const ident = (s: string) => { const c = s.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^[0-9]/, "_$&"); return reserved.has(c) ? `${c}_` : c; };
19
+ const camel = (s: string) => s.replace(/[-_/]+(.)/g, (_, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9_$]/g, "");
20
+ const jsKey = (k: string) => (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : JSON.stringify(k));
21
+ const refName = (r: unknown) => (isReference(r) ? String((r as { $ref: string }).$ref).split("/").pop()! : null);
22
+
23
+ /** A JSON schema → a TS type string (used for typed method inputs + response types). */
24
+ export function tsType(doc: OpenAPIv4Document, schema: unknown, depth = 0): string {
25
+ if (schema == null || schema === true) return "unknown";
26
+ if (schema === false) return "never";
27
+ const rn = refName(schema);
28
+ if (rn) return ident(rn);
29
+ const s = schema as Record<string, unknown>;
30
+ if (Array.isArray(s.enum)) return (s.enum as unknown[]).map((e) => JSON.stringify(e)).join(" | ") || "never";
31
+ if (s.const !== undefined) return JSON.stringify(s.const);
32
+ if (Array.isArray(s.oneOf) || Array.isArray(s.anyOf)) return ((s.oneOf ?? s.anyOf) as unknown[]).map((x) => tsType(doc, x, depth + 1)).join(" | ");
33
+ if (Array.isArray(s.allOf)) return (s.allOf as unknown[]).map((x) => tsType(doc, x, depth + 1)).join(" & ");
34
+ const t = Array.isArray(s.type) ? (s.type as string[])[0] : s.type;
35
+ if (t === "array" || s.items) return `${tsType(doc, s.items, depth + 1)}[]`;
36
+ if (t === "object" || s.properties) {
37
+ const props = (s.properties ?? {}) as Record<string, unknown>;
38
+ const required = new Set((s.required as string[] | undefined) ?? []);
39
+ const keys = Object.keys(props);
40
+ if (!keys.length || depth > 8) return "Record<string, unknown>";
41
+ return `{ ${keys.map((k) => `${jsKey(k)}${required.has(k) ? "" : "?"}: ${tsType(doc, props[k], depth + 1)}`).join("; ")} }`;
42
+ }
43
+ return t === "string" ? "string" : t === "integer" || t === "number" ? "number" : t === "boolean" ? "boolean" : t === "null" ? "null" : "unknown";
44
+ }
45
+
46
+ interface RawReq {
47
+ method: string; summary?: string; contentSchema?: unknown;
48
+ parameterSchema?: Record<string, unknown>;
49
+ responses?: Record<string, { status: string | number; contentSchema?: unknown }>;
50
+ ["x-suluk-cost"]?: { estimateMicroUsd?: number; components?: { microUsd?: number }[] };
51
+ ["x-suluk-access"]?: { requires?: string; scope?: string };
52
+ }
53
+ interface OpInfo {
54
+ name: string; ns: string[]; member: string; method: string; uri: string;
55
+ pathParams: string[]; queryRaw?: unknown; bodyRaw?: unknown; respType: string;
56
+ cost: number | null; requires: string; scope?: string; summary?: string;
57
+ bid?: string; qid?: string; bodyTs?: string; queryTs?: string; // assigned after collision resolution
58
+ }
59
+
60
+ const pathVars = (uri: string) => [...uri.matchAll(/\{\+?([^}?&]+)\}/g)].map((m) => m[1]);
61
+ function costOf(req: RawReq): number | null {
62
+ const c = req["x-suluk-cost"]; if (!c) return null;
63
+ return c.estimateMicroUsd ?? (c.components ?? []).reduce((s, x) => s + Number(x.microUsd ?? 0), 0);
64
+ }
65
+ function respType(doc: OpenAPIv4Document, req: RawReq): string {
66
+ const r = Object.values(req.responses ?? {}).find((x) => String(x.status).startsWith("2") && x.contentSchema != null);
67
+ return r ? tsType(doc, r.contentSchema) : "unknown";
68
+ }
69
+
70
+ function walkOps(doc: OpenAPIv4Document): OpInfo[] {
71
+ const ops: OpInfo[] = [];
72
+ for (const [uri, piRaw] of Object.entries(doc.paths ?? {})) {
73
+ const pi = piRaw as { requests?: Record<string, RawReq> };
74
+ for (const [name, req] of Object.entries(pi.requests ?? {})) {
75
+ const crud = /^(list|get|create|update|delete)([A-Z]\w*)$/.exec(name);
76
+ let ns: string[], member: string;
77
+ if (crud) { ns = [camel(crud[2][0].toLowerCase() + crud[2].slice(1))]; member = crud[1]; }
78
+ else { const segs = uri.split("/").filter((x) => x && !x.startsWith("{")); ns = segs.slice(0, -1).map(camel); member = camel(segs[segs.length - 1] ?? name); }
79
+ const ps = req.parameterSchema ?? {};
80
+ const acc = req["x-suluk-access"];
81
+ ops.push({
82
+ name, ns, member, method: req.method.toLowerCase(), uri, pathParams: pathVars(uri),
83
+ queryRaw: ps.query, bodyRaw: req.contentSchema ?? ps.body, respType: respType(doc, req),
84
+ cost: costOf(req), requires: acc?.requires ?? "anyone", scope: acc?.scope, summary: req.summary,
85
+ });
86
+ }
87
+ }
88
+ return ops;
89
+ }
90
+
91
+ function emitMethod(op: OpInfo): string {
92
+ const args: string[] = op.pathParams.map((p) => `${ident(p)}: string | number`);
93
+ if (op.qid) args.push(`query?: ${op.queryTs}`);
94
+ if (op.bid) args.push(`body: ${op.bodyTs}`);
95
+ const url = "`" + op.uri.replace(/^\/?/, "/").replace(/\{\+?([^}?&]+)\}/g, (_, p) => "${" + ident(p) + "}") + "`";
96
+ const opts = [`method: "${op.method.toUpperCase()}"`];
97
+ if (op.bid) opts.push(`body: _v ? parse(${op.bid}, body) : body`);
98
+ if (op.qid) opts.push(`query: _v && query ? parse(${op.qid}, query) : query`);
99
+ const meta = `{ cost: ${op.cost ?? "null"}, requires: ${JSON.stringify(op.requires)}${op.scope ? `, scope: ${JSON.stringify(op.scope)}` : ""}${op.bid ? `, input: ${op.bid}` : ""} }`;
100
+ const doc = op.summary ? ` /** ${op.summary.replace(/\*\//g, "*\\/")} — ${op.requires}${op.cost != null ? ` · ⛁ ${op.cost}µ$` : ""} */\n` : "";
101
+ return `${doc} ${ident(op.member)}: Object.assign(\n (${args.join(", ")}) => api<${op.respType}>(${url}, { ${opts.join(", ")} }),\n ${meta},\n )`;
102
+ }
103
+
104
+ function emitTree(ops: OpInfo[]): string {
105
+ const tree = new Map<string, OpInfo[]>();
106
+ for (const op of ops) tree.set(op.ns.join("."), [...(tree.get(op.ns.join(".")) ?? []), op]);
107
+ return [...tree.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ns, list]) => {
108
+ const methods = list.map(emitMethod).join(",\n");
109
+ return ns === "" ? methods : ` ${ident(ns.split(".").pop()!)}: {\n${methods.split("\n").map((l) => " " + l).join("\n")}\n }`;
110
+ }).join(",\n");
111
+ }
112
+
113
+ export interface SdkOptions { baseURL?: string }
114
+
115
+ export function generateSdk(doc: OpenAPIv4Document, opts: SdkOptions = {}): string {
116
+ const ops = walkOps(doc);
117
+ // resolve method-name collisions DETERMINISTICALLY (council wf4pmh1ie: never a runtime guess) — append the HTTP
118
+ // method, then a stable index; surface them in the header.
119
+ const collisions: string[] = [];
120
+ const byKey = new Map<string, OpInfo[]>();
121
+ for (const op of ops) { const k = [...op.ns, op.member].join("."); (byKey.get(k) ?? byKey.set(k, []).get(k)!).push(op); }
122
+ for (const [key, list] of byKey) {
123
+ if (list.length < 2) continue;
124
+ collisions.push(`client.${key} ← ${list.map((o) => o.name).join(", ")}`);
125
+ const used = new Map<string, number>();
126
+ for (const op of [...list].sort((a, b) => a.name.localeCompare(b.name) || a.method.localeCompare(b.method))) {
127
+ const cand = op.member + op.method.charAt(0).toUpperCase() + op.method.slice(1);
128
+ const n = used.get(cand) ?? 0; used.set(cand, n + 1);
129
+ op.member = n === 0 ? cand : `${cand}${n + 1}`;
130
+ }
131
+ }
132
+
133
+ // Emit a JSON Schema AS A LITERAL. When it $refs components, splice them in as $defs and rewrite the pointers,
134
+ // so each validator is self-contained (the generic engine resolves refs without the whole document).
135
+ const defs = (doc.components?.schemas ?? {}) as Record<string, unknown>;
136
+ const hasDefs = Object.keys(defs).length > 0;
137
+ const schemaLiteral = (s: unknown): string => {
138
+ const str = JSON.stringify(s ?? {});
139
+ if (!hasDefs || !str.includes("#/components/schemas/")) return str;
140
+ const merged = { $defs: JSON.parse(JSON.stringify(defs)), ...JSON.parse(str) };
141
+ return JSON.stringify(merged).replace(/#\/components\/schemas\//g, "#/$defs/");
142
+ };
143
+
144
+ // per-op input schemas (body + query), stored AS DATA in `schemas`, then wrapped as Standard Schemas. Keyed off
145
+ // the final (post-collision) method path so the id is stable + unique.
146
+ const schemaEntries: string[] = [];
147
+ const inputDecls: string[] = [];
148
+ for (const op of ops) {
149
+ const base = [...op.ns, op.member].join("_").replace(/[^a-zA-Z0-9_$]/g, "_");
150
+ if (op.bodyRaw != null) {
151
+ op.bid = `${base}Input`; op.bodyTs = tsType(doc, op.bodyRaw);
152
+ schemaEntries.push(` ${ident(base)}: ${schemaLiteral(op.bodyRaw)},`);
153
+ inputDecls.push(`const ${op.bid} = std<${op.bodyTs}>(schemas.${ident(base)});`);
154
+ }
155
+ if (op.queryRaw != null) {
156
+ op.qid = `${base}_q`; op.queryTs = tsType(doc, op.queryRaw);
157
+ schemaEntries.push(` ${ident(base + "_q")}: ${schemaLiteral(op.queryRaw)},`);
158
+ inputDecls.push(`const ${op.qid} = std<${op.queryTs}>(schemas.${ident(base + "_q")});`);
159
+ }
160
+ }
161
+ const manifest = Object.fromEntries(ops.map((o) => [[...o.ns, o.member].join("."), { cost: o.cost, requires: o.requires, ...(o.scope ? { scope: o.scope } : {}) }]));
162
+ const title = doc.info?.title ?? "API";
163
+ const version = doc.openapi ?? "4.0.0-candidate";
164
+ // shared models (components.schemas): a Standard Schema + an inferred TS type, both from the one JSON Schema.
165
+ const models = Object.entries(defs).map(([n, s]) => `export type ${ident(n)} = ${tsType(doc, s)};\nexport const ${ident(n)}Schema = std<${ident(n)}>(${schemaLiteral(s)});`).join("\n");
166
+ const totalCost = ops.filter((o) => o.cost != null).reduce((s, o) => s + (o.cost ?? 0), 0);
167
+
168
+ return `/**
169
+ * ${title} — TypeScript SDK. AUTO-GENERATED by @suluk/sdk from the v4 contract (OpenAPI ${version}). Do not edit.
170
+ *
171
+ * Generated straight from the contract: ${ops.length} operations, fully typed. The contract's input JSON Schemas
172
+ * are shipped AS DATA (\`schemas\`) and validated DIRECTLY by a generic, eval-free engine — never transpiled into a
173
+ * single validator's source — so what runs is exactly what the contract stores (lossless). Each input is a STANDARD
174
+ * SCHEMA (\`.input\`), so it drops into react-hook-form / TanStack Form / tRPC unchanged. Auth is wired via an
175
+ * interceptor; every method carries the v4 facets as typed metadata — \`.cost\` (µ$), \`.requires\` (who can call it),
176
+ * \`.input\` (the Standard Schema). Those are HINTS + a client-side guard, not enforcement — the server is the
177
+ * security boundary.${collisions.length ? `\n * Namespaced ${collisions.length} method collision(s) (a v4 multi-request-per-method capability): ${collisions.join("; ")}.` : ""}
178
+ *
179
+ * import { createClient } from "./suluk-sdk";
180
+ * const api = createClient({ baseURL: "${opts.baseURL ?? ""}", token: () => localStorage.getItem("token") });
181
+ * const products = await api.product.list();
182
+ * const order = await api.checkout.order({ items: [{ productId: 1, qty: 2 }] }); // input validated before send
183
+ *
184
+ * Requires: \`npm i ofetch @cfworker/json-schema\`.
185
+ */
186
+ import { ofetch, type FetchError } from "ofetch";
187
+ import { Validator } from "@cfworker/json-schema";
188
+
189
+ export type { FetchError };
190
+
191
+ /** The Standard Schema v1 interface — \`.input\` implements this, so any Standard-Schema-aware tool can consume it. */
192
+ export interface StandardSchemaV1<Output = unknown> {
193
+ readonly "~standard": {
194
+ readonly version: 1;
195
+ readonly vendor: "suluk";
196
+ readonly validate: (value: unknown) => StandardResult<Output>;
197
+ readonly types?: { readonly output: Output };
198
+ };
199
+ }
200
+ export type StandardIssue = { readonly message: string; readonly path: ReadonlyArray<string | number> };
201
+ export type StandardResult<O> = { readonly value: O } | { readonly issues: ReadonlyArray<StandardIssue> };
202
+
203
+ /** Thrown by a method when its input fails the contract's JSON Schema (only when \`validate\` is on). */
204
+ export class SulukValidationError extends Error {
205
+ constructor(public readonly issues: ReadonlyArray<StandardIssue>) {
206
+ super("Input failed contract validation: " + issues.map((i) => \`\${i.path.join(".") || "(root)"}: \${i.message}\`).join("; "));
207
+ this.name = "SulukValidationError";
208
+ }
209
+ }
210
+
211
+ /** The contract's input JSON Schemas (2020-12), shipped AS DATA — introspectable, validated directly. */
212
+ export const schemas = {
213
+ ${schemaEntries.join("\n")}
214
+ } as const;
215
+
216
+ /** Wrap a stored JSON Schema as a Standard Schema, backed by a generic, eval-free validator (Workers-native). */
217
+ function std<Output>(schema: unknown): StandardSchemaV1<Output> {
218
+ const v = new Validator(schema as object, "2020-12");
219
+ return {
220
+ "~standard": {
221
+ version: 1,
222
+ vendor: "suluk",
223
+ validate(value: unknown): StandardResult<Output> {
224
+ const r = v.validate(value);
225
+ if (r.valid) return { value: value as Output };
226
+ return { issues: (r.errors as Array<{ error?: string; instanceLocation?: string }>).map((e) => ({ message: e.error ?? "invalid", path: (e.instanceLocation ?? "").split("/").slice(1).filter(Boolean) })) };
227
+ },
228
+ },
229
+ };
230
+ }
231
+
232
+ /** Validate \`value\` against an input schema; return it (typed) or throw SulukValidationError. */
233
+ function parse<Output>(input: StandardSchemaV1<Output>, value: unknown): Output {
234
+ const r = input["~standard"].validate(value);
235
+ if ("issues" in r) throw new SulukValidationError(r.issues);
236
+ return r.value;
237
+ }
238
+
239
+ ${models}
240
+
241
+ ${inputDecls.join("\n")}
242
+
243
+ export interface SulukClientConfig {
244
+ /** API base URL (default: same-origin "${opts.baseURL ?? ""}"). */
245
+ baseURL?: string;
246
+ /** a bearer token, or a (sync/async) getter — injected as \`Authorization: Bearer …\` on every request. */
247
+ token?: string | (() => string | null | undefined | Promise<string | null | undefined>);
248
+ /** send cookies for session auth (default "include"). */
249
+ credentials?: RequestCredentials;
250
+ /** extra default headers (e.g. a \`x-suluk-action\` tag for cost attribution). */
251
+ headers?: Record<string, string>;
252
+ /** retries for idempotent requests (ofetch default). */
253
+ retry?: number;
254
+ /** validate inputs against the contract's JSON Schemas before sending (default true). */
255
+ validate?: boolean;
256
+ }
257
+
258
+ /** Create a typed, input-validating client for ${title}. */
259
+ export function createClient(config: SulukClientConfig = {}) {
260
+ const _v = config.validate !== false;
261
+ const api = ofetch.create({
262
+ baseURL: config.baseURL ?? ${JSON.stringify(opts.baseURL ?? "")},
263
+ credentials: config.credentials ?? "include",
264
+ headers: config.headers,
265
+ retry: config.retry,
266
+ async onRequest({ options }) {
267
+ const t = typeof config.token === "function" ? await config.token() : config.token;
268
+ if (t) { const h = new Headers(options.headers as HeadersInit | undefined); h.set("Authorization", \`Bearer \${t}\`); options.headers = h; }
269
+ },
270
+ });
271
+ return {
272
+ ${emitTree(ops)},
273
+ /** the raw ofetch instance — escape hatch for anything the typed methods don't cover. */
274
+ $fetch: api,
275
+ /** the contract's input JSON Schemas, as data (for codegen / forms / introspection). */
276
+ $schemas: schemas,
277
+ /** introspectable per-operation facet manifest (for agents/tooling): { "<method.path>": { cost, requires } }. */
278
+ $manifest: ${JSON.stringify(manifest)} as const,
279
+ /** total declared cost of all priced operations (µ$): ${totalCost}. */
280
+ $meta: { operations: ${ops.length}, totalDeclaredMicroUsd: ${totalCost}, version: ${JSON.stringify(version)} },
281
+ };
282
+ }
283
+
284
+ export type SulukClient = ReturnType<typeof createClient>;
285
+ `;
286
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @suluk/sdk — generate a complete, intuitive TypeScript SDK from a v4 "Suluk" contract. ofetch-based,
3
+ * entity-grouped, fully typed, auth wired, and the v4 superpowers (declared cost + access) surfaced as typed
4
+ * metadata on each method. A library a developer downloads and uses straight away — not a bag of functions.
5
+ *
6
+ * import { generateSdk } from "@suluk/sdk";
7
+ * const tsSource = generateSdk(v4Document, { baseURL: "https://api.example.com" }); // a self-contained .ts file
8
+ */
9
+ export { generateSdk, tsType, type SdkOptions } from "./generate";
@@ -0,0 +1,105 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { generateSdk, tsType } from "../src/index";
3
+ import type { OpenAPIv4Document } from "@suluk/core";
4
+
5
+ const doc = {
6
+ openapi: "4.0.0-candidate",
7
+ info: { title: "Store API" },
8
+ paths: {
9
+ product: {
10
+ requests: {
11
+ listProduct: { method: "get", summary: "List products", responses: { ok: { status: 200, contentSchema: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" } } } } } }, "x-suluk-access": { requires: "anyone" }, "x-suluk-cost": { estimateMicroUsd: 10 } },
12
+ createProduct: { method: "post", contentSchema: { type: "object", properties: { name: { type: "string", maxLength: 160, pattern: "^[^<>]+$" }, priceCents: { type: "integer", minimum: 0, maximum: 1000000000 }, status: { type: "string", enum: ["draft", "published"] } }, required: ["name"], additionalProperties: false }, responses: { created: { status: 201 } }, "x-suluk-access": { requires: "admin" }, "x-suluk-cost": { estimateMicroUsd: 145 } },
13
+ getProduct: { method: "get", responses: { ok: { status: 200 } }, "x-suluk-access": { requires: "anyone" } },
14
+ },
15
+ },
16
+ "checkout/order": { requests: { checkout: { method: "post", contentSchema: { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { productId: { type: "integer" }, qty: { type: "integer" } } } } } }, responses: { created: { status: 201 } }, "x-suluk-access": { requires: "anyone" } } } },
17
+ },
18
+ } as unknown as OpenAPIv4Document;
19
+
20
+ describe("@suluk/sdk — generate a typed ofetch SDK from a v4 contract", () => {
21
+ const sdk = generateSdk(doc, { baseURL: "https://api.example.com" });
22
+
23
+ test("emits a self-contained ofetch + generic-validator client", () => {
24
+ expect(sdk).toContain('import { ofetch, type FetchError } from "ofetch"');
25
+ expect(sdk).toContain('import { Validator } from "@cfworker/json-schema"'); // generic, eval-free engine
26
+ expect(sdk).toContain("export function createClient");
27
+ expect(sdk).toContain("onRequest"); // auth interceptor
28
+ expect(sdk).toContain('h.set("Authorization", `Bearer ${t}`)'); // injects the bearer via Headers.set
29
+ expect(sdk).toContain('"https://api.example.com"');
30
+ expect(sdk).toContain("Requires: `npm i ofetch @cfworker/json-schema`");
31
+ });
32
+
33
+ test("named-request identity → entity-grouped methods (CRUD by entity, custom by path)", () => {
34
+ expect(sdk).toContain("product: {");
35
+ expect(sdk).toContain("list: Object.assign");
36
+ expect(sdk).toContain("create: Object.assign");
37
+ expect(sdk).toContain("get: Object.assign");
38
+ expect(sdk).toContain("checkout: {");
39
+ expect(sdk).toContain("order: Object.assign");
40
+ });
41
+
42
+ test("v4 superpowers as INERT typed metadata (.cost / .requires) + a $manifest", () => {
43
+ expect(sdk).toContain('requires: "admin"'); // createProduct
44
+ expect(sdk).toContain("cost: 145");
45
+ expect(sdk).toContain('requires: "anyone"');
46
+ expect(sdk).toContain("$manifest:");
47
+ expect(sdk).toContain('"product.create":{"cost":145,"requires":"admin"}');
48
+ });
49
+
50
+ test("input JSON Schemas shipped AS DATA — the contract's real constraints, validated directly (not transpiled)", () => {
51
+ // the schemas are emitted as a data map, NOT transpiled into a validator's source
52
+ expect(sdk).toContain("export const schemas = {");
53
+ expect(sdk).toContain("product_create:");
54
+ // the contract's REAL constraints survive verbatim as JSON Schema (lossless — what's stored is what's checked)
55
+ expect(sdk).toContain('"maxLength":160');
56
+ expect(sdk).toContain('"pattern":"^[^<>]+$"');
57
+ expect(sdk).toContain('"minimum":0');
58
+ expect(sdk).toContain('"maximum":1000000000');
59
+ expect(sdk).toContain('"enum":["draft","published"]');
60
+ expect(sdk).toContain('"additionalProperties":false');
61
+ // each input is wrapped as a STANDARD SCHEMA, backed by the generic engine
62
+ expect(sdk).toContain("const product_createInput = std<");
63
+ expect(sdk).toContain("(schemas.product_create)");
64
+ expect(sdk).toContain('"~standard"');
65
+ expect(sdk).toContain('vendor: "suluk"');
66
+ expect(sdk).toContain('new Validator(schema as object, "2020-12")');
67
+ // the body is statically typed from the SAME schema, and validated through it before send
68
+ expect(sdk).toMatch(/body: \{ name: string/);
69
+ expect(sdk).toContain("body: _v ? parse(product_createInput, body) : body");
70
+ // validation is configurable + on by default
71
+ expect(sdk).toContain("validate?: boolean");
72
+ expect(sdk).toContain("const _v = config.validate !== false");
73
+ // the Standard Schema is surfaced as typed metadata (.input) + the raw schemas are introspectable
74
+ expect(sdk).toContain("input: product_createInput");
75
+ expect(sdk).toContain("$schemas: schemas");
76
+ });
77
+
78
+ test("one source — components.schemas emit a Standard Schema + an inferred TS type from the one JSON Schema", () => {
79
+ const withModels = generateSdk({
80
+ openapi: "4.0.0-candidate", info: { title: "M" },
81
+ paths: { thing: { requests: { getThing: { method: "get", responses: { ok: { status: 200 } } } } } },
82
+ components: { schemas: { Money: { type: "object", properties: { cents: { type: "integer", minimum: 0 } }, required: ["cents"], additionalProperties: false } } },
83
+ } as unknown as OpenAPIv4Document);
84
+ expect(withModels).toContain("export type Money = { cents: number };");
85
+ expect(withModels).toContain("export const MoneySchema = std<Money>(");
86
+ expect(withModels).toContain('"minimum":0'); // the real constraint travels with the data
87
+ });
88
+
89
+ test("collisions are resolved by DETERMINISTIC namespacing (never a runtime guess), and surfaced", () => {
90
+ const bad = { openapi: "4.0.0-candidate", info: { title: "X" }, paths: {
91
+ a: { requests: { createThing: { method: "post", responses: { ok: { status: 200 } } } } },
92
+ b: { requests: { createThing: { method: "post", responses: { ok: { status: 200 } } } } }, // both → thing.create
93
+ } } as unknown as OpenAPIv4Document;
94
+ const out = generateSdk(bad); // does NOT throw
95
+ expect(out).toContain("createPost: Object.assign");
96
+ expect(out).toContain("createPost2: Object.assign"); // distinct, stable names
97
+ expect(out).toContain("Namespaced 1 method collision");
98
+ });
99
+
100
+ test("tsType maps schemas to TS", () => {
101
+ expect(tsType(doc, { type: "string" })).toBe("string");
102
+ expect(tsType(doc, { type: "array", items: { type: "integer" } })).toBe("number[]");
103
+ expect(tsType(doc, { type: "object", properties: { a: { type: "string" } }, required: ["a"] })).toBe("{ a: string }");
104
+ });
105
+ });
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }