@suluk/sdk 0.2.2 → 0.3.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 +3 -2
- package/src/generate.ts +14 -4
- package/test/origin-metadata.test.ts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
".": "./src/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@suluk/core": "^0.1.
|
|
22
|
+
"@suluk/core": "^0.1.13",
|
|
23
|
+
"@suluk/examples": "^0.1.0"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
26
|
"ofetch": "^1.5.1",
|
package/src/generate.ts
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* • an ofetch-based createClient(config) factory — auth wired via an onRequest interceptor (bearer / cookie)
|
|
9
9
|
* • methods grouped intuitively: CRUD by entity (client.product.create), custom ops by path (client.checkout.order)
|
|
10
10
|
* • the v4 SUPERPOWERS surfaced as TYPED METADATA on each method: `.cost` (µ$), `.requires` (access), `.input`
|
|
11
|
-
* (the Standard Schema)
|
|
11
|
+
* (the Standard Schema), `.fields` (C041 per-input origin: input/sourced/computed + a wireable source edge — so a
|
|
12
|
+
* caller knows which inputs to faker, which to chain from a prior call, and which are server-set). Metadata + a
|
|
13
|
+
* client-side guard, not enforcement — the server is the boundary (C022).
|
|
12
14
|
* Static TS types come from the SAME JSON Schema (tsType), so the body is typed AND validated from one source.
|
|
13
15
|
*/
|
|
14
16
|
import type { OpenAPIv4Document, SulukStore } from "@suluk/core";
|
|
15
17
|
import { isReference } from "@suluk/core";
|
|
18
|
+
import { describeInputs, type FieldDescriptor } from "@suluk/examples";
|
|
16
19
|
|
|
17
20
|
const reserved = new Set(["delete", "new", "function", "default", "return", "class", "in", "for"]);
|
|
18
21
|
export const ident = (s: string) => { const c = s.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^[0-9]/, "_$&"); return reserved.has(c) ? `${c}_` : c; };
|
|
@@ -56,6 +59,7 @@ export interface OpInfo {
|
|
|
56
59
|
pathParams: string[]; queryRaw?: unknown; bodyRaw?: unknown; respType: string;
|
|
57
60
|
cost: number | null; requires: string; scope?: string; summary?: string;
|
|
58
61
|
store?: SulukStore; // the C037 reactive facet (read by generateStores, not generateSdk)
|
|
62
|
+
fields?: FieldDescriptor[]; // C041 per-input field origin (input/sourced/computed + wireable source edge)
|
|
59
63
|
bid?: string; qid?: string; bodyTs?: string; queryTs?: string; // assigned after collision resolution
|
|
60
64
|
}
|
|
61
65
|
|
|
@@ -80,11 +84,14 @@ function walkOps(doc: OpenAPIv4Document): OpInfo[] {
|
|
|
80
84
|
else { const segs = uri.split("/").filter((x) => x && !x.startsWith("{")); ns = segs.slice(0, -1).map(camel); member = camel(segs[segs.length - 1] ?? name); }
|
|
81
85
|
const ps = req.parameterSchema ?? {};
|
|
82
86
|
const acc = req["x-suluk-access"];
|
|
87
|
+
const bodyRaw = req.contentSchema ?? ps.body;
|
|
88
|
+
const fields = describeInputs(bodyRaw as Record<string, unknown> | undefined);
|
|
83
89
|
ops.push({
|
|
84
90
|
name, ns, member, method: req.method.toLowerCase(), uri, pathParams: pathVars(uri),
|
|
85
|
-
queryRaw: ps.query, bodyRaw
|
|
91
|
+
queryRaw: ps.query, bodyRaw, respType: respType(doc, req),
|
|
86
92
|
cost: costOf(req), requires: acc?.requires ?? "anyone", scope: acc?.scope, summary: req.summary,
|
|
87
93
|
store: req["x-suluk-store"],
|
|
94
|
+
fields: fields.length ? fields : undefined,
|
|
88
95
|
});
|
|
89
96
|
}
|
|
90
97
|
}
|
|
@@ -128,7 +135,8 @@ function emitMethod(op: OpInfo): string {
|
|
|
128
135
|
const opts = [`method: "${op.method.toUpperCase()}"`];
|
|
129
136
|
if (op.bid) opts.push(`body: _v ? parse(${op.bid}, body) : body`);
|
|
130
137
|
if (op.qid) opts.push(`query: _v && query ? parse(${op.qid}, query) : query`);
|
|
131
|
-
const
|
|
138
|
+
const fieldsMeta = op.fields?.length ? `, fields: ${JSON.stringify(op.fields)}` : "";
|
|
139
|
+
const meta = `{ cost: ${op.cost ?? "null"}, requires: ${JSON.stringify(op.requires)}${op.scope ? `, scope: ${JSON.stringify(op.scope)}` : ""}${op.bid ? `, input: ${op.bid}` : ""}${fieldsMeta} }`;
|
|
132
140
|
const doc = op.summary ? ` /** ${op.summary.replace(/\*\//g, "*\\/")} — ${op.requires}${op.cost != null ? ` · ⛁ ${op.cost}µ$` : ""} */\n` : "";
|
|
133
141
|
return `${doc} ${ident(op.member)}: Object.assign(\n (${args.join(", ")}) => api<${op.respType}>(${url}, { ${opts.join(", ")} }),\n ${meta},\n )`;
|
|
134
142
|
}
|
|
@@ -177,6 +185,7 @@ export function generateSdk(doc: OpenAPIv4Document, opts: SdkOptions = {}): stri
|
|
|
177
185
|
inputDecls.push(`const ${op.qid} = std<${op.queryTs}>(schemas.${ident(base + "_q")});`);
|
|
178
186
|
}
|
|
179
187
|
}
|
|
188
|
+
// $manifest stays the LEAN facet index (cost/requires/scope); the full per-input origin lives on each method's `.fields`.
|
|
180
189
|
const manifest = Object.fromEntries(ops.map((o) => [[...o.ns, o.member].join("."), { cost: o.cost, requires: o.requires, ...(o.scope ? { scope: o.scope } : {}) }]));
|
|
181
190
|
const title = doc.info?.title ?? "API";
|
|
182
191
|
const version = doc.openapi ?? "4.0.0-candidate";
|
|
@@ -192,7 +201,8 @@ export function generateSdk(doc: OpenAPIv4Document, opts: SdkOptions = {}): stri
|
|
|
192
201
|
* single validator's source — so what runs is exactly what the contract stores (lossless). Each input is a STANDARD
|
|
193
202
|
* SCHEMA (\`.input\`), so it drops into react-hook-form / TanStack Form / tRPC unchanged. Auth is wired via an
|
|
194
203
|
* interceptor; every method carries the v4 facets as typed metadata — \`.cost\` (µ$), \`.requires\` (who can call it),
|
|
195
|
-
* \`.input\` (the Standard Schema)
|
|
204
|
+
* \`.input\` (the Standard Schema), and \`.fields\` (per-input origin: input/sourced/computed + wireable source edges).
|
|
205
|
+
* Those are HINTS + a client-side guard, not enforcement — the server is the
|
|
196
206
|
* security boundary.${collisions.length ? `\n * Namespaced ${collisions.length} method collision(s) (a v4 multi-request-per-method capability): ${collisions.join("; ")}.` : ""}
|
|
197
207
|
*
|
|
198
208
|
* import { createClient } from "./suluk-sdk";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { generateSdk, resolveOps } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* C041 — the SDK generator surfaces field-origin as typed metadata (`.fields`) so a caller knows which inputs to faker
|
|
7
|
+
* (`input`), which to CHAIN from a prior call (`sourced`, with a wireable `{op, select}` edge), and which are server-set
|
|
8
|
+
* (`computed`). Read from `@suluk/examples` (the shared leaf), not re-derived. The marker rides the request body schema.
|
|
9
|
+
*/
|
|
10
|
+
const doc = {
|
|
11
|
+
openapi: "4.0.0-candidate",
|
|
12
|
+
info: { title: "Billing API" },
|
|
13
|
+
paths: {
|
|
14
|
+
"billing/charge": {
|
|
15
|
+
requests: {
|
|
16
|
+
charge: {
|
|
17
|
+
method: "post",
|
|
18
|
+
contentSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
required: ["amountCents", "subscriptionId"],
|
|
21
|
+
properties: {
|
|
22
|
+
amountCents: { type: "integer", minimum: 100, "x-suluk-origin": "input" },
|
|
23
|
+
subscriptionId: { type: "string", "x-suluk-origin": "sourced", "x-suluk-from": { op: "createSubscription", select: "id" } },
|
|
24
|
+
total: { type: "number", "x-suluk-origin": "computed", "x-suluk-from": "amountCents + fees" },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
responses: { ok: { status: 200 } },
|
|
28
|
+
"x-suluk-access": { requires: "authenticated" },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
} as unknown as OpenAPIv4Document;
|
|
34
|
+
|
|
35
|
+
describe("@suluk/sdk surfaces C041 field origin as method metadata", () => {
|
|
36
|
+
const src = generateSdk(doc, { baseURL: "https://api.example.com" });
|
|
37
|
+
|
|
38
|
+
test("the op carries a `fields:` metadata block", () => {
|
|
39
|
+
expect(src).toContain("fields:");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the sourced field is surfaced as a wireable edge (op + select), not a free input", () => {
|
|
43
|
+
expect(src).toContain('"origin":"sourced"');
|
|
44
|
+
expect(src).toContain('"op":"createSubscription"');
|
|
45
|
+
expect(src).toContain('"select":"id"');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the input field is marked fakerable; the computed field is marked not-fakerable", () => {
|
|
49
|
+
expect(src).toMatch(/"name":"amountCents"[^}]*"origin":"input"[^}]*"fakerable":true/);
|
|
50
|
+
expect(src).toMatch(/"name":"total"[^}]*"origin":"computed"[^}]*"fakerable":false/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("resolveOps populates op.fields from the request body (the SDK reads, never re-derives)", () => {
|
|
54
|
+
const { ops } = resolveOps(doc);
|
|
55
|
+
const charge = ops.find((o) => o.name === "charge")!;
|
|
56
|
+
expect(charge.fields?.map((f) => [f.name, f.origin])).toEqual([
|
|
57
|
+
["amountCents", "input"],
|
|
58
|
+
["subscriptionId", "sourced"],
|
|
59
|
+
["total", "computed"],
|
|
60
|
+
]);
|
|
61
|
+
expect(charge.fields?.find((f) => f.name === "subscriptionId")?.source).toEqual({ op: "createSubscription", select: "id" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an op with no body emits no fields block (no bloat)", () => {
|
|
65
|
+
const noBody = {
|
|
66
|
+
openapi: "4.0.0-candidate", info: { title: "T" },
|
|
67
|
+
paths: { health: { requests: { health: { method: "get", responses: { ok: { status: 200 } } } } } },
|
|
68
|
+
} as unknown as OpenAPIv4Document;
|
|
69
|
+
expect(generateSdk(noBody)).not.toContain("fields:");
|
|
70
|
+
});
|
|
71
|
+
});
|