@ogygie/core 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/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +77 -0
- package/src/bundle-budget.test.ts +64 -0
- package/src/bundle-budget.ts +51 -0
- package/src/clock.ts +24 -0
- package/src/contract-purity.test.ts +185 -0
- package/src/contract-purity.ts +213 -0
- package/src/db.ts +9 -0
- package/src/decision-refs.test.ts +114 -0
- package/src/decision-refs.ts +103 -0
- package/src/design-brief.test.ts +221 -0
- package/src/design-brief.ts +581 -0
- package/src/error.schema.ts +62 -0
- package/src/examples/erreur-convention-ci.json +10 -0
- package/src/examples/erreur-validation-runtime.json +10 -0
- package/src/manifest-client.test.ts +287 -0
- package/src/manifest-client.ts +313 -0
- package/src/manifest-gateway.test.ts +541 -0
- package/src/manifest-gateway.ts +392 -0
- package/src/manifest.schema.test.ts +255 -0
- package/src/manifest.schema.ts +187 -0
- package/src/module-purity.test.ts +117 -0
- package/src/module-purity.ts +127 -0
- package/src/product-vocabulary.scan.test.ts +142 -0
- package/src/shared-tables.test.ts +136 -0
- package/src/shared-tables.ts +113 -0
- package/src/spec.schema.test.ts +143 -0
- package/src/spec.schema.ts +182 -0
- package/src/tenant.ts +207 -0
- package/src/test-kit.ts +30 -0
- package/src/vocabulary.test.ts +46 -0
- package/src/vocabulary.ts +36 -0
- package/src/web-lint.test.ts +100 -0
- package/src/web-lint.ts +176 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Proves the manifest-client derivation (spec front-derivation §1):
|
|
3
|
+
* the client exposes exactly the declared routes (mutation test: a removed
|
|
4
|
+
* route disappears), derives namespaces, operations, cache keys and
|
|
5
|
+
* invalidation roots, and refuses loudly what it cannot derive. Fixtures are
|
|
6
|
+
* deliberately vocabulary-neutral — the vocabulary test next door scans this
|
|
7
|
+
* file too.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { OgygieFailure } from "./error.schema.js";
|
|
12
|
+
import {
|
|
13
|
+
createManifestClient,
|
|
14
|
+
type ManifestClientRequest,
|
|
15
|
+
} from "./manifest-client.js";
|
|
16
|
+
|
|
17
|
+
const SCHEMA_NAMES = [
|
|
18
|
+
"ListWidgetsInputSchema",
|
|
19
|
+
"ListWidgetsOutputSchema",
|
|
20
|
+
"CreateWidgetInputSchema",
|
|
21
|
+
"CreateWidgetOutputSchema",
|
|
22
|
+
"UpdateWidgetInputSchema",
|
|
23
|
+
"UpdateWidgetOutputSchema",
|
|
24
|
+
"WidgetParamsSchema",
|
|
25
|
+
"GetWidgetOutputSchema",
|
|
26
|
+
"ArchiveWidgetOutputSchema",
|
|
27
|
+
"ListWorkOrdersInputSchema",
|
|
28
|
+
"ListWorkOrdersOutputSchema",
|
|
29
|
+
"DecideWorkOrderInputSchema",
|
|
30
|
+
"DecideWorkOrderOutputSchema",
|
|
31
|
+
"SearchReportsInputSchema",
|
|
32
|
+
"SearchReportsOutputSchema",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/** One distinct schema instance per name, so identity can be asserted. */
|
|
36
|
+
function schemaRegistry(): Record<string, z.ZodType> {
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
SCHEMA_NAMES.map((name) => [name, z.record(z.string(), z.unknown())]),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RouteOverride {
|
|
43
|
+
invalidates?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fixtureManifest(options?: {
|
|
47
|
+
dropArchiveRoute?: boolean;
|
|
48
|
+
extraWidgetRoutes?: Record<string, unknown>[];
|
|
49
|
+
acceptRoute?: RouteOverride;
|
|
50
|
+
}) {
|
|
51
|
+
const widgetRoutes: Record<string, unknown>[] = [
|
|
52
|
+
{
|
|
53
|
+
method: "GET",
|
|
54
|
+
path: "/api/widgets",
|
|
55
|
+
description: "Lists the widgets of the account.",
|
|
56
|
+
schemaInput: "ListWidgetsInputSchema",
|
|
57
|
+
schemaOutput: "ListWidgetsOutputSchema",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
method: "POST",
|
|
61
|
+
path: "/api/widgets",
|
|
62
|
+
description: "Creates a widget for the account.",
|
|
63
|
+
schemaInput: "CreateWidgetInputSchema",
|
|
64
|
+
schemaOutput: "CreateWidgetOutputSchema",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
method: "PATCH",
|
|
68
|
+
path: "/api/widgets/:widgetId",
|
|
69
|
+
description: "Updates a widget of the account.",
|
|
70
|
+
schemaInput: "UpdateWidgetInputSchema",
|
|
71
|
+
schemaOutput: "UpdateWidgetOutputSchema",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
method: "GET",
|
|
75
|
+
path: "/api/widgets/:widgetId",
|
|
76
|
+
description: "Reads one widget of the account.",
|
|
77
|
+
schemaInput: "WidgetParamsSchema",
|
|
78
|
+
schemaOutput: "GetWidgetOutputSchema",
|
|
79
|
+
},
|
|
80
|
+
...(options?.dropArchiveRoute === true
|
|
81
|
+
? []
|
|
82
|
+
: [
|
|
83
|
+
{
|
|
84
|
+
method: "POST",
|
|
85
|
+
path: "/api/widgets/:widgetId/archive",
|
|
86
|
+
description: "Archives a widget (one-way removal).",
|
|
87
|
+
schemaInput: "WidgetParamsSchema",
|
|
88
|
+
schemaOutput: "ArchiveWidgetOutputSchema",
|
|
89
|
+
},
|
|
90
|
+
]),
|
|
91
|
+
...(options?.extraWidgetRoutes ?? []),
|
|
92
|
+
];
|
|
93
|
+
return {
|
|
94
|
+
version: 1,
|
|
95
|
+
product: { name: "acme", description: "Vocabulary-neutral fixture product for derivation tests." },
|
|
96
|
+
apps: [{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" }],
|
|
97
|
+
features: [
|
|
98
|
+
{
|
|
99
|
+
name: "widgets",
|
|
100
|
+
intent: "Fixture feature with the full nominal route surface of a referential.",
|
|
101
|
+
folder: "src/features/widgets",
|
|
102
|
+
spec: null,
|
|
103
|
+
routes: widgetRoutes,
|
|
104
|
+
dbTables: [],
|
|
105
|
+
dependsOn: [],
|
|
106
|
+
status: "delivered",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "work-orders",
|
|
110
|
+
intent: "Fixture feature with a kebab-case root and a cross invalidation.",
|
|
111
|
+
folder: "src/features/work-orders",
|
|
112
|
+
spec: null,
|
|
113
|
+
routes: [
|
|
114
|
+
{
|
|
115
|
+
method: "GET",
|
|
116
|
+
path: "/api/work-orders",
|
|
117
|
+
description: "Lists the pending work orders.",
|
|
118
|
+
schemaInput: "ListWorkOrdersInputSchema",
|
|
119
|
+
schemaOutput: "ListWorkOrdersOutputSchema",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
method: "POST",
|
|
123
|
+
path: "/api/work-orders/:orderId/accept",
|
|
124
|
+
description: "Accepts a pending work order.",
|
|
125
|
+
schemaInput: "DecideWorkOrderInputSchema",
|
|
126
|
+
schemaOutput: "DecideWorkOrderOutputSchema",
|
|
127
|
+
...(options?.acceptRoute ?? { invalidates: ["widgets"] }),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
method: "GET",
|
|
131
|
+
path: "/api/reports",
|
|
132
|
+
description: "Searches the reports of the account.",
|
|
133
|
+
schemaInput: "SearchReportsInputSchema",
|
|
134
|
+
schemaOutput: "SearchReportsOutputSchema",
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
dbTables: [],
|
|
138
|
+
dependsOn: [],
|
|
139
|
+
status: "delivered",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildClient(manifest: unknown = fixtureManifest()) {
|
|
147
|
+
const requests: ManifestClientRequest[] = [];
|
|
148
|
+
const schemas = schemaRegistry();
|
|
149
|
+
const client = createManifestClient(manifest, schemas, {
|
|
150
|
+
performRequest: async (request) => {
|
|
151
|
+
requests.push(request);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
return { client, requests, schemas };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function failureCodeOf(run: () => unknown): string | null {
|
|
159
|
+
try {
|
|
160
|
+
run();
|
|
161
|
+
return null;
|
|
162
|
+
} catch (thrown) {
|
|
163
|
+
return thrown instanceof OgygieFailure ? thrown.error.code : "NOT_AN_OGYGIE_FAILURE";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
describe("manifest-client — dérivation (spec front-derivation §1)", () => {
|
|
168
|
+
it("ÉTANT DONNÉ le manifeste QUAND le client est construit ALORS il expose exactement les routes déclarées", () => {
|
|
169
|
+
const { client } = buildClient();
|
|
170
|
+
expect(Object.keys(client).sort()).toEqual(["reports", "widgets", "workOrders"]);
|
|
171
|
+
expect(Object.keys(client["widgets"] ?? {}).sort()).toEqual([
|
|
172
|
+
"archive",
|
|
173
|
+
"create",
|
|
174
|
+
"get",
|
|
175
|
+
"list",
|
|
176
|
+
"update",
|
|
177
|
+
]);
|
|
178
|
+
expect(Object.keys(client["workOrders"] ?? {}).sort()).toEqual(["accept", "list"]);
|
|
179
|
+
expect(Object.keys(client["reports"] ?? {}).sort()).toEqual(["search"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("MUTATION : une route retirée du manifeste disparaît du client", () => {
|
|
183
|
+
const { client } = buildClient(fixtureManifest({ dropArchiveRoute: true }));
|
|
184
|
+
expect(client["widgets"]?.["archive"]).toBeUndefined();
|
|
185
|
+
expect(client["widgets"]?.["create"]).toBeDefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("les clés de cache sont dérivées du chemin : racine, opération, valeurs de params, puis reste de l'entrée", () => {
|
|
189
|
+
const { client } = buildClient();
|
|
190
|
+
expect(client["widgets"]?.["list"]?.queryKey()).toEqual(["widgets", "list"]);
|
|
191
|
+
expect(client["widgets"]?.["get"]?.queryKey({ widgetId: "w-1" })).toEqual([
|
|
192
|
+
"widgets",
|
|
193
|
+
"get",
|
|
194
|
+
"w-1",
|
|
195
|
+
]);
|
|
196
|
+
expect(client["reports"]?.["search"]?.queryKey({ q: "ab", limit: 20 })).toEqual([
|
|
197
|
+
"reports",
|
|
198
|
+
"search",
|
|
199
|
+
{ q: "ab", limit: 20 },
|
|
200
|
+
]);
|
|
201
|
+
// Kebab-case root stays RAW in the key (it is the invalidation currency),
|
|
202
|
+
// while the namespace is camelCased for the call site.
|
|
203
|
+
expect(client["workOrders"]?.["list"]?.queryKey()).toEqual(["work-orders", "list"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("RÉGRESSION (ecrans-plan) : deux GET voisins sur la même entité ne partagent JAMAIS une clé de cache", () => {
|
|
207
|
+
// Without the operation in the key, widgets.get and a sibling
|
|
208
|
+
// GET /api/widgets/:widgetId/child would collide — TanStack would dedupe
|
|
209
|
+
// them and the second query would silently never fire.
|
|
210
|
+
const { client } = buildClient();
|
|
211
|
+
const getKey = client["widgets"]?.["get"]?.queryKey({ widgetId: "w-1" });
|
|
212
|
+
const archiveKey = client["widgets"]?.["archive"]?.queryKey({ widgetId: "w-1" });
|
|
213
|
+
expect(getKey).not.toEqual(archiveKey);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("les racines d'invalidation = racine propre + invalidates déclarés au manifeste", () => {
|
|
217
|
+
const { client } = buildClient();
|
|
218
|
+
expect(client["workOrders"]?.["accept"]?.invalidationRoots).toEqual([
|
|
219
|
+
"work-orders",
|
|
220
|
+
"widgets",
|
|
221
|
+
]);
|
|
222
|
+
expect(client["widgets"]?.["create"]?.invalidationRoots).toEqual(["widgets"]);
|
|
223
|
+
expect(client["widgets"]?.["create"]?.isMutation).toBe(true);
|
|
224
|
+
expect(client["widgets"]?.["list"]?.isMutation).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("un appel GET envoie le reste de l'entrée en query string, sans body", async () => {
|
|
228
|
+
const { client, requests, schemas } = buildClient();
|
|
229
|
+
await client["reports"]?.["search"]?.({ q: "ab", limit: 20 });
|
|
230
|
+
expect(requests).toEqual([
|
|
231
|
+
{
|
|
232
|
+
method: "GET",
|
|
233
|
+
path: "/api/reports?q=ab&limit=20",
|
|
234
|
+
outputSchema: schemas["SearchReportsOutputSchema"],
|
|
235
|
+
},
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("un appel de mutation consomme les params du chemin et envoie le reste en body", async () => {
|
|
240
|
+
const { client, requests } = buildClient();
|
|
241
|
+
await client["widgets"]?.["update"]?.({ widgetId: "w-1", title: "renamed" });
|
|
242
|
+
await client["workOrders"]?.["accept"]?.({ orderId: "o-9" });
|
|
243
|
+
expect(requests[0]?.path).toBe("/api/widgets/w-1");
|
|
244
|
+
expect(requests[0]?.body).toEqual({ title: "renamed" });
|
|
245
|
+
expect(requests[1]?.path).toBe("/api/work-orders/o-9/accept");
|
|
246
|
+
expect(requests[1]?.body).toEqual({});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("un param de chemin manquant à l'appel est refusé bruyamment", async () => {
|
|
250
|
+
const { client } = buildClient();
|
|
251
|
+
await expect(client["widgets"]?.["update"]?.({ title: "renamed" })).rejects.toSatisfy(
|
|
252
|
+
(thrown) =>
|
|
253
|
+
thrown instanceof OgygieFailure && thrown.error.code === "CLIENT_MISSING_PATH_PARAM",
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("MUTATION : un schéma absent du registre refuse la construction (CLIENT_MANIFEST_INVALID)", () => {
|
|
258
|
+
const schemas = schemaRegistry();
|
|
259
|
+
delete schemas["CreateWidgetInputSchema"];
|
|
260
|
+
expect(
|
|
261
|
+
failureCodeOf(() =>
|
|
262
|
+
createManifestClient(fixtureManifest(), schemas, {
|
|
263
|
+
performRequest: async () => ({}),
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
).toBe("CLIENT_MANIFEST_INVALID");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("MUTATION : deux routes qui dérivent la même opération refusent la construction", () => {
|
|
270
|
+
const manifest = fixtureManifest({
|
|
271
|
+
extraWidgetRoutes: [
|
|
272
|
+
{
|
|
273
|
+
method: "PUT",
|
|
274
|
+
path: "/api/widgets/:widgetId",
|
|
275
|
+
description: "Collides with PATCH: same derived operation name.",
|
|
276
|
+
schemaInput: "UpdateWidgetInputSchema",
|
|
277
|
+
schemaOutput: "UpdateWidgetOutputSchema",
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
expect(failureCodeOf(() => buildClient(manifest))).toBe("CLIENT_MANIFEST_INVALID");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("MUTATION : un manifeste invalide refuse la construction au lieu de dériver un client menteur", () => {
|
|
285
|
+
expect(failureCodeOf(() => buildClient({ version: 1 }))).toBe("CLIENT_MANIFEST_INVALID");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent GENERIC manifest-derived API client factory, framework side — the
|
|
3
|
+
* browser pendant of the manifest-derived gateway: this module knows ZERO
|
|
4
|
+
* product vocabulary (D10 amended — generic tooling lives in the core), and
|
|
5
|
+
* the vocabulary test next to it proves it. It derives callable operations
|
|
6
|
+
* from an Ogygie manifest: every declared route becomes a method under its
|
|
7
|
+
* path-root namespace, nothing else exists — a route removed from the
|
|
8
|
+
* manifest disappears from the client, the manifest is the only map (D27).
|
|
9
|
+
*
|
|
10
|
+
* Derivations (all from the manifest, nothing hand-written per feature):
|
|
11
|
+
* - namespace: first path segment after /api, camelCased
|
|
12
|
+
* ("/api/work-orders/..." → client.workOrders);
|
|
13
|
+
* - operation: the static segments after the namespace, camelCased
|
|
14
|
+
* (".../:orderId/accept" → accept); when there are none, the VERB of the
|
|
15
|
+
* declared input schema name ("SearchThingsInputSchema" → search); when the
|
|
16
|
+
* schema name carries no verb, a method fallback (GET → list, GET on
|
|
17
|
+
* ":param" → get, POST → create, PATCH/PUT → update, DELETE → delete);
|
|
18
|
+
* - cache key: [root, ...path param values, remaining input if any] — the
|
|
19
|
+
* number-one source of cache bugs, so it is never written by hand;
|
|
20
|
+
* - invalidation roots: the route's own root, plus the roots the manifest
|
|
21
|
+
* declares in `invalidates` (cross-feature staleness is READ, never
|
|
22
|
+
* guessed in a screen).
|
|
23
|
+
*
|
|
24
|
+
* Transport stays OUTSIDE: the consumer injects `performRequest` (its typed
|
|
25
|
+
* fetch over the shared contracts, dev-time response validation included) —
|
|
26
|
+
* this module only resolves paths, splits inputs and derives keys. Building
|
|
27
|
+
* an incoherent client REFUSES loudly (CLIENT_MANIFEST_INVALID): a refusal
|
|
28
|
+
* at construction beats a method that lies at call time.
|
|
29
|
+
*/
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { throwOgygieError } from "./error.schema.js";
|
|
32
|
+
|
|
33
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The SUBSET of an Ogygie manifest the client consumes: the route surface,
|
|
37
|
+
* nothing else. Validating this VIEW (zod strips the unknown rest, so a full
|
|
38
|
+
* manifest passes as-is) keeps the guardianship of the whole manifest where
|
|
39
|
+
* it lives — verify-manifest in CI, the gateway at boot — and lets a browser
|
|
40
|
+
* bundle carry only these fields: the consumer may prune the manifest at
|
|
41
|
+
* build time by parsing it with this very schema.
|
|
42
|
+
*/
|
|
43
|
+
export const ClientManifestViewSchema = z.object({
|
|
44
|
+
features: z.array(
|
|
45
|
+
z.object({
|
|
46
|
+
name: z.string().min(1),
|
|
47
|
+
routes: z.array(
|
|
48
|
+
z.object({
|
|
49
|
+
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
50
|
+
path: z.string().startsWith("/api/"),
|
|
51
|
+
schemaInput: z.string().min(1),
|
|
52
|
+
schemaOutput: z.string().min(1),
|
|
53
|
+
invalidates: z.array(z.string()).optional(),
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
export type ClientManifestView = z.infer<typeof ClientManifestViewSchema>;
|
|
60
|
+
|
|
61
|
+
/** What the injected transport receives: a fully resolved request. */
|
|
62
|
+
export interface ManifestClientRequest {
|
|
63
|
+
method: HttpMethod;
|
|
64
|
+
/** Path with params substituted and the query string already appended. */
|
|
65
|
+
path: string;
|
|
66
|
+
/** Present on POST/PUT/PATCH only: the input minus the path params. */
|
|
67
|
+
body?: unknown;
|
|
68
|
+
outputSchema: z.ZodType;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ManifestClientOptions {
|
|
72
|
+
performRequest: (request: ManifestClientRequest) => Promise<unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One derived operation: callable, and self-describing for the query layer. */
|
|
76
|
+
export interface ApiOperation {
|
|
77
|
+
(input?: Record<string, unknown>): Promise<unknown>;
|
|
78
|
+
method: HttpMethod;
|
|
79
|
+
path: string;
|
|
80
|
+
namespace: string;
|
|
81
|
+
operation: string;
|
|
82
|
+
inputSchema: z.ZodType;
|
|
83
|
+
outputSchema: z.ZodType;
|
|
84
|
+
/** True for POST/PUT/PATCH/DELETE — the caller picks query vs mutation. */
|
|
85
|
+
isMutation: boolean;
|
|
86
|
+
/** Derived cache key: [root, ...param values, remaining input if any]. */
|
|
87
|
+
queryKey: (input?: Record<string, unknown>) => ReadonlyArray<unknown>;
|
|
88
|
+
/** Cache roots stale after a successful mutation: own root + declared `invalidates`. */
|
|
89
|
+
invalidationRoots: ReadonlyArray<string>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type ManifestClient = Record<string, Record<string, ApiOperation>>;
|
|
93
|
+
|
|
94
|
+
const METHODS_WITH_BODY = new Set<HttpMethod>(["POST", "PUT", "PATCH"]);
|
|
95
|
+
|
|
96
|
+
/** Same well-formedness bar as the gateway: /api segments and :params only. */
|
|
97
|
+
const WELL_FORMED_ROUTE_PATH = /^\/api(\/(:?[a-zA-Z0-9_-]+))+$/;
|
|
98
|
+
|
|
99
|
+
/** "work-orders" → "workOrders" (path segments are kebab-case by convention). */
|
|
100
|
+
function camelCaseOf(segment: string): string {
|
|
101
|
+
return segment.replace(/-([a-z0-9])/g, (_, letter: string) => letter.toUpperCase());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** "SearchThingsInputSchema" → "search"; null when the name carries no verb. */
|
|
105
|
+
function verbOfInputSchemaName(schemaName: string): string | null {
|
|
106
|
+
const match = /^([A-Z][a-z0-9]*)[A-Z].*InputSchema$/.exec(schemaName);
|
|
107
|
+
return match?.[1] === undefined ? null : match[1].toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function refuseClient(cause: string): never {
|
|
111
|
+
throwOgygieError(
|
|
112
|
+
{
|
|
113
|
+
code: "CLIENT_MANIFEST_INVALID",
|
|
114
|
+
message: "Manifest client construction refused: the manifest cannot be derived into operations.",
|
|
115
|
+
file: "ogygie.manifest.json",
|
|
116
|
+
cause,
|
|
117
|
+
suggestedFix:
|
|
118
|
+
"Fix the manifest (or register the missing schema in the client's schema registry) — a construction refusal beats a method that lies.",
|
|
119
|
+
},
|
|
120
|
+
500,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface DerivedRoute {
|
|
125
|
+
method: HttpMethod;
|
|
126
|
+
path: string;
|
|
127
|
+
root: string;
|
|
128
|
+
namespace: string;
|
|
129
|
+
operation: string;
|
|
130
|
+
paramNames: ReadonlyArray<string>;
|
|
131
|
+
schemaInput: string;
|
|
132
|
+
schemaOutput: string;
|
|
133
|
+
invalidates: ReadonlyArray<string>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function deriveOperationName(route: DerivedRoute): string {
|
|
137
|
+
const staticTail = route.path
|
|
138
|
+
.split("/")
|
|
139
|
+
.slice(3)
|
|
140
|
+
.filter((segment) => !segment.startsWith(":"));
|
|
141
|
+
if (staticTail.length > 0) return camelCaseOf(staticTail.join("-"));
|
|
142
|
+
const verb = verbOfInputSchemaName(route.schemaInput);
|
|
143
|
+
if (verb !== null) return verb;
|
|
144
|
+
const endsOnParam = route.path.split("/").at(-1)?.startsWith(":") === true;
|
|
145
|
+
switch (route.method) {
|
|
146
|
+
case "GET":
|
|
147
|
+
return endsOnParam ? "get" : "list";
|
|
148
|
+
case "POST":
|
|
149
|
+
return "create";
|
|
150
|
+
case "PUT":
|
|
151
|
+
case "PATCH":
|
|
152
|
+
return "update";
|
|
153
|
+
case "DELETE":
|
|
154
|
+
return "delete";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createManifestClient(
|
|
159
|
+
manifest: unknown,
|
|
160
|
+
schemas: Record<string, z.ZodType>,
|
|
161
|
+
options: ManifestClientOptions,
|
|
162
|
+
): ManifestClient {
|
|
163
|
+
const parsedManifest = ClientManifestViewSchema.safeParse(manifest);
|
|
164
|
+
if (!parsedManifest.success) {
|
|
165
|
+
refuseClient(
|
|
166
|
+
parsedManifest.error.issues
|
|
167
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
168
|
+
.join(" ; "),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const derived: DerivedRoute[] = [];
|
|
173
|
+
const mounted = new Set<string>();
|
|
174
|
+
for (const feature of parsedManifest.data.features) {
|
|
175
|
+
for (const route of feature.routes) {
|
|
176
|
+
if (!WELL_FORMED_ROUTE_PATH.test(route.path)) {
|
|
177
|
+
refuseClient(`feature "${feature.name}": route path "${route.path}" is malformed`);
|
|
178
|
+
}
|
|
179
|
+
for (const schemaName of [route.schemaInput, route.schemaOutput]) {
|
|
180
|
+
if (schemas[schemaName] === undefined) {
|
|
181
|
+
refuseClient(
|
|
182
|
+
`feature "${feature.name}": route ${route.method} ${route.path} declares schema "${schemaName}" which is not in the schema registry`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const key = `${route.method} ${route.path}`;
|
|
187
|
+
if (mounted.has(key)) refuseClient(`route ${key} is declared twice`);
|
|
188
|
+
mounted.add(key);
|
|
189
|
+
|
|
190
|
+
const segments = route.path.split("/").slice(2);
|
|
191
|
+
const root = segments[0];
|
|
192
|
+
if (root === undefined || root.startsWith(":")) {
|
|
193
|
+
refuseClient(`route ${key}: the first segment after /api must be static`);
|
|
194
|
+
}
|
|
195
|
+
const entry: DerivedRoute = {
|
|
196
|
+
method: route.method,
|
|
197
|
+
path: route.path,
|
|
198
|
+
root,
|
|
199
|
+
namespace: camelCaseOf(root),
|
|
200
|
+
operation: "",
|
|
201
|
+
paramNames: segments
|
|
202
|
+
.filter((segment) => segment.startsWith(":"))
|
|
203
|
+
.map((segment) => segment.slice(1)),
|
|
204
|
+
schemaInput: route.schemaInput,
|
|
205
|
+
schemaOutput: route.schemaOutput,
|
|
206
|
+
invalidates: route.invalidates ?? [],
|
|
207
|
+
};
|
|
208
|
+
entry.operation = deriveOperationName(entry);
|
|
209
|
+
derived.push(entry);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const client: ManifestClient = {};
|
|
214
|
+
for (const route of derived) {
|
|
215
|
+
const namespaceOperations = (client[route.namespace] ??= {});
|
|
216
|
+
if (namespaceOperations[route.operation] !== undefined) {
|
|
217
|
+
refuseClient(
|
|
218
|
+
`operation "${route.namespace}.${route.operation}" is derived twice (${route.method} ${route.path} collides) — the derivation must stay unambiguous`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
namespaceOperations[route.operation] = buildOperation(route, schemas, options);
|
|
222
|
+
}
|
|
223
|
+
return client;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildOperation(
|
|
227
|
+
route: DerivedRoute,
|
|
228
|
+
schemas: Record<string, z.ZodType>,
|
|
229
|
+
options: ManifestClientOptions,
|
|
230
|
+
): ApiOperation {
|
|
231
|
+
const inputSchema = schemas[route.schemaInput];
|
|
232
|
+
const outputSchema = schemas[route.schemaOutput];
|
|
233
|
+
if (inputSchema === undefined || outputSchema === undefined) {
|
|
234
|
+
refuseClient(`route ${route.method} ${route.path}: schema lookup failed`); // unreachable — checked above
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Path params consumed by name; everything else is body or query string. */
|
|
238
|
+
function splitInput(input: Record<string, unknown>): {
|
|
239
|
+
paramValues: string[];
|
|
240
|
+
rest: Record<string, unknown>;
|
|
241
|
+
} {
|
|
242
|
+
const paramValues: string[] = [];
|
|
243
|
+
const rest: Record<string, unknown> = { ...input };
|
|
244
|
+
for (const paramName of route.paramNames) {
|
|
245
|
+
const value = input[paramName];
|
|
246
|
+
if (typeof value !== "string" || value === "") {
|
|
247
|
+
throwOgygieError(
|
|
248
|
+
{
|
|
249
|
+
code: "CLIENT_MISSING_PATH_PARAM",
|
|
250
|
+
message: `Operation ${route.namespace}.${route.operation} was called without its ":${paramName}" path param.`,
|
|
251
|
+
file: null,
|
|
252
|
+
cause: `${route.method} ${route.path} needs "${paramName}", received ${JSON.stringify(value)}`,
|
|
253
|
+
suggestedFix: `Pass a non-empty string "${paramName}" in the operation input.`,
|
|
254
|
+
},
|
|
255
|
+
500,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
paramValues.push(value);
|
|
259
|
+
delete rest[paramName];
|
|
260
|
+
}
|
|
261
|
+
return { paramValues, rest };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const call = async (input: Record<string, unknown> = {}): Promise<unknown> => {
|
|
265
|
+
const { rest } = splitInput(input);
|
|
266
|
+
let resolvedPath = route.path;
|
|
267
|
+
for (const paramName of route.paramNames) {
|
|
268
|
+
resolvedPath = resolvedPath.replace(
|
|
269
|
+
`:${paramName}`,
|
|
270
|
+
encodeURIComponent(String(input[paramName])),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (METHODS_WITH_BODY.has(route.method)) {
|
|
274
|
+
return options.performRequest({
|
|
275
|
+
method: route.method,
|
|
276
|
+
path: resolvedPath,
|
|
277
|
+
body: rest,
|
|
278
|
+
outputSchema,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const searchParameters = new URLSearchParams();
|
|
282
|
+
for (const [name, value] of Object.entries(rest)) {
|
|
283
|
+
if (value !== undefined && value !== null) searchParameters.set(name, String(value));
|
|
284
|
+
}
|
|
285
|
+
const queryString = searchParameters.toString();
|
|
286
|
+
return options.performRequest({
|
|
287
|
+
method: route.method,
|
|
288
|
+
path: queryString === "" ? resolvedPath : `${resolvedPath}?${queryString}`,
|
|
289
|
+
outputSchema,
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return Object.assign(call, {
|
|
294
|
+
method: route.method,
|
|
295
|
+
path: route.path,
|
|
296
|
+
namespace: route.namespace,
|
|
297
|
+
operation: route.operation,
|
|
298
|
+
inputSchema,
|
|
299
|
+
outputSchema,
|
|
300
|
+
isMutation: route.method !== "GET",
|
|
301
|
+
queryKey: (input: Record<string, unknown> = {}): ReadonlyArray<unknown> => {
|
|
302
|
+
const { paramValues, rest } = splitInput(input);
|
|
303
|
+
// The OPERATION is part of the key: two GET routes on the same entity
|
|
304
|
+
// (…/:id, …/:id/quantities, …/:id/costs) must never share a cache entry
|
|
305
|
+
// — without it TanStack dedupes them and the later queries never fire
|
|
306
|
+
// (found by ecrans-plan, the first consumer of sibling GET routes).
|
|
307
|
+
const key: unknown[] = [route.root, route.operation, ...paramValues];
|
|
308
|
+
if (Object.keys(rest).length > 0) key.push(rest);
|
|
309
|
+
return key;
|
|
310
|
+
},
|
|
311
|
+
invalidationRoots: [route.root, ...route.invalidates],
|
|
312
|
+
});
|
|
313
|
+
}
|