@ogygie/core 0.10.3 → 0.11.1
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/README.md +67 -1
- package/package.json +11 -2
- package/src/manifest-client-react.test.ts +187 -0
- package/src/manifest-client-react.ts +196 -0
- package/src/manifest-client.test.ts +198 -59
- package/src/manifest-client.ts +190 -50
- package/src/manifest.schema.test.ts +2 -2
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@ dans `apps/`.
|
|
|
17
17
|
| `@ogygie/core/vocabulary` | Appariement des racines de vocabulaire sur les identifiants machine — les racines appartiennent au produit |
|
|
18
18
|
| `@ogygie/core/vocabulary-audit` | Jugement du gate de vocabulaire : alias interdits du code, pureté des fichiers gouvernés |
|
|
19
19
|
| `@ogygie/core/gateway` | Passerelle publique dérivée du manifeste (ci-dessous) |
|
|
20
|
-
| `@ogygie/core/client` | Client d'API front dérivé du manifeste (clés de cache
|
|
20
|
+
| `@ogygie/core/client` | Client d'API front dérivé du manifeste : transport (`fetchImpl` injecté), clés de cache et invalidations (D13) |
|
|
21
|
+
| `@ogygie/core/client-react` | Les hooks du même client — `react` et `@tanstack/react-query` en peers OPTIONNELS, chargés dynamiquement (D13) |
|
|
21
22
|
|
|
22
23
|
## Le test du vocabulaire couvre TOUT le core
|
|
23
24
|
|
|
@@ -142,3 +143,68 @@ et les `Set-Cookie` de l'amont, eux, traversent **octet pour octet** — une
|
|
|
142
143
|
| `GATEWAY_ROUTE_UNKNOWN` | 404 | Aucune route déclarée ne correspond (et aucun fallback SPA applicable). |
|
|
143
144
|
| `GATEWAY_UPSTREAM_UNAVAILABLE` | 502 | L'amont est injoignable. Aucun détail interne ne fuit : ni URL privée, ni stack. |
|
|
144
145
|
| `GATEWAY_UPSTREAM_TIMEOUT` | 504 | L'amont n'a pas répondu dans `timeoutMs`. |
|
|
146
|
+
|
|
147
|
+
## Client d'API dérivé du manifeste — `@ogygie/core/client` et `/client-react`
|
|
148
|
+
|
|
149
|
+
`createManifestClient(manifest, schemas, options)` est le **pendant front** de
|
|
150
|
+
la passerelle (spec `manifest-client`, D13) : chaque route déclarée devient une
|
|
151
|
+
méthode, sous le namespace de sa racine de chemin. Une route absente du
|
|
152
|
+
manifeste n'existe pas côté client. Les **clés de cache** et les
|
|
153
|
+
**invalidations** sont DÉRIVÉES — un produit n'écrit plus une seule clé à la
|
|
154
|
+
main, ni un seul `fetch`.
|
|
155
|
+
|
|
156
|
+
### Usage minimal
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { createManifestClient } from "@ogygie/core/client";
|
|
160
|
+
import { createApiHooks } from "@ogygie/core/client-react";
|
|
161
|
+
import * as widgetSchemas from "@my-product/api/features/widgets/schemas";
|
|
162
|
+
|
|
163
|
+
const api = createManifestClient(manifest, { ...widgetSchemas }, {
|
|
164
|
+
fetchImpl: globalThis.fetch, // effet injecté, jamais importé au fond d'un helper
|
|
165
|
+
validateResponses: import.meta.env.DEV ? "always" : "never",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
api.widgets.list(); // GET /api/widgets
|
|
169
|
+
api.widgets.get({ widgetId }); // GET /api/widgets/:widgetId
|
|
170
|
+
api.widgets.list.queryKey(); // ["widgets", "list"]
|
|
171
|
+
api.widgets.create.invalidationRoots; // ["widgets", ...invalidates du manifeste]
|
|
172
|
+
|
|
173
|
+
// Les hooks : peers OPTIONNELS, chargés dynamiquement (refus structuré si absents).
|
|
174
|
+
const { useApiQuery, useApiMutation } = await createApiHooks(queryClient);
|
|
175
|
+
useApiQuery(api.widgets.list);
|
|
176
|
+
useApiMutation(api.widgets.create); // invalide ["widgets"] + les racines déclarées
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Dérivations
|
|
180
|
+
|
|
181
|
+
| Élément | Règle |
|
|
182
|
+
|---|---|
|
|
183
|
+
| Namespace | Premier segment après `/api`, camelCasé (`/api/work-orders/...` → `client.workOrders`). |
|
|
184
|
+
| Opération | Segments statiques suivants (`.../:id/archive` → `archive`) ; sinon le VERBE du nom du schéma d'entrée (`SearchReportsInputSchema` → `search`) ; sinon la méthode (`GET` → `list`/`get`, `POST` → `create`, `PATCH`/`PUT` → `update`, `DELETE` → `delete`). |
|
|
185
|
+
| Clé de cache | `[racine, opération, ...valeurs des params, reste de l'entrée]`. L'opération EN FAIT PARTIE : deux GET voisins sur la même entité partageraient sinon une entrée de cache, et le second ne partirait jamais. |
|
|
186
|
+
| Invalidation | Racine propre + les racines déclarées dans `invalidates` de la route (vérifiées par `verify-manifest` : une cible fantôme casse le build). |
|
|
187
|
+
|
|
188
|
+
### Options
|
|
189
|
+
|
|
190
|
+
| Option | Requis | Effet |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `fetchImpl` | oui | L'effet réseau, injecté (`globalThis.fetch`, ou un faux en test). Le module n'appelle jamais le réseau autrement. |
|
|
193
|
+
| `validateResponses` | oui | `"always"` reparse chaque réponse avec son `schemaOutput` (dev : un contrat cassé échoue ICI) ; `"never"` = passthrough. Le PRODUIT choisit par environnement — le core ne lit aucune variable d'env. |
|
|
194
|
+
| `baseUrl` | non | Préfixe du chemin dérivé ; vide par défaut, le front appelle son api en relatif. |
|
|
195
|
+
|
|
196
|
+
### Erreurs
|
|
197
|
+
|
|
198
|
+
| Code | Quand |
|
|
199
|
+
|---|---|
|
|
200
|
+
| `MANIFEST_CLIENT_INVALID_MANIFEST` | **À la construction** : manifeste illisible, chemin malformé, route déclarée deux fois. |
|
|
201
|
+
| `MANIFEST_CLIENT_UNKNOWN_SCHEMA` | **À la construction** : un `schemaInput`/`schemaOutput` du manifeste manque au registre. |
|
|
202
|
+
| `MANIFEST_CLIENT_AMBIGUOUS_OPERATION` | **À la construction** : deux routes dérivent le même nom d'opération — jamais un écrasement silencieux. |
|
|
203
|
+
| `MANIFEST_CLIENT_MISSING_PATH_PARAM` | Appel sans le param de chemin que la route exige, avant tout appel réseau. |
|
|
204
|
+
| `NETWORK_ERROR` | `fetchImpl` a rejeté, ou l'api a échoué SANS corps `OgygieError` (statut conservé). |
|
|
205
|
+
| `CONTRACT_BROKEN` | Réponse non-JSON, ou violant `schemaOutput` en `validateResponses: "always"`. |
|
|
206
|
+
| `CLIENT_REACT_PEER_MISSING` | `client-react` sans `@tanstack/react-query` installé : refus nommant la commande, jamais une erreur de résolution de module. |
|
|
207
|
+
|
|
208
|
+
Un corps `OgygieError` renvoyé par l'api, lui, **ressort intact** (code,
|
|
209
|
+
`details`, `suggestedFix`, statut) : c'est la seule chose sur laquelle un écran
|
|
210
|
+
puisse agir.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,15 +33,18 @@
|
|
|
33
33
|
"./shared-tables": "./src/shared-tables.ts",
|
|
34
34
|
"./test-kit": "./src/test-kit.ts",
|
|
35
35
|
"./gateway": "./src/manifest-gateway.ts",
|
|
36
|
-
"./client": "./src/manifest-client.ts"
|
|
36
|
+
"./client": "./src/manifest-client.ts",
|
|
37
|
+
"./client-react": "./src/manifest-client-react.ts"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
40
|
"@electric-sql/pglite": "^0.3.0",
|
|
40
41
|
"@fastify/helmet": "^13.0.0",
|
|
41
42
|
"@fastify/rate-limit": "^10.0.0",
|
|
42
43
|
"@fastify/static": "^10.1.2",
|
|
44
|
+
"@tanstack/react-query": "^5.0.0",
|
|
43
45
|
"drizzle-orm": "^0.45.2",
|
|
44
46
|
"fastify": "^5.0.0",
|
|
47
|
+
"react": "^19.0.0",
|
|
45
48
|
"zod": "^4.0.0"
|
|
46
49
|
},
|
|
47
50
|
"peerDependenciesMeta": {
|
|
@@ -57,11 +60,17 @@
|
|
|
57
60
|
"@fastify/static": {
|
|
58
61
|
"optional": true
|
|
59
62
|
},
|
|
63
|
+
"@tanstack/react-query": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
60
66
|
"drizzle-orm": {
|
|
61
67
|
"optional": true
|
|
62
68
|
},
|
|
63
69
|
"fastify": {
|
|
64
70
|
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"react": {
|
|
73
|
+
"optional": true
|
|
65
74
|
}
|
|
66
75
|
},
|
|
67
76
|
"devDependencies": {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Proves the behaviors of specs/manifest-client.spec.md §2: the hooks
|
|
3
|
+
* carry the DERIVED cache key and the invalidations a successful mutation
|
|
4
|
+
* implies — a screen writes neither — and the optional peer is refused
|
|
5
|
+
* STRUCTURALLY when it is missing (this repo deliberately does not install it,
|
|
6
|
+
* so that last test is the real thing, not a simulation).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { OgygieFailure } from "./error.schema.js";
|
|
11
|
+
import {
|
|
12
|
+
createManifestClient,
|
|
13
|
+
type ApiOperation,
|
|
14
|
+
type ManifestClient,
|
|
15
|
+
} from "./manifest-client.js";
|
|
16
|
+
import {
|
|
17
|
+
createApiHooks,
|
|
18
|
+
loadReactQuery,
|
|
19
|
+
staleQueryKeysOf,
|
|
20
|
+
type ApiMutationOptions,
|
|
21
|
+
type ApiQueryOptions,
|
|
22
|
+
type ReactQueryLike,
|
|
23
|
+
} from "./manifest-client-react.js";
|
|
24
|
+
|
|
25
|
+
/** Reads a derived operation, or fails the test — never a cast. */
|
|
26
|
+
function operationOf(client: ManifestClient, namespace: string, operation: string): ApiOperation {
|
|
27
|
+
const found = client[namespace]?.[operation];
|
|
28
|
+
if (found === undefined) throw new Error(`fixture client has no ${namespace}.${operation}`);
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fixtureClient() {
|
|
33
|
+
const schemas: Record<string, z.ZodType> = {
|
|
34
|
+
ListWidgetsInputSchema: z.record(z.string(), z.unknown()),
|
|
35
|
+
ListWidgetsOutputSchema: z.record(z.string(), z.unknown()),
|
|
36
|
+
CreateWidgetInputSchema: z.record(z.string(), z.unknown()),
|
|
37
|
+
CreateWidgetOutputSchema: z.record(z.string(), z.unknown()),
|
|
38
|
+
};
|
|
39
|
+
const manifest = {
|
|
40
|
+
features: [
|
|
41
|
+
{
|
|
42
|
+
name: "widgets",
|
|
43
|
+
routes: [
|
|
44
|
+
{
|
|
45
|
+
method: "GET",
|
|
46
|
+
path: "/api/widgets",
|
|
47
|
+
schemaInput: "ListWidgetsInputSchema",
|
|
48
|
+
schemaOutput: "ListWidgetsOutputSchema",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
method: "POST",
|
|
52
|
+
path: "/api/widgets",
|
|
53
|
+
schemaInput: "CreateWidgetInputSchema",
|
|
54
|
+
schemaOutput: "CreateWidgetOutputSchema",
|
|
55
|
+
invalidates: ["work-orders"],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
const calls: string[] = [];
|
|
62
|
+
const client = createManifestClient(manifest, schemas, {
|
|
63
|
+
fetchImpl: async (url) => {
|
|
64
|
+
calls.push(url);
|
|
65
|
+
return { ok: true, status: 200, text: async () => JSON.stringify({ ok: true }) };
|
|
66
|
+
},
|
|
67
|
+
validateResponses: "never",
|
|
68
|
+
});
|
|
69
|
+
return { client, calls };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The peer, faked: it only has to record what the hooks hand it. */
|
|
73
|
+
function fakeReactQuery(): {
|
|
74
|
+
module: ReactQueryLike;
|
|
75
|
+
queries: ApiQueryOptions[];
|
|
76
|
+
mutations: ApiMutationOptions[];
|
|
77
|
+
} {
|
|
78
|
+
const queries: ApiQueryOptions[] = [];
|
|
79
|
+
const mutations: ApiMutationOptions[] = [];
|
|
80
|
+
return {
|
|
81
|
+
queries,
|
|
82
|
+
mutations,
|
|
83
|
+
module: {
|
|
84
|
+
useQuery: (options: ApiQueryOptions) => {
|
|
85
|
+
queries.push(options);
|
|
86
|
+
return { data: undefined, isPending: true, isError: false, error: null };
|
|
87
|
+
},
|
|
88
|
+
useMutation: (options: ApiMutationOptions) => {
|
|
89
|
+
mutations.push(options);
|
|
90
|
+
return { mutate: () => undefined, isPending: false };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function recordingQueryClient(): {
|
|
97
|
+
queryClient: { invalidateQueries: (filters: { queryKey: ReadonlyArray<unknown> }) => unknown };
|
|
98
|
+
invalidated: ReadonlyArray<unknown>[];
|
|
99
|
+
} {
|
|
100
|
+
const invalidated: ReadonlyArray<unknown>[] = [];
|
|
101
|
+
return {
|
|
102
|
+
invalidated,
|
|
103
|
+
queryClient: {
|
|
104
|
+
invalidateQueries: (filters) => {
|
|
105
|
+
invalidated.push(filters.queryKey);
|
|
106
|
+
return undefined;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("manifest-client-react — hooks dérivés (spec manifest-client §2)", () => {
|
|
113
|
+
it("useApiQuery lit par la clé DÉRIVÉE et appelle l'opération, sans une ligne au site d'appel", async () => {
|
|
114
|
+
const { client, calls } = fixtureClient();
|
|
115
|
+
const peer = fakeReactQuery();
|
|
116
|
+
const { queryClient } = recordingQueryClient();
|
|
117
|
+
const hooks = await createApiHooks(queryClient, peer.module);
|
|
118
|
+
|
|
119
|
+
hooks.useApiQuery(operationOf(client, "widgets", "list"), { q: "ab" });
|
|
120
|
+
|
|
121
|
+
expect(peer.queries[0]?.queryKey).toEqual(["widgets", "list", { q: "ab" }]);
|
|
122
|
+
await peer.queries[0]?.queryFn();
|
|
123
|
+
expect(calls).toEqual(["/api/widgets?q=ab"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("ÉTANT DONNÉ une mutation réussie ALORS sa racine est invalidée sans une ligne dans le code appelant", async () => {
|
|
127
|
+
const { client } = fixtureClient();
|
|
128
|
+
const peer = fakeReactQuery();
|
|
129
|
+
const { queryClient, invalidated } = recordingQueryClient();
|
|
130
|
+
const hooks = await createApiHooks(queryClient, peer.module);
|
|
131
|
+
|
|
132
|
+
hooks.useApiMutation(operationOf(client, "widgets", "create"));
|
|
133
|
+
await peer.mutations[0]?.onSuccess();
|
|
134
|
+
|
|
135
|
+
// Own root FIRST, then the cross-invalidation the manifest declares.
|
|
136
|
+
expect(invalidated).toEqual([["widgets"], ["work-orders"]]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ÉTANT DONNÉ une route déclarant invalidates ALORS la dépendance croisée est LUE, jamais devinée", () => {
|
|
140
|
+
const { client } = fixtureClient();
|
|
141
|
+
expect(staleQueryKeysOf(operationOf(client, "widgets", "create"))).toEqual([
|
|
142
|
+
["widgets"],
|
|
143
|
+
["work-orders"],
|
|
144
|
+
]);
|
|
145
|
+
expect(staleQueryKeysOf(operationOf(client, "widgets", "list"))).toEqual([["widgets"]]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("ÉTANT DONNÉ client-react chargé sans @tanstack/react-query ALORS refus structuré nommant la commande", async () => {
|
|
149
|
+
// The absence is SIMULATED at the importer, not at the disk: the framework
|
|
150
|
+
// repo installs the peer the day it declares it (pnpm resolves declared
|
|
151
|
+
// peers), and a behavior may not stop being proven because the machine
|
|
152
|
+
// happens to satisfy it.
|
|
153
|
+
let thrown: unknown;
|
|
154
|
+
try {
|
|
155
|
+
await loadReactQuery(() =>
|
|
156
|
+
Promise.reject(new Error("Cannot find package '@tanstack/react-query'")),
|
|
157
|
+
);
|
|
158
|
+
} catch (failure) {
|
|
159
|
+
thrown = failure;
|
|
160
|
+
}
|
|
161
|
+
expect(thrown).toBeInstanceOf(OgygieFailure);
|
|
162
|
+
const failure = thrown as OgygieFailure;
|
|
163
|
+
expect(failure.error.code).toBe("CLIENT_REACT_PEER_MISSING");
|
|
164
|
+
expect(failure.error.suggestedFix).toContain("pnpm add react @tanstack/react-query");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("ÉTANT DONNÉ le peer installé ALORS l'import dynamique rend de vrais hooks", async () => {
|
|
168
|
+
const { queryClient } = recordingQueryClient();
|
|
169
|
+
const hooks = await createApiHooks(queryClient);
|
|
170
|
+
expect(typeof hooks.useApiQuery).toBe("function");
|
|
171
|
+
expect(typeof hooks.useApiMutation).toBe("function");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("MUTATION : un module de peer sans hooks est refusé comme un peer absent", async () => {
|
|
175
|
+
const { queryClient } = recordingQueryClient();
|
|
176
|
+
let thrown: unknown;
|
|
177
|
+
try {
|
|
178
|
+
// The injected module is the peer's shape, so an empty one must be
|
|
179
|
+
// refused HERE — otherwise the failure would surface as "undefined is
|
|
180
|
+
// not a function" inside a render.
|
|
181
|
+
await createApiHooks(queryClient, { useQuery: undefined, useMutation: undefined });
|
|
182
|
+
} catch (failure) {
|
|
183
|
+
thrown = failure;
|
|
184
|
+
}
|
|
185
|
+
expect((thrown as OgygieFailure | undefined)?.error.code).toBe("CLIENT_REACT_PEER_MISSING");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent React hooks over the manifest-derived client (D13, entry point
|
|
3
|
+
* `@ogygie/core/client-react`) — SEPARATE from `@ogygie/core/client` so the
|
|
4
|
+
* pure module stays consumable without React: a script, a test, another
|
|
5
|
+
* framework. A front feature no longer writes a `queries.ts`: the hook
|
|
6
|
+
* carries the derived cache key, the call, and the invalidations a successful
|
|
7
|
+
* mutation implies (own root + the roots the manifest declares).
|
|
8
|
+
*
|
|
9
|
+
* `react` and `@tanstack/react-query` are OPTIONAL peers of `@ogygie/core`
|
|
10
|
+
* (D1): the product that uses this module already owns them. They are loaded
|
|
11
|
+
* by DYNAMIC import, the doctrine already held for the studio and the MCP
|
|
12
|
+
* server (D7/D9) — which is exactly what makes the refusal STRUCTURED
|
|
13
|
+
* (CLIENT_REACT_PEER_MISSING, naming the install command) instead of a module
|
|
14
|
+
* resolution error nobody can act on. Hence an ASYNC factory. The peer may
|
|
15
|
+
* also be INJECTED (principle 5: a declared effect is passed in), which is
|
|
16
|
+
* what the tests do and what a product that prefers the explicit can do.
|
|
17
|
+
*
|
|
18
|
+
* This module imports NO react type: it declares the SHAPES it needs and
|
|
19
|
+
* nothing else — the framework repo does not install the peers, so a static
|
|
20
|
+
* import would make its own typecheck depend on a package it refuses to own.
|
|
21
|
+
*/
|
|
22
|
+
import type { ApiOperation } from "./manifest-client.js";
|
|
23
|
+
import { throwOgygieError } from "./error.schema.js";
|
|
24
|
+
|
|
25
|
+
/** The install command a refusal names — one place, so it cannot drift. */
|
|
26
|
+
const PEER_INSTALL_COMMAND = "pnpm add react @tanstack/react-query";
|
|
27
|
+
|
|
28
|
+
/** What this module needs from a QueryClient: making derived keys stale. */
|
|
29
|
+
export interface QueryClientLike {
|
|
30
|
+
invalidateQueries: (filters: { queryKey: ReadonlyArray<unknown> }) => unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ApiQueryOptions {
|
|
34
|
+
queryKey: ReadonlyArray<unknown>;
|
|
35
|
+
queryFn: () => Promise<unknown>;
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ApiMutationOptions {
|
|
40
|
+
mutationKey: ReadonlyArray<unknown>;
|
|
41
|
+
mutationFn: (input?: Record<string, unknown>) => Promise<unknown>;
|
|
42
|
+
onSuccess: () => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The two hooks of the peer this module builds upon — nothing else is used. */
|
|
46
|
+
export interface ReactQueryLike {
|
|
47
|
+
useQuery: (options: ApiQueryOptions) => unknown;
|
|
48
|
+
useMutation: (options: ApiMutationOptions) => unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The SUBSET of a react-query result a screen composes with. The peer returns
|
|
53
|
+
* more; declaring what we consume keeps this module free of its types (and
|
|
54
|
+
* honest about what it promises).
|
|
55
|
+
*/
|
|
56
|
+
export interface ApiQueryResult<TData> {
|
|
57
|
+
data: TData | undefined;
|
|
58
|
+
isPending: boolean;
|
|
59
|
+
isError: boolean;
|
|
60
|
+
error: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ApiMutationResult<TData> {
|
|
64
|
+
mutate: (input?: Record<string, unknown>) => void;
|
|
65
|
+
mutateAsync: (input?: Record<string, unknown>) => Promise<TData>;
|
|
66
|
+
data: TData | undefined;
|
|
67
|
+
isPending: boolean;
|
|
68
|
+
isError: boolean;
|
|
69
|
+
error: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ApiHooks {
|
|
73
|
+
/** Reads through the derived cache key — nothing to name, nothing to invent. */
|
|
74
|
+
useApiQuery: <TData = unknown>(
|
|
75
|
+
operation: ApiOperation,
|
|
76
|
+
input?: Record<string, unknown>,
|
|
77
|
+
options?: { enabled?: boolean },
|
|
78
|
+
) => ApiQueryResult<TData>;
|
|
79
|
+
/** Writes, then makes stale what the manifest says this route makes stale. */
|
|
80
|
+
useApiMutation: <TData = unknown>(operation: ApiOperation) => ApiMutationResult<TData>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The cache keys a successful mutation makes stale: its own root, plus every
|
|
85
|
+
* root the route declares in `invalidates`. DERIVED, never written in a
|
|
86
|
+
* screen — a forgotten invalidation never fails, it serves stale data.
|
|
87
|
+
*/
|
|
88
|
+
export function staleQueryKeysOf(operation: ApiOperation): ReadonlyArray<ReadonlyArray<string>> {
|
|
89
|
+
return operation.invalidationRoots.map((root) => [root]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Applies that plan. Exposed so a consumer outside React keeps the behavior. */
|
|
93
|
+
export async function invalidateAfterMutation(
|
|
94
|
+
queryClient: QueryClientLike,
|
|
95
|
+
operation: ApiOperation,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
for (const queryKey of staleQueryKeysOf(operation)) {
|
|
98
|
+
await queryClient.invalidateQueries({ queryKey });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function looksLikeReactQuery(value: unknown): value is ReactQueryLike {
|
|
103
|
+
if (typeof value !== "object" || value === null) return false;
|
|
104
|
+
const candidate = value as Partial<ReactQueryLike>;
|
|
105
|
+
return typeof candidate.useQuery === "function" && typeof candidate.useMutation === "function";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The peer arrives from OUTSIDE the type system — a dynamic import, or the
|
|
110
|
+
* module a product hands over. It is therefore validated, never trusted: a
|
|
111
|
+
* module without the two hooks is refused HERE, where the refusal can name
|
|
112
|
+
* the fix, instead of surfacing as "undefined is not a function" in a render.
|
|
113
|
+
*/
|
|
114
|
+
function asReactQuery(loaded: unknown, origin: string): ReactQueryLike {
|
|
115
|
+
if (!looksLikeReactQuery(loaded)) {
|
|
116
|
+
throwOgygieError(
|
|
117
|
+
{
|
|
118
|
+
code: "CLIENT_REACT_PEER_MISSING",
|
|
119
|
+
message: "@ogygie/core/client-react received no usable @tanstack/react-query module.",
|
|
120
|
+
file: null,
|
|
121
|
+
cause: `${origin}: the module carries neither useQuery nor useMutation as functions`,
|
|
122
|
+
suggestedFix: `Check the installed peer (${PEER_INSTALL_COMMAND}) — this module only uses useQuery and useMutation.`,
|
|
123
|
+
},
|
|
124
|
+
500,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return loaded;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Loads the optional peer, or REFUSES with the command to run. The default
|
|
132
|
+
* importer keeps the specifier LITERAL so a bundler can see it (a computed one
|
|
133
|
+
* would break the browser build); it is a parameter because the load is an
|
|
134
|
+
* effect like any other (principle 5) — that is how the refusal is proven in a
|
|
135
|
+
* repo where the peer happens to be installed.
|
|
136
|
+
*/
|
|
137
|
+
export async function loadReactQuery(
|
|
138
|
+
importPeer: () => Promise<unknown> = () => import("@tanstack/react-query"),
|
|
139
|
+
): Promise<ReactQueryLike> {
|
|
140
|
+
let loaded: unknown;
|
|
141
|
+
try {
|
|
142
|
+
loaded = await importPeer();
|
|
143
|
+
} catch (thrown) {
|
|
144
|
+
throwOgygieError(
|
|
145
|
+
{
|
|
146
|
+
code: "CLIENT_REACT_PEER_MISSING",
|
|
147
|
+
message: "@ogygie/core/client-react needs its optional peer @tanstack/react-query.",
|
|
148
|
+
file: null,
|
|
149
|
+
cause: `dynamic import of "@tanstack/react-query" failed: ${String(thrown)}`,
|
|
150
|
+
suggestedFix: `Install the peers in the project that uses these hooks: ${PEER_INSTALL_COMMAND} — or pass the module to createApiHooks yourself.`,
|
|
151
|
+
},
|
|
152
|
+
500,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return asReactQuery(loaded, "dynamic import of @tanstack/react-query");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* THE way a product gets its hooks. `reactQuery` is optional: absent, the
|
|
160
|
+
* optional peer is loaded dynamically (and its absence refused structurally).
|
|
161
|
+
*/
|
|
162
|
+
export async function createApiHooks(
|
|
163
|
+
queryClient: QueryClientLike,
|
|
164
|
+
reactQuery?: unknown,
|
|
165
|
+
): Promise<ApiHooks> {
|
|
166
|
+
const hooks =
|
|
167
|
+
reactQuery === undefined
|
|
168
|
+
? await loadReactQuery()
|
|
169
|
+
: asReactQuery(reactQuery, "module injected into createApiHooks");
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
useApiQuery: <TData = unknown>(
|
|
173
|
+
operation: ApiOperation,
|
|
174
|
+
input?: Record<string, unknown>,
|
|
175
|
+
options?: { enabled?: boolean },
|
|
176
|
+
): ApiQueryResult<TData> => {
|
|
177
|
+
const queryOptions: ApiQueryOptions = {
|
|
178
|
+
queryKey: operation.queryKey(input),
|
|
179
|
+
queryFn: () => operation(input),
|
|
180
|
+
...(options?.enabled === undefined ? {} : { enabled: options.enabled }),
|
|
181
|
+
};
|
|
182
|
+
// The peer returns its own richer result; we promise only the subset we
|
|
183
|
+
// declared, and the response was already validated by the client.
|
|
184
|
+
return hooks.useQuery(queryOptions) as ApiQueryResult<TData>;
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
useApiMutation: <TData = unknown>(operation: ApiOperation): ApiMutationResult<TData> => {
|
|
188
|
+
const mutationOptions: ApiMutationOptions = {
|
|
189
|
+
mutationKey: [operation.namespace, operation.operation],
|
|
190
|
+
mutationFn: (input?: Record<string, unknown>) => operation(input),
|
|
191
|
+
onSuccess: () => invalidateAfterMutation(queryClient, operation),
|
|
192
|
+
};
|
|
193
|
+
return hooks.useMutation(mutationOptions) as ApiMutationResult<TData>;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Proves the manifest-client
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* @intent Proves the behaviors of specs/manifest-client.spec.md §1: the client
|
|
3
|
+
* exposes exactly the declared routes (mutation test: a removed route
|
|
4
|
+
* disappears), derives namespaces, operations, cache keys and invalidation
|
|
5
|
+
* roots, refuses loudly what it cannot derive, and speaks ONE error language
|
|
6
|
+
* on the wire (OgygieError untouched, everything else NETWORK_ERROR or
|
|
7
|
+
* CONTRACT_BROKEN). Fixtures are deliberately vocabulary-neutral — the
|
|
8
|
+
* vocabulary gate scans this file too.
|
|
8
9
|
*/
|
|
9
10
|
import { describe, expect, it } from "vitest";
|
|
10
11
|
import { z } from "zod";
|
|
11
|
-
import { OgygieFailure } from "./error.schema.js";
|
|
12
|
+
import { OgygieFailure, ogygieError } from "./error.schema.js";
|
|
12
13
|
import {
|
|
13
14
|
createManifestClient,
|
|
14
|
-
type
|
|
15
|
+
type ApiOperation,
|
|
16
|
+
type HttpResponseLike,
|
|
17
|
+
type ManifestClient,
|
|
15
18
|
} from "./manifest-client.js";
|
|
16
19
|
|
|
20
|
+
/** Reads a derived operation, or fails the test — never a cast. */
|
|
21
|
+
function operationOf(client: ManifestClient, namespace: string, operation: string): ApiOperation {
|
|
22
|
+
const found = client[namespace]?.[operation];
|
|
23
|
+
if (found === undefined) throw new Error(`fixture client has no ${namespace}.${operation}`);
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
const SCHEMA_NAMES = [
|
|
18
28
|
"ListWidgetsInputSchema",
|
|
19
29
|
"ListWidgetsOutputSchema",
|
|
@@ -22,7 +32,6 @@ const SCHEMA_NAMES = [
|
|
|
22
32
|
"UpdateWidgetInputSchema",
|
|
23
33
|
"UpdateWidgetOutputSchema",
|
|
24
34
|
"WidgetParamsSchema",
|
|
25
|
-
"GetWidgetOutputSchema",
|
|
26
35
|
"ArchiveWidgetOutputSchema",
|
|
27
36
|
"ListWorkOrdersInputSchema",
|
|
28
37
|
"ListWorkOrdersOutputSchema",
|
|
@@ -34,9 +43,13 @@ const SCHEMA_NAMES = [
|
|
|
34
43
|
|
|
35
44
|
/** One distinct schema instance per name, so identity can be asserted. */
|
|
36
45
|
function schemaRegistry(): Record<string, z.ZodType> {
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
return {
|
|
47
|
+
...Object.fromEntries(
|
|
48
|
+
SCHEMA_NAMES.map((name) => [name, z.record(z.string(), z.unknown())]),
|
|
49
|
+
),
|
|
50
|
+
// One STRICT contract, so response validation has something to refuse.
|
|
51
|
+
GetWidgetOutputSchema: z.object({ widgetId: z.string() }),
|
|
52
|
+
};
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
interface RouteOverride {
|
|
@@ -143,14 +156,49 @@ function fixtureManifest(options?: {
|
|
|
143
156
|
};
|
|
144
157
|
}
|
|
145
158
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
interface RecordedRequest {
|
|
160
|
+
url: string;
|
|
161
|
+
method: string;
|
|
162
|
+
headers: Record<string, string>;
|
|
163
|
+
body?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A response the fake transport hands back, built like a real one would be. */
|
|
167
|
+
function response(status: number, rawBody: string): HttpResponseLike {
|
|
168
|
+
return { ok: status < 400, status, text: async () => rawBody };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function jsonResponse(status: number, body: unknown): HttpResponseLike {
|
|
172
|
+
return response(status, JSON.stringify(body));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildClient(options?: {
|
|
176
|
+
manifest?: unknown;
|
|
177
|
+
schemas?: Record<string, z.ZodType>;
|
|
178
|
+
validateResponses?: "always" | "never";
|
|
179
|
+
baseUrl?: string;
|
|
180
|
+
answer?: (request: RecordedRequest) => HttpResponseLike;
|
|
181
|
+
}): {
|
|
182
|
+
client: ManifestClient;
|
|
183
|
+
requests: RecordedRequest[];
|
|
184
|
+
schemas: Record<string, z.ZodType>;
|
|
185
|
+
} {
|
|
186
|
+
const requests: RecordedRequest[] = [];
|
|
187
|
+
const schemas = options?.schemas ?? schemaRegistry();
|
|
188
|
+
const answer = options?.answer ?? (() => jsonResponse(200, { ok: true }));
|
|
189
|
+
const client = createManifestClient(options?.manifest ?? fixtureManifest(), schemas, {
|
|
190
|
+
fetchImpl: async (url, init) => {
|
|
191
|
+
const recorded: RecordedRequest = {
|
|
192
|
+
url,
|
|
193
|
+
method: init.method,
|
|
194
|
+
headers: init.headers,
|
|
195
|
+
...(init.body === undefined ? {} : { body: init.body }),
|
|
196
|
+
};
|
|
197
|
+
requests.push(recorded);
|
|
198
|
+
return answer(recorded);
|
|
153
199
|
},
|
|
200
|
+
validateResponses: options?.validateResponses ?? "always",
|
|
201
|
+
...(options?.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
|
|
154
202
|
});
|
|
155
203
|
return { client, requests, schemas };
|
|
156
204
|
}
|
|
@@ -164,7 +212,17 @@ function failureCodeOf(run: () => unknown): string | null {
|
|
|
164
212
|
}
|
|
165
213
|
}
|
|
166
214
|
|
|
167
|
-
|
|
215
|
+
async function failureOf(run: () => Promise<unknown>): Promise<OgygieFailure> {
|
|
216
|
+
try {
|
|
217
|
+
await run();
|
|
218
|
+
} catch (thrown) {
|
|
219
|
+
if (thrown instanceof OgygieFailure) return thrown;
|
|
220
|
+
throw thrown;
|
|
221
|
+
}
|
|
222
|
+
throw new Error("the call was expected to fail, and it did not");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
describe("manifest-client — dérivation (spec manifest-client §1)", () => {
|
|
168
226
|
it("ÉTANT DONNÉ le manifeste QUAND le client est construit ALORS il expose exactement les routes déclarées", () => {
|
|
169
227
|
const { client } = buildClient();
|
|
170
228
|
expect(Object.keys(client).sort()).toEqual(["reports", "widgets", "workOrders"]);
|
|
@@ -180,11 +238,17 @@ describe("manifest-client — dérivation (spec front-derivation §1)", () => {
|
|
|
180
238
|
});
|
|
181
239
|
|
|
182
240
|
it("MUTATION : une route retirée du manifeste disparaît du client", () => {
|
|
183
|
-
const { client } = buildClient(fixtureManifest({ dropArchiveRoute: true }));
|
|
241
|
+
const { client } = buildClient({ manifest: fixtureManifest({ dropArchiveRoute: true }) });
|
|
184
242
|
expect(client["widgets"]?.["archive"]).toBeUndefined();
|
|
185
243
|
expect(client["widgets"]?.["create"]).toBeDefined();
|
|
186
244
|
});
|
|
187
245
|
|
|
246
|
+
it("chaque opération porte les schémas du registre, sans annotation manuelle", () => {
|
|
247
|
+
const { client, schemas } = buildClient();
|
|
248
|
+
expect(client["reports"]?.["search"]?.outputSchema).toBe(schemas["SearchReportsOutputSchema"]);
|
|
249
|
+
expect(client["reports"]?.["search"]?.inputSchema).toBe(schemas["SearchReportsInputSchema"]);
|
|
250
|
+
});
|
|
251
|
+
|
|
188
252
|
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
253
|
const { client } = buildClient();
|
|
190
254
|
expect(client["widgets"]?.["list"]?.queryKey()).toEqual(["widgets", "list"]);
|
|
@@ -203,7 +267,7 @@ describe("manifest-client — dérivation (spec front-derivation §1)", () => {
|
|
|
203
267
|
expect(client["workOrders"]?.["list"]?.queryKey()).toEqual(["work-orders", "list"]);
|
|
204
268
|
});
|
|
205
269
|
|
|
206
|
-
it("RÉGRESSION
|
|
270
|
+
it("RÉGRESSION : deux GET voisins sur la même entité ne partagent JAMAIS une clé de cache", () => {
|
|
207
271
|
// Without the operation in the key, widgets.get and a sibling
|
|
208
272
|
// GET /api/widgets/:widgetId/child would collide — TanStack would dedupe
|
|
209
273
|
// them and the second query would silently never fire.
|
|
@@ -224,64 +288,139 @@ describe("manifest-client — dérivation (spec front-derivation §1)", () => {
|
|
|
224
288
|
expect(client["widgets"]?.["list"]?.isMutation).toBe(false);
|
|
225
289
|
});
|
|
226
290
|
|
|
291
|
+
it("MUTATION : un schéma absent du registre refuse la construction", () => {
|
|
292
|
+
const schemas = schemaRegistry();
|
|
293
|
+
delete schemas["CreateWidgetInputSchema"];
|
|
294
|
+
expect(failureCodeOf(() => buildClient({ schemas }))).toBe("MANIFEST_CLIENT_UNKNOWN_SCHEMA");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("MUTATION : deux routes qui dérivent la même opération refusent la construction", () => {
|
|
298
|
+
const manifest = fixtureManifest({
|
|
299
|
+
extraWidgetRoutes: [
|
|
300
|
+
{
|
|
301
|
+
method: "PUT",
|
|
302
|
+
path: "/api/widgets/:widgetId",
|
|
303
|
+
description: "Collides with PATCH: same derived operation name.",
|
|
304
|
+
schemaInput: "UpdateWidgetInputSchema",
|
|
305
|
+
schemaOutput: "UpdateWidgetOutputSchema",
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
expect(failureCodeOf(() => buildClient({ manifest }))).toBe(
|
|
310
|
+
"MANIFEST_CLIENT_AMBIGUOUS_OPERATION",
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("MUTATION : un manifeste invalide refuse la construction au lieu de dériver un client menteur", () => {
|
|
315
|
+
expect(failureCodeOf(() => buildClient({ manifest: { version: 1 } }))).toBe(
|
|
316
|
+
"MANIFEST_CLIENT_INVALID_MANIFEST",
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("manifest-client — transport (spec manifest-client §1)", () => {
|
|
227
322
|
it("un appel GET envoie le reste de l'entrée en query string, sans body", async () => {
|
|
228
|
-
const { client, requests
|
|
323
|
+
const { client, requests } = buildClient({ validateResponses: "never" });
|
|
229
324
|
await client["reports"]?.["search"]?.({ q: "ab", limit: 20 });
|
|
230
325
|
expect(requests).toEqual([
|
|
231
326
|
{
|
|
327
|
+
url: "/api/reports?q=ab&limit=20",
|
|
232
328
|
method: "GET",
|
|
233
|
-
|
|
234
|
-
outputSchema: schemas["SearchReportsOutputSchema"],
|
|
329
|
+
headers: { accept: "application/json" },
|
|
235
330
|
},
|
|
236
331
|
]);
|
|
237
332
|
});
|
|
238
333
|
|
|
239
334
|
it("un appel de mutation consomme les params du chemin et envoie le reste en body", async () => {
|
|
240
|
-
const { client, requests } = buildClient();
|
|
335
|
+
const { client, requests } = buildClient({ validateResponses: "never" });
|
|
241
336
|
await client["widgets"]?.["update"]?.({ widgetId: "w-1", title: "renamed" });
|
|
242
337
|
await client["workOrders"]?.["accept"]?.({ orderId: "o-9" });
|
|
243
|
-
expect(requests[0]?.
|
|
244
|
-
expect(requests[0]?.body).
|
|
245
|
-
expect(requests[
|
|
246
|
-
expect(requests[1]?.
|
|
338
|
+
expect(requests[0]?.url).toBe("/api/widgets/w-1");
|
|
339
|
+
expect(requests[0]?.body).toBe(JSON.stringify({ title: "renamed" }));
|
|
340
|
+
expect(requests[0]?.headers["content-type"]).toBe("application/json");
|
|
341
|
+
expect(requests[1]?.url).toBe("/api/work-orders/o-9/accept");
|
|
342
|
+
expect(requests[1]?.body).toBe("{}");
|
|
247
343
|
});
|
|
248
344
|
|
|
249
|
-
it("
|
|
250
|
-
const { client } = buildClient(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
345
|
+
it("le baseUrl préfixe le chemin dérivé, sans jamais doubler la barre", async () => {
|
|
346
|
+
const { client, requests } = buildClient({
|
|
347
|
+
validateResponses: "never",
|
|
348
|
+
baseUrl: "https://api.example.test/",
|
|
349
|
+
});
|
|
350
|
+
await client["widgets"]?.["list"]?.();
|
|
351
|
+
expect(requests[0]?.url).toBe("https://api.example.test/api/widgets");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("un param de chemin manquant à l'appel est refusé bruyamment, sans appeler le réseau", async () => {
|
|
355
|
+
const { client, requests } = buildClient();
|
|
356
|
+
const failure = await failureOf(() => operationOf(client, "widgets", "update")({ title: "x" }));
|
|
357
|
+
expect(failure.error.code).toBe("MANIFEST_CLIENT_MISSING_PATH_PARAM");
|
|
358
|
+
expect(requests).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("ÉTANT DONNÉ validateResponses \"always\" et une réponse violant le schéma ALORS CONTRACT_BROKEN", async () => {
|
|
362
|
+
const { client } = buildClient({
|
|
363
|
+
answer: () => jsonResponse(200, { widgetId: 42 }),
|
|
364
|
+
});
|
|
365
|
+
const failure = await failureOf(
|
|
366
|
+
() => operationOf(client, "widgets", "get")({ widgetId: "w-1" }),
|
|
254
367
|
);
|
|
368
|
+
expect(failure.error.code).toBe("CONTRACT_BROKEN");
|
|
369
|
+
expect(failure.error.cause).toContain("GetWidgetOutputSchema");
|
|
255
370
|
});
|
|
256
371
|
|
|
257
|
-
it("
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
),
|
|
266
|
-
).toBe("CLIENT_MANIFEST_INVALID");
|
|
372
|
+
it("ÉTANT DONNÉ validateResponses \"never\" ALORS la même réponse passe telle quelle", async () => {
|
|
373
|
+
const { client } = buildClient({
|
|
374
|
+
validateResponses: "never",
|
|
375
|
+
answer: () => jsonResponse(200, { widgetId: 42 }),
|
|
376
|
+
});
|
|
377
|
+
await expect(client["widgets"]?.["get"]?.({ widgetId: "w-1" })).resolves.toEqual({
|
|
378
|
+
widgetId: 42,
|
|
379
|
+
});
|
|
267
380
|
});
|
|
268
381
|
|
|
269
|
-
it("
|
|
270
|
-
const
|
|
271
|
-
|
|
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
|
-
],
|
|
382
|
+
it("une réponse qui n'est pas du JSON est un contrat cassé, jamais une SyntaxError nue", async () => {
|
|
383
|
+
const { client } = buildClient({
|
|
384
|
+
answer: () => response(200, "<html>gateway timeout</html>"),
|
|
280
385
|
});
|
|
281
|
-
|
|
386
|
+
const failure = await failureOf(() => operationOf(client, "widgets", "list")());
|
|
387
|
+
expect(failure.error.code).toBe("CONTRACT_BROKEN");
|
|
282
388
|
});
|
|
283
389
|
|
|
284
|
-
it("
|
|
285
|
-
|
|
390
|
+
it("ÉTANT DONNÉ une réponse d'échec portant un corps OgygieError ALORS elle ressort inchangée", async () => {
|
|
391
|
+
const apiFailure = ogygieError({
|
|
392
|
+
code: "WIDGET_TITLE_ALREADY_USED",
|
|
393
|
+
message: "A widget with this title already exists in this account.",
|
|
394
|
+
file: "src/features/widgets/service.ts",
|
|
395
|
+
cause: "title=renamed already stored",
|
|
396
|
+
suggestedFix: "Choose another title, or update the existing widget.",
|
|
397
|
+
details: { conflictingId: "w-2" },
|
|
398
|
+
});
|
|
399
|
+
const { client } = buildClient({ answer: () => jsonResponse(409, apiFailure) });
|
|
400
|
+
const failure = await failureOf(
|
|
401
|
+
() => operationOf(client, "widgets", "update")({ widgetId: "w-1", title: "renamed" }),
|
|
402
|
+
);
|
|
403
|
+
expect(failure.error).toEqual(apiFailure);
|
|
404
|
+
expect(failure.httpStatus).toBe(409);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("ÉTANT DONNÉ un échec sans corps OgygieError ALORS NETWORK_ERROR, statut conservé", async () => {
|
|
408
|
+
const { client } = buildClient({ answer: () => jsonResponse(502, { message: "boom" }) });
|
|
409
|
+
const failure = await failureOf(() => operationOf(client, "widgets", "list")());
|
|
410
|
+
expect(failure.error.code).toBe("NETWORK_ERROR");
|
|
411
|
+
expect(failure.error.details["status"]).toBe(502);
|
|
412
|
+
expect(failure.httpStatus).toBe(502);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("ÉTANT DONNÉ un fetchImpl qui rejette ALORS NETWORK_ERROR, jamais l'erreur brute", async () => {
|
|
416
|
+
const client = createManifestClient(fixtureManifest(), schemaRegistry(), {
|
|
417
|
+
fetchImpl: async () => {
|
|
418
|
+
throw new TypeError("Failed to fetch");
|
|
419
|
+
},
|
|
420
|
+
validateResponses: "always",
|
|
421
|
+
});
|
|
422
|
+
const failure = await failureOf(() => operationOf(client, "widgets", "list")());
|
|
423
|
+
expect(failure.error.code).toBe("NETWORK_ERROR");
|
|
424
|
+
expect(failure.error.cause).toContain("Failed to fetch");
|
|
286
425
|
});
|
|
287
426
|
});
|
package/src/manifest-client.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @intent GENERIC manifest-derived API client factory, framework side — the
|
|
3
3
|
* browser pendant of the manifest-derived gateway: this module knows ZERO
|
|
4
|
-
* product vocabulary (
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* product vocabulary (generic tooling lives in the core), and the vocabulary
|
|
5
|
+
* gate next to it proves it. It derives callable operations from an Ogygie
|
|
6
|
+
* manifest: every declared route becomes a method under its path-root
|
|
7
|
+
* namespace, nothing else exists — a route removed from the manifest
|
|
8
|
+
* disappears from the client, the manifest is the only map.
|
|
9
9
|
*
|
|
10
10
|
* Derivations (all from the manifest, nothing hand-written per feature):
|
|
11
11
|
* - namespace: first path segment after /api, camelCased
|
|
@@ -15,20 +15,24 @@
|
|
|
15
15
|
* declared input schema name ("SearchThingsInputSchema" → search); when the
|
|
16
16
|
* schema name carries no verb, a method fallback (GET → list, GET on
|
|
17
17
|
* ":param" → get, POST → create, PATCH/PUT → update, DELETE → delete);
|
|
18
|
-
* - cache key: [root, ...path param values, remaining input if any]
|
|
19
|
-
* number-one source of cache bugs, so it is never written by hand;
|
|
18
|
+
* - cache key: [root, operation, ...path param values, remaining input if any]
|
|
19
|
+
* — the number-one source of cache bugs, so it is never written by hand;
|
|
20
20
|
* - invalidation roots: the route's own root, plus the roots the manifest
|
|
21
21
|
* declares in `invalidates` (cross-feature staleness is READ, never
|
|
22
22
|
* guessed in a screen).
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* The TRANSPORT lives here too and owns no effect of its own: `fetchImpl` is
|
|
25
|
+
* injected (principle 5), no environment variable is ever read, and the
|
|
26
|
+
* response contract is re-validated when the product asks for it
|
|
27
|
+
* (`validateResponses: "always"` in dev). Failures speak ONE language: an
|
|
28
|
+
* OgygieError body comes back untouched, anything else is wrapped in
|
|
29
|
+
* NETWORK_ERROR or CONTRACT_BROKEN — never a raw fetch rejection.
|
|
30
|
+
*
|
|
31
|
+
* Building an incoherent client REFUSES loudly (MANIFEST_CLIENT_*): a refusal
|
|
28
32
|
* at construction beats a method that lies at call time.
|
|
29
33
|
*/
|
|
30
34
|
import { z } from "zod";
|
|
31
|
-
import { throwOgygieError } from "./error.schema.js";
|
|
35
|
+
import { OgygieErrorSchema, OgygieFailure, throwOgygieError } from "./error.schema.js";
|
|
32
36
|
|
|
33
37
|
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
34
38
|
|
|
@@ -58,18 +62,36 @@ export const ClientManifestViewSchema = z.object({
|
|
|
58
62
|
});
|
|
59
63
|
export type ClientManifestView = z.infer<typeof ClientManifestViewSchema>;
|
|
60
64
|
|
|
61
|
-
/**
|
|
62
|
-
export interface
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/** Present on POST/PUT/PATCH only: the input minus the path params. */
|
|
67
|
-
body?: unknown;
|
|
68
|
-
outputSchema: z.ZodType;
|
|
65
|
+
/** The SHAPE of a response the client needs — a `Response` satisfies it. */
|
|
66
|
+
export interface HttpResponseLike {
|
|
67
|
+
ok: boolean;
|
|
68
|
+
status: number;
|
|
69
|
+
text: () => Promise<string>;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The injected network effect. `globalThis.fetch` satisfies it, and so does a
|
|
74
|
+
* fake in a test — this module never reaches the network by itself.
|
|
75
|
+
*/
|
|
76
|
+
export type FetchLike = (
|
|
77
|
+
url: string,
|
|
78
|
+
init: { method: HttpMethod; headers: Record<string, string>; body?: string },
|
|
79
|
+
) => Promise<HttpResponseLike>;
|
|
80
|
+
|
|
71
81
|
export interface ManifestClientOptions {
|
|
72
|
-
|
|
82
|
+
fetchImpl: FetchLike;
|
|
83
|
+
/**
|
|
84
|
+
* "always" re-parses every response with its declared output schema (in dev
|
|
85
|
+
* a broken contract must fail HERE, not three screens later); "never" is a
|
|
86
|
+
* passthrough. The PRODUCT decides per environment — the core reads no env.
|
|
87
|
+
*/
|
|
88
|
+
validateResponses: "always" | "never";
|
|
89
|
+
/**
|
|
90
|
+
* Prefix put in front of the derived "/api/..." path. Empty by default: the
|
|
91
|
+
* front calls its api RELATIVELY and knows neither host nor port
|
|
92
|
+
* (AGENTS.md §Sécurité 8). A trailing "/" is trimmed.
|
|
93
|
+
*/
|
|
94
|
+
baseUrl?: string;
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
/** One derived operation: callable, and self-describing for the query layer. */
|
|
@@ -83,7 +105,7 @@ export interface ApiOperation {
|
|
|
83
105
|
outputSchema: z.ZodType;
|
|
84
106
|
/** True for POST/PUT/PATCH/DELETE — the caller picks query vs mutation. */
|
|
85
107
|
isMutation: boolean;
|
|
86
|
-
/** Derived cache key: [root, ...param values, remaining input if any]. */
|
|
108
|
+
/** Derived cache key: [root, operation, ...param values, remaining input if any]. */
|
|
87
109
|
queryKey: (input?: Record<string, unknown>) => ReadonlyArray<unknown>;
|
|
88
110
|
/** Cache roots stale after a successful mutation: own root + declared `invalidates`. */
|
|
89
111
|
invalidationRoots: ReadonlyArray<string>;
|
|
@@ -107,15 +129,28 @@ function verbOfInputSchemaName(schemaName: string): string | null {
|
|
|
107
129
|
return match?.[1] === undefined ? null : match[1].toLowerCase();
|
|
108
130
|
}
|
|
109
131
|
|
|
110
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Construction refusals, one STABLE code per cause: "the manifest is
|
|
134
|
+
* unreadable", "a declared schema is missing from the registry" and "two
|
|
135
|
+
* routes derive the same operation" are three different things to fix, and an
|
|
136
|
+
* agent reading the error alone must know which.
|
|
137
|
+
*/
|
|
138
|
+
function refuseClient(
|
|
139
|
+
code:
|
|
140
|
+
| "MANIFEST_CLIENT_INVALID_MANIFEST"
|
|
141
|
+
| "MANIFEST_CLIENT_UNKNOWN_SCHEMA"
|
|
142
|
+
| "MANIFEST_CLIENT_AMBIGUOUS_OPERATION",
|
|
143
|
+
cause: string,
|
|
144
|
+
suggestedFix: string,
|
|
145
|
+
): never {
|
|
111
146
|
throwOgygieError(
|
|
112
147
|
{
|
|
113
|
-
code
|
|
114
|
-
message:
|
|
148
|
+
code,
|
|
149
|
+
message:
|
|
150
|
+
"Manifest client construction refused: the manifest cannot be derived into operations.",
|
|
115
151
|
file: "ogygie.manifest.json",
|
|
116
152
|
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.",
|
|
153
|
+
suggestedFix,
|
|
119
154
|
},
|
|
120
155
|
500,
|
|
121
156
|
);
|
|
@@ -163,9 +198,11 @@ export function createManifestClient(
|
|
|
163
198
|
const parsedManifest = ClientManifestViewSchema.safeParse(manifest);
|
|
164
199
|
if (!parsedManifest.success) {
|
|
165
200
|
refuseClient(
|
|
201
|
+
"MANIFEST_CLIENT_INVALID_MANIFEST",
|
|
166
202
|
parsedManifest.error.issues
|
|
167
203
|
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
168
204
|
.join(" ; "),
|
|
205
|
+
"Fix the declared route surface (method, /api path, schemaInput, schemaOutput) — a construction refusal beats a method that lies.",
|
|
169
206
|
);
|
|
170
207
|
}
|
|
171
208
|
|
|
@@ -173,24 +210,40 @@ export function createManifestClient(
|
|
|
173
210
|
const mounted = new Set<string>();
|
|
174
211
|
for (const feature of parsedManifest.data.features) {
|
|
175
212
|
for (const route of feature.routes) {
|
|
213
|
+
const key = `${route.method} ${route.path}`;
|
|
176
214
|
if (!WELL_FORMED_ROUTE_PATH.test(route.path)) {
|
|
177
|
-
refuseClient(
|
|
215
|
+
refuseClient(
|
|
216
|
+
"MANIFEST_CLIENT_INVALID_MANIFEST",
|
|
217
|
+
`feature "${feature.name}": route path "${route.path}" is malformed`,
|
|
218
|
+
'Write the path as static or ":param" segments under /api (e.g. "/api/orders/:orderId").',
|
|
219
|
+
);
|
|
178
220
|
}
|
|
179
221
|
for (const schemaName of [route.schemaInput, route.schemaOutput]) {
|
|
180
222
|
if (schemas[schemaName] === undefined) {
|
|
181
223
|
refuseClient(
|
|
182
|
-
|
|
224
|
+
"MANIFEST_CLIENT_UNKNOWN_SCHEMA",
|
|
225
|
+
`feature "${feature.name}": route ${key} declares schema "${schemaName}", absent from the schema registry`,
|
|
226
|
+
`Register "${schemaName}" in the registry passed to createManifestClient, or fix the name declared in the manifest.`,
|
|
183
227
|
);
|
|
184
228
|
}
|
|
185
229
|
}
|
|
186
|
-
|
|
187
|
-
|
|
230
|
+
if (mounted.has(key)) {
|
|
231
|
+
refuseClient(
|
|
232
|
+
"MANIFEST_CLIENT_INVALID_MANIFEST",
|
|
233
|
+
`route ${key} is declared twice`,
|
|
234
|
+
"Declare each method+path exactly once in the manifest.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
188
237
|
mounted.add(key);
|
|
189
238
|
|
|
190
239
|
const segments = route.path.split("/").slice(2);
|
|
191
240
|
const root = segments[0];
|
|
192
241
|
if (root === undefined || root.startsWith(":")) {
|
|
193
|
-
refuseClient(
|
|
242
|
+
refuseClient(
|
|
243
|
+
"MANIFEST_CLIENT_INVALID_MANIFEST",
|
|
244
|
+
`route ${key}: the first segment after /api must be static`,
|
|
245
|
+
'Start the path with a static root — it is both the namespace and the cache root ("/api/orders/...").',
|
|
246
|
+
);
|
|
194
247
|
}
|
|
195
248
|
const entry: DerivedRoute = {
|
|
196
249
|
method: route.method,
|
|
@@ -215,7 +268,9 @@ export function createManifestClient(
|
|
|
215
268
|
const namespaceOperations = (client[route.namespace] ??= {});
|
|
216
269
|
if (namespaceOperations[route.operation] !== undefined) {
|
|
217
270
|
refuseClient(
|
|
218
|
-
|
|
271
|
+
"MANIFEST_CLIENT_AMBIGUOUS_OPERATION",
|
|
272
|
+
`operation "${route.namespace}.${route.operation}" is derived twice (${route.method} ${route.path} collides)`,
|
|
273
|
+
"Give one of the two routes a distinct static segment (or a distinct input schema verb) — a silent overwrite would hide a route.",
|
|
219
274
|
);
|
|
220
275
|
}
|
|
221
276
|
namespaceOperations[route.operation] = buildOperation(route, schemas, options);
|
|
@@ -223,6 +278,26 @@ export function createManifestClient(
|
|
|
223
278
|
return client;
|
|
224
279
|
}
|
|
225
280
|
|
|
281
|
+
/** JSON body of a response, or a CONTRACT_BROKEN refusal — never a raw SyntaxError. */
|
|
282
|
+
function parseJsonBody(rawBody: string, route: DerivedRoute, url: string): unknown {
|
|
283
|
+
if (rawBody.trim() === "") return undefined;
|
|
284
|
+
try {
|
|
285
|
+
return JSON.parse(rawBody) as unknown;
|
|
286
|
+
} catch (thrown) {
|
|
287
|
+
throwOgygieError(
|
|
288
|
+
{
|
|
289
|
+
code: "CONTRACT_BROKEN",
|
|
290
|
+
message: `Response of ${route.namespace}.${route.operation} is not JSON.`,
|
|
291
|
+
file: null,
|
|
292
|
+
cause: `${route.method} ${url} answered a body starting with ${JSON.stringify(rawBody.slice(0, 80))} (${String(thrown)})`,
|
|
293
|
+
suggestedFix:
|
|
294
|
+
"Find what really answered on this path (a proxy error page? an html fallback?) — the declared output schema promises JSON.",
|
|
295
|
+
},
|
|
296
|
+
500,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
226
301
|
function buildOperation(
|
|
227
302
|
route: DerivedRoute,
|
|
228
303
|
schemas: Record<string, z.ZodType>,
|
|
@@ -231,8 +306,14 @@ function buildOperation(
|
|
|
231
306
|
const inputSchema = schemas[route.schemaInput];
|
|
232
307
|
const outputSchema = schemas[route.schemaOutput];
|
|
233
308
|
if (inputSchema === undefined || outputSchema === undefined) {
|
|
234
|
-
|
|
309
|
+
// Unreachable: createManifestClient refused every unknown schema above.
|
|
310
|
+
refuseClient(
|
|
311
|
+
"MANIFEST_CLIENT_UNKNOWN_SCHEMA",
|
|
312
|
+
`route ${route.method} ${route.path}: schema lookup failed`,
|
|
313
|
+
"Register the declared input and output schemas before building the client.",
|
|
314
|
+
);
|
|
235
315
|
}
|
|
316
|
+
const baseUrl = (options.baseUrl ?? "").replace(/\/$/, "");
|
|
236
317
|
|
|
237
318
|
/** Path params consumed by name; everything else is body or query string. */
|
|
238
319
|
function splitInput(input: Record<string, unknown>): {
|
|
@@ -246,7 +327,7 @@ function buildOperation(
|
|
|
246
327
|
if (typeof value !== "string" || value === "") {
|
|
247
328
|
throwOgygieError(
|
|
248
329
|
{
|
|
249
|
-
code: "
|
|
330
|
+
code: "MANIFEST_CLIENT_MISSING_PATH_PARAM",
|
|
250
331
|
message: `Operation ${route.namespace}.${route.operation} was called without its ":${paramName}" path param.`,
|
|
251
332
|
file: null,
|
|
252
333
|
cause: `${route.method} ${route.path} needs "${paramName}", received ${JSON.stringify(value)}`,
|
|
@@ -270,24 +351,83 @@ function buildOperation(
|
|
|
270
351
|
encodeURIComponent(String(input[paramName])),
|
|
271
352
|
);
|
|
272
353
|
}
|
|
273
|
-
|
|
274
|
-
|
|
354
|
+
|
|
355
|
+
const carriesBody = METHODS_WITH_BODY.has(route.method);
|
|
356
|
+
let url = `${baseUrl}${resolvedPath}`;
|
|
357
|
+
if (!carriesBody) {
|
|
358
|
+
const searchParameters = new URLSearchParams();
|
|
359
|
+
for (const [name, value] of Object.entries(rest)) {
|
|
360
|
+
if (value !== undefined && value !== null) searchParameters.set(name, String(value));
|
|
361
|
+
}
|
|
362
|
+
const queryString = searchParameters.toString();
|
|
363
|
+
if (queryString !== "") url = `${url}?${queryString}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let response: HttpResponseLike;
|
|
367
|
+
let rawBody: string;
|
|
368
|
+
try {
|
|
369
|
+
response = await options.fetchImpl(url, {
|
|
275
370
|
method: route.method,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
371
|
+
headers: carriesBody
|
|
372
|
+
? { "content-type": "application/json", accept: "application/json" }
|
|
373
|
+
: { accept: "application/json" },
|
|
374
|
+
...(carriesBody ? { body: JSON.stringify(rest) } : {}),
|
|
279
375
|
});
|
|
376
|
+
rawBody = await response.text();
|
|
377
|
+
} catch (thrown) {
|
|
378
|
+
// The network layer failed BEFORE anything could be judged: nothing was
|
|
379
|
+
// answered, so nothing can be blamed on a contract.
|
|
380
|
+
throwOgygieError(
|
|
381
|
+
{
|
|
382
|
+
code: "NETWORK_ERROR",
|
|
383
|
+
message: `Call to ${route.namespace}.${route.operation} never reached the api.`,
|
|
384
|
+
file: null,
|
|
385
|
+
cause: `${route.method} ${url} threw ${String(thrown)}`,
|
|
386
|
+
suggestedFix:
|
|
387
|
+
"Check that the api answers on this path (dev proxy up? offline?) — the client never invents a fallback answer.",
|
|
388
|
+
},
|
|
389
|
+
503,
|
|
390
|
+
);
|
|
280
391
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
392
|
+
|
|
393
|
+
if (!response.ok) {
|
|
394
|
+
// An OgygieError body is the api SPEAKING: it comes back untouched —
|
|
395
|
+
// code, details and suggestedFix included. Wrapping it would destroy
|
|
396
|
+
// the only thing a screen can act on.
|
|
397
|
+
const failurePayload = OgygieErrorSchema.safeParse(parseJsonBody(rawBody, route, url));
|
|
398
|
+
if (failurePayload.success) throw new OgygieFailure(failurePayload.data, response.status);
|
|
399
|
+
throwOgygieError(
|
|
400
|
+
{
|
|
401
|
+
code: "NETWORK_ERROR",
|
|
402
|
+
message: `Call to ${route.namespace}.${route.operation} failed with status ${response.status}.`,
|
|
403
|
+
file: null,
|
|
404
|
+
cause: `${route.method} ${url} answered ${response.status} without an OgygieError body: ${JSON.stringify(rawBody.slice(0, 200))}`,
|
|
405
|
+
suggestedFix:
|
|
406
|
+
"Find what answered (gateway? proxy? an api without its error handler?) — every Ogygie failure carries an OgygieError body.",
|
|
407
|
+
details: { status: response.status },
|
|
408
|
+
},
|
|
409
|
+
response.status,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const data = parseJsonBody(rawBody, route, url);
|
|
414
|
+
if (options.validateResponses === "never") return data;
|
|
415
|
+
const validated = outputSchema.safeParse(data);
|
|
416
|
+
if (!validated.success) {
|
|
417
|
+
throwOgygieError(
|
|
418
|
+
{
|
|
419
|
+
code: "CONTRACT_BROKEN",
|
|
420
|
+
message: `Response of ${route.namespace}.${route.operation} violates its declared output schema.`,
|
|
421
|
+
file: null,
|
|
422
|
+
cause: `${route.method} ${url} — ${route.schemaOutput}: ${validated.error.issues
|
|
423
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
424
|
+
.join(" ; ")}`,
|
|
425
|
+
suggestedFix: `Align the api answer with ${route.schemaOutput}, or amend the contract on both sides in the same PR.`,
|
|
426
|
+
},
|
|
427
|
+
500,
|
|
428
|
+
);
|
|
284
429
|
}
|
|
285
|
-
|
|
286
|
-
return options.performRequest({
|
|
287
|
-
method: route.method,
|
|
288
|
-
path: queryString === "" ? resolvedPath : `${resolvedPath}?${queryString}`,
|
|
289
|
-
outputSchema,
|
|
290
|
-
});
|
|
430
|
+
return validated.data;
|
|
291
431
|
};
|
|
292
432
|
|
|
293
433
|
return Object.assign(call, {
|
|
@@ -303,7 +443,7 @@ function buildOperation(
|
|
|
303
443
|
// The OPERATION is part of the key: two GET routes on the same entity
|
|
304
444
|
// (…/:id, …/:id/quantities, …/:id/costs) must never share a cache entry
|
|
305
445
|
// — without it TanStack dedupes them and the later queries never fire
|
|
306
|
-
// (found by
|
|
446
|
+
// (regression found by the first consumer of sibling GET routes).
|
|
307
447
|
const key: unknown[] = [route.root, route.operation, ...paramValues];
|
|
308
448
|
if (Object.keys(rest).length > 0) key.push(rest);
|
|
309
449
|
return key;
|
|
@@ -73,7 +73,7 @@ describe("manifest — features front", () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
//
|
|
76
|
+
// Manifest-client extension (specs/manifest-client.spec.md §3): a mutation route may
|
|
77
77
|
// declare the cache roots it stales beyond its own — read by the derived
|
|
78
78
|
// client, verified here: a target matching nothing is a typo that would
|
|
79
79
|
// silently never invalidate anything.
|
|
@@ -102,7 +102,7 @@ const listArchiveRoute = {
|
|
|
102
102
|
schemaOutput: "ListRecordArchiveOutputSchema",
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
-
describe("manifest — invalidations croisées des routes (spec
|
|
105
|
+
describe("manifest — invalidations croisées des routes (spec manifest-client §3)", () => {
|
|
106
106
|
it("une route sans invalidates reste acceptée (champ optionnel, élargissement libre)", () => {
|
|
107
107
|
const parsed = OgygieManifestSchema.safeParse(
|
|
108
108
|
manifestWithApiRoutes([createRecordRoute]),
|