@suluk/sdk 0.2.1 → 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-stores.ts +13 -1
- package/src/generate.ts +14 -4
- package/test/origin-metadata.test.ts +71 -0
- package/test/stores.test.ts +2 -1
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-stores.ts
CHANGED
|
@@ -204,7 +204,19 @@ export interface CreateStoresOptions {
|
|
|
204
204
|
/** Create the reactive store layer for ${title}, bound to an SDK client. The contract declares the policy; you inject the rendering via \`hooks\`. */
|
|
205
205
|
export function createStores(client: SulukClient, options: CreateStoresOptions = {}) {
|
|
206
206
|
const hooks = options.hooks ?? createHooks<StoreHooks>();
|
|
207
|
-
|
|
207
|
+
// A bounded cache: @nanostores/query's default cache (a plain Map) is never evicted (cacheLifetime only gates a HIT),
|
|
208
|
+
// so a parameterized store driven by free text (a search box) would grow it for the page's lifetime. Cap it — evict
|
|
209
|
+
// the oldest entry past the limit (LRU-ish). The entry shape matches @nanostores/query's so the type stays exact.
|
|
210
|
+
const cache = new Map<string, { data?: unknown; error?: unknown; retryCount?: number; created?: number; expires?: number }>();
|
|
211
|
+
const _set = cache.set.bind(cache);
|
|
212
|
+
cache.set = (k, v) => {
|
|
213
|
+
if (!cache.has(k) && cache.size >= 500) {
|
|
214
|
+
const oldest = cache.keys().next().value;
|
|
215
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
216
|
+
}
|
|
217
|
+
return _set(k, v);
|
|
218
|
+
};
|
|
219
|
+
const [createFetcherStore, , ctx] = nanoquery({ cache });
|
|
208
220
|
/** last surfaced status per op — so an AUTO re-run of a failing query (retry-backoff / revalidate-on-focus) doesn't
|
|
209
221
|
* re-toast the SAME failure. A query clears its entry on success; user-triggered actions/one-offs pass dedupe=false. */
|
|
210
222
|
const _seen = new Map<string, number | "network">();
|
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
|
+
});
|
package/test/stores.test.ts
CHANGED
|
@@ -24,7 +24,8 @@ describe("@suluk/sdk generateStores — a typed Nano Stores reactive layer from
|
|
|
24
24
|
expect(stores).toContain('import { createHooks, type Hookable } from "hookable"');
|
|
25
25
|
expect(stores).toContain('import type { SulukClient } from "./sdk"'); // client TYPE only — self-contained
|
|
26
26
|
expect(stores).toContain("export function createStores(client: SulukClient");
|
|
27
|
-
expect(stores).toContain("const [createFetcherStore, , ctx] = nanoquery()");
|
|
27
|
+
expect(stores).toContain("const [createFetcherStore, , ctx] = nanoquery({ cache })"); // bounded cache (no unbounded growth)
|
|
28
|
+
expect(stores).toContain("if (!cache.has(k) && cache.size >= 500)");
|
|
28
29
|
expect(stores).toContain("Requires: `npm i @nanostores/query nanostores hookable`");
|
|
29
30
|
});
|
|
30
31
|
|