@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 +40 -0
- package/src/generate.ts +286 -0
- package/src/index.ts +9 -0
- package/test/sdk.test.ts +105 -0
- package/tsconfig.json +1 -0
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
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -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";
|
package/test/sdk.test.ts
ADDED
|
@@ -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"] }
|