@decocms/apps-magento 8.0.0 → 8.1.0-next.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 +4 -4
- package/src/README.md +2 -2
- package/src/__tests__/graphql.test.ts +3 -3
- package/src/__tests__/magentoClient.test.ts +166 -0
- package/src/client.ts +26 -2
- package/src/index.ts +25 -0
- package/src/magentoClient.ts +129 -0
- package/src/middleware.ts +2 -2
- package/src/utils/__tests__/instrumentation.test.ts +59 -0
- package/src/utils/client/types.ts +1 -1
- package/src/utils/constants.ts +25 -0
- package/src/utils/fetchCache.ts +55 -0
- package/src/utils/instrumentedFetch.ts +57 -0
- package/src/utils/operationRouter.ts +43 -0
- package/src/utils/transform.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-magento",
|
|
3
|
-
"version": "8.0.0",
|
|
3
|
+
"version": "8.1.0-next.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce app: Magento integration",
|
|
6
6
|
"repository": {
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"lint:unused": "knip"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@decocms/blocks": "8.0.0",
|
|
30
|
-
"@decocms/apps-commerce": "8.0.0",
|
|
31
|
-
"@decocms/tanstack": "8.0.0"
|
|
29
|
+
"@decocms/blocks": "8.1.0-next.0",
|
|
30
|
+
"@decocms/apps-commerce": "8.1.0-next.0",
|
|
31
|
+
"@decocms/tanstack": "8.1.0-next.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": "^19.0.0",
|
package/src/README.md
CHANGED
|
@@ -13,7 +13,7 @@ the original deco-cx/apps repo and need adaptation passes (Deno → Node,
|
|
|
13
13
|
ctx-based to client-based state access, cookie helpers from
|
|
14
14
|
`@decocms/start/sdk/cookie`).
|
|
15
15
|
|
|
16
|
-
A real-world consumer (
|
|
16
|
+
A real-world consumer (a production Magento storefront) is wiring magento
|
|
17
17
|
in-site today using a thin adapter that wraps the legacy `magento/mod.ts`
|
|
18
18
|
shape. Their adapter is the migration target — once this package covers
|
|
19
19
|
the surface area they need, the in-site copy goes away.
|
|
@@ -54,7 +54,7 @@ above is a hard requirement of this port.
|
|
|
54
54
|
|
|
55
55
|
## Why a stub now
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
A production Magento storefront's migration hit a HIGH parity finding:
|
|
58
58
|
|
|
59
59
|
```
|
|
60
60
|
invoke(magento/loaders/features) failed: handler not found
|
|
@@ -148,15 +148,15 @@ describe("transformFilterGraphQL — merge order", () => {
|
|
|
148
148
|
|
|
149
149
|
describe("formatUrlSuffix", () => {
|
|
150
150
|
it("strips a single leading slash", () => {
|
|
151
|
-
expect(formatUrlSuffix("/
|
|
151
|
+
expect(formatUrlSuffix("/acme/")).toBe("acme/");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
it("appends trailing slash when missing", () => {
|
|
155
|
-
expect(formatUrlSuffix("
|
|
155
|
+
expect(formatUrlSuffix("acme")).toBe("acme/");
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
it("leaves trailing slash alone", () => {
|
|
159
|
-
expect(formatUrlSuffix("
|
|
159
|
+
expect(formatUrlSuffix("acme/")).toBe("acme/");
|
|
160
160
|
});
|
|
161
161
|
});
|
|
162
162
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
/**
|
|
3
|
+
* The v8 Magento client (/next/upstream-clients): every request goes through
|
|
4
|
+
* the instrumented fetch as provider "magento", named by its operation; the
|
|
5
|
+
* store's credentials only go to the store; errors never carry bodies.
|
|
6
|
+
*/
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
const instrumented = vi.hoisted(() => ({
|
|
10
|
+
providers: [] as string[],
|
|
11
|
+
operations: [] as (string | undefined)[],
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@decocms/blocks/fetch", async (importOriginal) => {
|
|
15
|
+
const real = await importOriginal<typeof import("@decocms/blocks/fetch")>();
|
|
16
|
+
return {
|
|
17
|
+
createInstrumentedFetch: (options: Parameters<typeof real.createInstrumentedFetch>[0]) => {
|
|
18
|
+
instrumented.providers.push(options.provider);
|
|
19
|
+
const request = real.createInstrumentedFetch(options);
|
|
20
|
+
return (input: string | URL | Request, init?: RequestInit & { operation?: string }) => {
|
|
21
|
+
instrumented.operations.push(init?.operation);
|
|
22
|
+
return request(input, init);
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
import { createMagentoClient, MagentoError } from "../magentoClient";
|
|
29
|
+
|
|
30
|
+
/** The error a call rejects with (fails the test if it resolves). */
|
|
31
|
+
const failure = (call: Promise<unknown>): Promise<Error> =>
|
|
32
|
+
call.then(
|
|
33
|
+
() => {
|
|
34
|
+
throw new Error("expected the call to fail");
|
|
35
|
+
},
|
|
36
|
+
(error: Error) => error,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
function upstream(body: unknown, status = 200) {
|
|
40
|
+
return vi.fn(
|
|
41
|
+
async (_input: string | URL | Request, _init?: RequestInit) =>
|
|
42
|
+
new Response(JSON.stringify(body), { status }),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const config = { baseUrl: "https://store.example.com/", apiKey: "key", originHeader: "origin" };
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
instrumented.providers.length = 0;
|
|
50
|
+
instrumented.operations.length = 0;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("createMagentoClient", () => {
|
|
54
|
+
it("calls REST with the store credentials, labeled by operation", async () => {
|
|
55
|
+
const fetch = upstream({ id: 7 });
|
|
56
|
+
const magento = createMagentoClient(config, { fetch });
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
await magento.rest<{ id: number }>("/rest/default/V1/carts/7", { operation: "getCart" }),
|
|
60
|
+
).toEqual({ id: 7 });
|
|
61
|
+
expect(instrumented.providers).toEqual(["magento"]);
|
|
62
|
+
expect(instrumented.operations).toEqual(["getCart"]);
|
|
63
|
+
const [url, init] = fetch.mock.calls[0]!;
|
|
64
|
+
expect(String(url)).toBe("https://store.example.com/rest/default/V1/carts/7");
|
|
65
|
+
const headers = new Headers(init?.headers);
|
|
66
|
+
expect(headers.get("authorization")).toBe("Bearer key");
|
|
67
|
+
expect(headers.get("x-origin-header")).toBe("origin");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("omits the token when a call opts out", async () => {
|
|
71
|
+
const fetch = upstream({});
|
|
72
|
+
const magento = createMagentoClient(config, { fetch });
|
|
73
|
+
await magento.request("/customer/section/load", {
|
|
74
|
+
operation: "loadSections",
|
|
75
|
+
authenticated: false,
|
|
76
|
+
});
|
|
77
|
+
expect(new Headers(fetch.mock.calls[0]![1]?.headers).get("authorization")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("refuses URLs on another origin, so the token never leaves the store", async () => {
|
|
81
|
+
const fetch = upstream({});
|
|
82
|
+
const magento = createMagentoClient(config, { fetch });
|
|
83
|
+
await expect(magento.request("https://evil.example/steal", { operation: "x" })).rejects.toThrow(
|
|
84
|
+
/only paths on the configured store/,
|
|
85
|
+
);
|
|
86
|
+
await expect(magento.request("//evil.example/steal", { operation: "x" })).rejects.toThrow(
|
|
87
|
+
/only paths on the configured store/,
|
|
88
|
+
);
|
|
89
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps a customer Authorization header instead of the store's token", async () => {
|
|
93
|
+
const fetch = upstream({ data: { customer: { email: "a" } }, items: [] });
|
|
94
|
+
const magento = createMagentoClient(config, { fetch });
|
|
95
|
+
await magento.graphql("query Customer { customer { email } }", undefined, {
|
|
96
|
+
operationName: "Customer",
|
|
97
|
+
headers: { Authorization: "Bearer customer" },
|
|
98
|
+
});
|
|
99
|
+
await magento.rest("/rest/default/V1/carts/mine", {
|
|
100
|
+
operation: "getCart",
|
|
101
|
+
headers: { Authorization: "Bearer customer" },
|
|
102
|
+
});
|
|
103
|
+
for (const [, init] of fetch.mock.calls) {
|
|
104
|
+
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer customer");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("sends GraphQL without the store's token unless asked, and keeps the JSON content type", async () => {
|
|
109
|
+
const fetch = upstream({ data: {} });
|
|
110
|
+
const magento = createMagentoClient(config, { fetch });
|
|
111
|
+
await magento.graphql("query Q { a }", undefined, {
|
|
112
|
+
operationName: "Q",
|
|
113
|
+
headers: { "Content-Type": "text/plain" },
|
|
114
|
+
});
|
|
115
|
+
await magento.graphql("query Q { a }", undefined, { operationName: "Q", authenticated: true });
|
|
116
|
+
const [first, second] = fetch.mock.calls.map(([, init]) => new Headers(init?.headers));
|
|
117
|
+
expect(first!.get("authorization")).toBeNull();
|
|
118
|
+
expect(first!.get("content-type")).toBe("application/json");
|
|
119
|
+
expect(second!.get("authorization")).toBe("Bearer key");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns undefined for a 204 REST response", async () => {
|
|
123
|
+
const fetch = vi.fn(async () => new Response(null, { status: 204 }));
|
|
124
|
+
const magento = createMagentoClient(config, { fetch });
|
|
125
|
+
await expect(
|
|
126
|
+
magento.rest("/rest/default/V1/carts/mine/items/1", {
|
|
127
|
+
operation: "removeCartItem",
|
|
128
|
+
method: "DELETE",
|
|
129
|
+
}),
|
|
130
|
+
).resolves.toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("sends GraphQL with its operationName, which is the label", async () => {
|
|
134
|
+
const fetch = upstream({ data: { productStockAlert: { status: true } } });
|
|
135
|
+
const magento = createMagentoClient(config, { fetch });
|
|
136
|
+
const data = await magento.graphql<{ productStockAlert: { status: boolean } }>(
|
|
137
|
+
"mutation ProductStockAlert($sku: String!) { productStockAlert(sku: $sku) { status } }",
|
|
138
|
+
{ sku: "A1" },
|
|
139
|
+
{ operationName: "ProductStockAlert", headers: { Store: "default" } },
|
|
140
|
+
);
|
|
141
|
+
expect(data.productStockAlert.status).toBe(true);
|
|
142
|
+
expect(instrumented.operations).toEqual(["ProductStockAlert"]);
|
|
143
|
+
const [url, init] = fetch.mock.calls[0]!;
|
|
144
|
+
expect(String(url)).toBe("https://store.example.com/graphql");
|
|
145
|
+
expect(new Headers(init?.headers).get("store")).toBe("default");
|
|
146
|
+
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
147
|
+
operationName: "ProductStockAlert",
|
|
148
|
+
variables: { sku: "A1" },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("throws a MagentoError without the body", async () => {
|
|
153
|
+
const magento = createMagentoClient(config, {
|
|
154
|
+
fetch: upstream({ message: "token key invalid" }, 401),
|
|
155
|
+
});
|
|
156
|
+
const error = await failure(magento.rest("/rest/V1/carts/mine", { operation: "getCart" }));
|
|
157
|
+
expect(error).toBeInstanceOf(MagentoError);
|
|
158
|
+
expect(error.message).toBe("magento getCart failed with HTTP 401");
|
|
159
|
+
|
|
160
|
+
const gql = createMagentoClient(config, {
|
|
161
|
+
fetch: upstream({ errors: [{ message: "x@example.com" }] }),
|
|
162
|
+
});
|
|
163
|
+
const gqlError = await failure(gql.graphql("query Q { a }", undefined, { operationName: "Q" }));
|
|
164
|
+
expect(gqlError.message).toBe("magento Q returned 1 GraphQL error(s)");
|
|
165
|
+
});
|
|
166
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* Magento has consistent muscle memory.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
|
|
19
|
+
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
19
21
|
// Config shapes
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
@@ -56,7 +58,7 @@ export interface MagentoCartConfigs {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export interface MagentoConfig {
|
|
59
|
-
/** Magento storefront base URL, e.g. `https://loja.
|
|
61
|
+
/** Magento storefront base URL, e.g. `https://loja.acme.com.br/` */
|
|
60
62
|
baseUrl: string;
|
|
61
63
|
/** Bearer token for `Authorization` header on admin REST calls */
|
|
62
64
|
apiKey: string;
|
|
@@ -88,6 +90,28 @@ export interface MagentoConfig {
|
|
|
88
90
|
|
|
89
91
|
let config: MagentoConfig | null = null;
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Underlying fetch used by {@link magentoFetch}. Defaults to `globalThis.fetch`;
|
|
95
|
+
* override once at boot with {@link setMagentoFetch} to plug in the instrumented
|
|
96
|
+
* fetch (spans + `http.client.request.duration` histogram, `provider:"magento"`).
|
|
97
|
+
* Mirrors VTEX's `setVtexFetch` / Shopify's `setShopifyFetch` so every commerce
|
|
98
|
+
* app funnels egress through a single instrumented `_fetch`.
|
|
99
|
+
*/
|
|
100
|
+
let _fetch: FetchFn = withFetchTimeout();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Override the fetch used by every Magento egress call.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* import { createMagentoFetch, setMagentoFetch } from "@decocms/apps/magento";
|
|
108
|
+
* setMagentoFetch(createMagentoFetch());
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function setMagentoFetch(fetchFn: FetchFn): void {
|
|
112
|
+
_fetch = fetchFn;
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
export function configureMagento(c: MagentoConfig): void {
|
|
92
116
|
config = c;
|
|
93
117
|
}
|
|
@@ -224,5 +248,5 @@ export function magentoFetch(path: string, opts: MagentoFetchOpts = {}): Promise
|
|
|
224
248
|
// clarity at the call site.
|
|
225
249
|
const sameOrigin = target.origin === baseUrl.origin;
|
|
226
250
|
|
|
227
|
-
return
|
|
251
|
+
return _fetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) });
|
|
228
252
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// v8: the thin Magento client (see /next/upstream-clients).
|
|
2
|
+
|
|
3
|
+
// v7 (kept for v7 consumers until the v7 modules are dropped)
|
|
1
4
|
/**
|
|
2
5
|
* Magento app entry point for @decocms/apps.
|
|
3
6
|
* Re-exports client config + initializer.
|
|
@@ -8,4 +11,26 @@
|
|
|
8
11
|
* import { magentoFetch } from "@decocms/apps/magento/client"
|
|
9
12
|
*/
|
|
10
13
|
export * from "./client";
|
|
14
|
+
export {
|
|
15
|
+
createMagentoClient,
|
|
16
|
+
type MagentoClient,
|
|
17
|
+
type MagentoClientConfig,
|
|
18
|
+
type MagentoClientOptions,
|
|
19
|
+
MagentoError,
|
|
20
|
+
type MagentoRequestInit,
|
|
21
|
+
} from "./magentoClient";
|
|
11
22
|
export type { MagentoCart } from "./types";
|
|
23
|
+
export {
|
|
24
|
+
clearFetchCache,
|
|
25
|
+
type FetchCacheOptions,
|
|
26
|
+
getFetchCacheStats,
|
|
27
|
+
magentoCachedFetch,
|
|
28
|
+
} from "./utils/fetchCache";
|
|
29
|
+
// Observability wiring — sites call `setMagentoFetch(createMagentoFetch())` at
|
|
30
|
+
// boot to route every Magento egress call through the instrumented fetch
|
|
31
|
+
// (upstream latency/status), and use `magentoCachedFetch` for cacheable GETs
|
|
32
|
+
// (SWR hit/miss → `deco.cache.requests{layer="swr",profile="magento"}`).
|
|
33
|
+
export {
|
|
34
|
+
type CreateMagentoFetchOptions,
|
|
35
|
+
createMagentoFetch,
|
|
36
|
+
} from "./utils/instrumentedFetch";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Magento client: a thin client for a Magento store's REST and GraphQL
|
|
3
|
+
* APIs, over the framework's instrumented fetch (provider "magento"). See
|
|
4
|
+
* /next/upstream-clients.
|
|
5
|
+
*
|
|
6
|
+
* Configuration comes in as arguments; the site reads its environment (or a
|
|
7
|
+
* `secret` block) where it creates the client. Converters, hooks, cart and
|
|
8
|
+
* session flows, caching and feature toggles belong to the site (platform
|
|
9
|
+
* templates), not here.
|
|
10
|
+
*/
|
|
11
|
+
import { createInstrumentedFetch } from "@decocms/blocks/fetch";
|
|
12
|
+
|
|
13
|
+
export interface MagentoClientConfig {
|
|
14
|
+
/** The store's origin, e.g. `https://store.example.com`. Every request goes here. */
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
/** Integration access token, sent as `Authorization: Bearer` unless a call opts out. */
|
|
17
|
+
apiKey?: string;
|
|
18
|
+
/** Optional value sent as `x-origin-header`, for stores behind an origin guard. */
|
|
19
|
+
originHeader?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MagentoClientOptions {
|
|
23
|
+
/** The fetch underneath, e.g. a fake in tests. Defaults to `globalThis.fetch`. */
|
|
24
|
+
fetch?: Parameters<typeof createInstrumentedFetch>[0]["fetch"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MagentoRequestInit extends RequestInit {
|
|
28
|
+
/** The operation label, the API's own name for the call (e.g. `getCart`), never a URL. */
|
|
29
|
+
operation: string;
|
|
30
|
+
/**
|
|
31
|
+
* Send the `apiKey` bearer token. Default true for `request`/`rest`, false
|
|
32
|
+
* for `graphql`. Never replaces an `Authorization` header the call sets
|
|
33
|
+
* (e.g. a customer token).
|
|
34
|
+
*/
|
|
35
|
+
authenticated?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Thrown on a non-2xx response or a GraphQL `errors` payload. Never carries bodies or tokens. */
|
|
39
|
+
export class MagentoError extends Error {
|
|
40
|
+
constructor(
|
|
41
|
+
readonly operation: string,
|
|
42
|
+
readonly status: number,
|
|
43
|
+
/** How many GraphQL errors the response carried (0 for an HTTP failure). */
|
|
44
|
+
readonly graphqlErrors = 0,
|
|
45
|
+
) {
|
|
46
|
+
super(
|
|
47
|
+
graphqlErrors > 0
|
|
48
|
+
? `magento ${operation} returned ${graphqlErrors} GraphQL error(s)`
|
|
49
|
+
: `magento ${operation} failed with HTTP ${status}`,
|
|
50
|
+
);
|
|
51
|
+
this.name = "MagentoError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type MagentoClient = ReturnType<typeof createMagentoClient>;
|
|
56
|
+
|
|
57
|
+
export function createMagentoClient(
|
|
58
|
+
config: MagentoClientConfig,
|
|
59
|
+
options: MagentoClientOptions = {},
|
|
60
|
+
) {
|
|
61
|
+
const instrumented = createInstrumentedFetch({ provider: "magento", fetch: options.fetch });
|
|
62
|
+
const origin = new URL(config.baseUrl).origin;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A request to a path on the store, e.g. `/rest/default/V1/carts/mine` or
|
|
66
|
+
* `/customer/section/load`, returning the raw response. Paths only: the
|
|
67
|
+
* store's credentials never go to another origin.
|
|
68
|
+
*/
|
|
69
|
+
async function request(path: string, init: MagentoRequestInit): Promise<Response> {
|
|
70
|
+
const { operation, authenticated = true, ...rest } = init;
|
|
71
|
+
const url = new URL(path, origin);
|
|
72
|
+
if (url.origin !== origin) {
|
|
73
|
+
throw new Error(`magento ${operation}: only paths on the configured store are allowed`);
|
|
74
|
+
}
|
|
75
|
+
const headers = new Headers(rest.headers);
|
|
76
|
+
if (authenticated && config.apiKey && !headers.has("authorization")) {
|
|
77
|
+
headers.set("authorization", `Bearer ${config.apiKey}`);
|
|
78
|
+
}
|
|
79
|
+
if (config.originHeader) headers.set("x-origin-header", config.originHeader);
|
|
80
|
+
return instrumented(url, { ...rest, headers, operation });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
request,
|
|
85
|
+
|
|
86
|
+
/** A REST call (`/rest/<store>/V1/...`) returning its parsed JSON body. */
|
|
87
|
+
async rest<T>(path: string, init: MagentoRequestInit): Promise<T> {
|
|
88
|
+
const headers = new Headers(init.headers);
|
|
89
|
+
if (init.body !== undefined && !headers.has("content-type")) {
|
|
90
|
+
headers.set("content-type", "application/json");
|
|
91
|
+
}
|
|
92
|
+
const response = await request(path, { ...init, headers });
|
|
93
|
+
if (!response.ok) throw new MagentoError(init.operation, response.status);
|
|
94
|
+
if (response.status === 204) return undefined as T;
|
|
95
|
+
return (await response.json()) as T;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A GraphQL operation on `/graphql`. `operationName` is sent with the
|
|
100
|
+
* request and is the operation label. `headers` adds per-call headers,
|
|
101
|
+
* such as `Store` or a customer `Authorization` token. Storefront
|
|
102
|
+
* GraphQL is public, so the `apiKey` is sent only with
|
|
103
|
+
* `authenticated: true`, and never over a customer token.
|
|
104
|
+
*/
|
|
105
|
+
async graphql<TData, TVariables = Record<string, unknown>>(
|
|
106
|
+
query: string,
|
|
107
|
+
variables: TVariables | undefined,
|
|
108
|
+
init: { operationName: string; headers?: Record<string, string>; authenticated?: boolean },
|
|
109
|
+
): Promise<TData> {
|
|
110
|
+
const { operationName, ...rest } = init;
|
|
111
|
+
const headers = new Headers(rest.headers);
|
|
112
|
+
headers.set("content-type", "application/json");
|
|
113
|
+
const response = await request("/graphql", {
|
|
114
|
+
operation: operationName,
|
|
115
|
+
authenticated: rest.authenticated ?? false,
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers,
|
|
118
|
+
body: JSON.stringify({ query, variables, operationName }),
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok) throw new MagentoError(operationName, response.status);
|
|
121
|
+
const body = (await response.json()) as { data?: TData; errors?: unknown[] };
|
|
122
|
+
if (body.errors?.length) {
|
|
123
|
+
throw new MagentoError(operationName, response.status, body.errors.length);
|
|
124
|
+
}
|
|
125
|
+
if (body.data === undefined) throw new MagentoError(operationName, response.status);
|
|
126
|
+
return body.data;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* after checkout (`changeCardIdAfterCheckout`) and seeded the
|
|
6
6
|
* `form_key` for anonymous sessions. Both flows touched response
|
|
7
7
|
* headers and `customer/section/load` endpoints — non-trivial port,
|
|
8
|
-
* deferred to a follow-up PR. Today
|
|
9
|
-
*
|
|
8
|
+
* deferred to a follow-up PR. Today a production Magento storefront
|
|
9
|
+
* handles cart reconciliation on the client.
|
|
10
10
|
*
|
|
11
11
|
* Shape matches `@decocms/apps-commerce/app-types` so it can be
|
|
12
12
|
* plugged into the autoconfig pipeline once magento is registered
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for the Magento observability wiring added so cache/upstream
|
|
3
|
+
* telemetry flows automatically:
|
|
4
|
+
* - `magentoOperationRouter` names REST resources + GraphQL.
|
|
5
|
+
* - `setMagentoFetch` actually reroutes `magentoFetch`'s egress (the hook
|
|
6
|
+
* that lets `createMagentoFetch()`'s instrumentation take effect).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { magentoOperationRouter } from "../operationRouter";
|
|
10
|
+
|
|
11
|
+
describe("magentoOperationRouter", () => {
|
|
12
|
+
it("names REST resources by their first V1 segment", () => {
|
|
13
|
+
expect(magentoOperationRouter("https://x.com/rest/default/V1/products/42", "GET")).toBe(
|
|
14
|
+
"rest.products",
|
|
15
|
+
);
|
|
16
|
+
expect(magentoOperationRouter("https://x.com/rest/V1/carts/mine", "POST")).toBe("rest.carts");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("names GraphQL calls `graphql`", () => {
|
|
20
|
+
expect(magentoOperationRouter("https://x.com/graphql", "POST")).toBe("graphql");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns undefined when nothing matches (framework falls back)", () => {
|
|
24
|
+
expect(magentoOperationRouter("https://x.com/media/logo.png", "GET")).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("tolerates non-absolute URLs", () => {
|
|
28
|
+
expect(magentoOperationRouter("/rest/default/V1/orders?x=1", "GET")).toBe("rest.orders");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("setMagentoFetch routing", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.resetModules();
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("routes magentoFetch egress through the fetch set via setMagentoFetch", async () => {
|
|
41
|
+
const { configureMagento, setMagentoFetch, magentoFetch } = await import("../../client");
|
|
42
|
+
configureMagento({
|
|
43
|
+
baseUrl: "https://loja.example.com/",
|
|
44
|
+
apiKey: "k",
|
|
45
|
+
storeId: 1,
|
|
46
|
+
site: "example",
|
|
47
|
+
});
|
|
48
|
+
const custom = vi
|
|
49
|
+
.fn()
|
|
50
|
+
.mockResolvedValue(new Response("{}", { status: 200 })) as unknown as typeof fetch;
|
|
51
|
+
const globalSpy = vi.spyOn(globalThis, "fetch");
|
|
52
|
+
setMagentoFetch(custom);
|
|
53
|
+
|
|
54
|
+
await magentoFetch("/rest/default/V1/products/1");
|
|
55
|
+
|
|
56
|
+
expect(custom).toHaveBeenCalledOnce();
|
|
57
|
+
expect(globalSpy).not.toHaveBeenCalled(); // did NOT hit the default fetch
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -24,7 +24,7 @@ export interface Customer {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* `carbono-customer` slice.
|
|
27
|
+
* `carbono-customer` slice. A storefront-specific overlay that mirrors the
|
|
28
28
|
* `customer` slice plus a website/store id pair and a normalized email.
|
|
29
29
|
* Other magento sites that don't run the Carbono module will get this
|
|
30
30
|
* absent; loaders/user.ts checks for it before mapping to a Person.
|
package/src/utils/constants.ts
CHANGED
|
@@ -12,6 +12,31 @@
|
|
|
12
12
|
|
|
13
13
|
import type { FiltersGraphQL } from "../client";
|
|
14
14
|
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// SWR fetch-cache tuning (consumed by utils/fetchCache.ts via the shared
|
|
17
|
+
// `createFetchCache` in @decocms/blocks/sdk/fetchCache). Mirrors the shape of
|
|
18
|
+
// VTEX's constants so Magento's cache posture is tuned in one place.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** Max distinct cache keys kept in memory; oldest `createdAt` evicted first. */
|
|
22
|
+
export const FETCH_CACHE_MAX_ENTRIES = 500;
|
|
23
|
+
|
|
24
|
+
/** How long a cached Magento response stays FRESH, keyed by status class. */
|
|
25
|
+
export const FETCH_CACHE_FRESH_TTL_MS = {
|
|
26
|
+
/** 2xx — 3 min. Catalog/price data doesn't need to be second-fresh. */
|
|
27
|
+
success: 180_000,
|
|
28
|
+
/** 404 — 10s. A just-published product shouldn't 404 for long. */
|
|
29
|
+
notFound: 10_000,
|
|
30
|
+
/** 5xx — never treated as a good cache hit. */
|
|
31
|
+
serverError: 0,
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/** Stale-if-error window: serve last-good this long past freshness on outage. */
|
|
35
|
+
export const FETCH_CACHE_STALE_IF_ERROR_MS = 86_400_000; // 24h
|
|
36
|
+
|
|
37
|
+
/** Inflight-slot backstop: bounds how long one hung fetch holds a dedup slot. */
|
|
38
|
+
export const FETCH_CACHE_INFLIGHT_BACKSTOP_MS = 15_000;
|
|
39
|
+
|
|
15
40
|
export const URL_KEY = "url_key";
|
|
16
41
|
|
|
17
42
|
// Schema.org availability mapping (used by utils/transform.ts to
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento SWR fetch cache — a thin binding over the shared, instrumented
|
|
3
|
+
* `createFetchCache` in `@decocms/blocks/sdk/fetchCache`.
|
|
4
|
+
*
|
|
5
|
+
* Same shared implementation VTEX uses (in-flight dedup, stale-while-
|
|
6
|
+
* revalidate, stale-if-error, inflight backstop), wired with Magento's tuning
|
|
7
|
+
* constants and the `provider: "magento"` label. Every call emits
|
|
8
|
+
* `deco.cache.requests{layer="swr",profile="magento"}` automatically.
|
|
9
|
+
*
|
|
10
|
+
* Cache key is caller-supplied (not required to be a URL): REST GETs key by
|
|
11
|
+
* their URL; GraphQL POSTs can key by a hash of `query + variables`. Callers
|
|
12
|
+
* pass the closure that performs the actual `magentoFetch`, so a HIT never
|
|
13
|
+
* touches the network.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createFetchCache,
|
|
18
|
+
type FetchCacheOptions as SharedFetchCacheOptions,
|
|
19
|
+
} from "@decocms/blocks/sdk/fetchCache";
|
|
20
|
+
import {
|
|
21
|
+
FETCH_CACHE_FRESH_TTL_MS,
|
|
22
|
+
FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
|
|
23
|
+
FETCH_CACHE_MAX_ENTRIES,
|
|
24
|
+
FETCH_CACHE_STALE_IF_ERROR_MS,
|
|
25
|
+
} from "./constants";
|
|
26
|
+
|
|
27
|
+
export type FetchCacheOptions = SharedFetchCacheOptions;
|
|
28
|
+
|
|
29
|
+
const cache = createFetchCache({
|
|
30
|
+
provider: "magento",
|
|
31
|
+
maxEntries: FETCH_CACHE_MAX_ENTRIES,
|
|
32
|
+
freshTtlMs: FETCH_CACHE_FRESH_TTL_MS,
|
|
33
|
+
staleIfErrorMs: FETCH_CACHE_STALE_IF_ERROR_MS,
|
|
34
|
+
inflightBackstopMs: FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap a Magento GET with SWR caching + in-flight dedup. Returns the parsed
|
|
39
|
+
* JSON body, or `null` for cacheable non-2xx responses (e.g. 404). 5xx throw.
|
|
40
|
+
*/
|
|
41
|
+
export function magentoCachedFetch<T>(
|
|
42
|
+
cacheKey: string,
|
|
43
|
+
doFetch: () => Promise<Response>,
|
|
44
|
+
opts?: FetchCacheOptions,
|
|
45
|
+
): Promise<T | null> {
|
|
46
|
+
return cache.fetchWithCache<T>(cacheKey, doFetch, opts);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearFetchCache() {
|
|
50
|
+
cache.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getFetchCacheStats() {
|
|
54
|
+
return cache.getStats();
|
|
55
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-wired instrumented fetch factory for Magento.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `vtex/utils/instrumentedFetch.ts` and `shopify/utils/instrumentedFetch.ts`.
|
|
5
|
+
* Bundles:
|
|
6
|
+
*
|
|
7
|
+
* 1. `createInstrumentedFetch` from `@decocms/blocks/sdk/instrumentedFetch`
|
|
8
|
+
* (spans, traceparent injection, URL redaction, cache-header span attrs).
|
|
9
|
+
* 2. `magentoOperationRouter` as the URL→operation fallback.
|
|
10
|
+
* 3. An `onComplete` that records the canonical
|
|
11
|
+
* `http.client.request.duration` histogram via the framework's
|
|
12
|
+
* `recordCommerceMetric(...)` helper with `provider: "magento"`.
|
|
13
|
+
*
|
|
14
|
+
* Sites opt in once at startup:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { createMagentoFetch, setMagentoFetch } from "@decocms/apps/magento";
|
|
18
|
+
* setMagentoFetch(createMagentoFetch());
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* With this wired, every Magento egress call (GraphQL + REST) funnels through
|
|
22
|
+
* one instrumented boundary, so upstream latency/status lands in ClickHouse.
|
|
23
|
+
* SWR hit/miss for cached GETs is emitted separately by
|
|
24
|
+
* `@decocms/blocks/sdk/fetchCache` (see `./fetchCache.ts`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createInstrumentedFetch,
|
|
29
|
+
type InstrumentedFetch,
|
|
30
|
+
} from "@decocms/blocks/sdk/instrumentedFetch";
|
|
31
|
+
import { recordCommerceMetric, statusClassFor } from "@decocms/blocks/sdk/observability";
|
|
32
|
+
import { magentoOperationRouter } from "./operationRouter";
|
|
33
|
+
|
|
34
|
+
export interface CreateMagentoFetchOptions {
|
|
35
|
+
/** Underlying fetch to wrap. Defaults to `globalThis.fetch`. */
|
|
36
|
+
baseFetch?: typeof fetch;
|
|
37
|
+
/**
|
|
38
|
+
* Disable the `http.client.request.duration` histogram for Magento calls.
|
|
39
|
+
* Spans + structured logs still emit. Default: false.
|
|
40
|
+
*/
|
|
41
|
+
disableHistogram?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Construct a pre-wired Magento `InstrumentedFetch`. Pass the result to
|
|
46
|
+
* `setMagentoFetch(...)`.
|
|
47
|
+
*/
|
|
48
|
+
export function createMagentoFetch(options: CreateMagentoFetchOptions = {}): InstrumentedFetch {
|
|
49
|
+
const { baseFetch, disableHistogram = false } = options;
|
|
50
|
+
return createInstrumentedFetch({
|
|
51
|
+
name: "magento",
|
|
52
|
+
baseFetch,
|
|
53
|
+
resolveOperation: magentoOperationRouter,
|
|
54
|
+
onComplete: disableHistogram ? undefined : (r) =>
|
|
55
|
+
recordCommerceMetric(r.durationMs, { provider: "magento", operation: r.operation, status_class: statusClassFor(r.status), cached: r.cached }),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-derived operation name router for Magento API calls.
|
|
3
|
+
*
|
|
4
|
+
* Plugged into `@decocms/blocks/sdk/instrumentedFetch`'s `resolveOperation`
|
|
5
|
+
* option. Mirrors the VTEX/Shopify routers. Magento's surface from this repo is
|
|
6
|
+
* a mix of GraphQL (`/graphql`) and REST (`/rest/<store>/V1/...`); the URL alone
|
|
7
|
+
* can name the REST resource, while GraphQL calls fall back to a single
|
|
8
|
+
* `graphql` operation (the semantic name lives in the document body, which
|
|
9
|
+
* callers may stamp as `init.operation` to override this router).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string);
|
|
13
|
+
|
|
14
|
+
interface Matcher {
|
|
15
|
+
pattern: RegExp;
|
|
16
|
+
operation: OperationResolver;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation });
|
|
20
|
+
|
|
21
|
+
const MATCHERS: ReadonlyArray<Matcher> = [
|
|
22
|
+
m(/\/graphql\b/, "graphql"),
|
|
23
|
+
// REST: /rest/<storeCode>/V1/<resource>/... — name by the first resource segment.
|
|
24
|
+
m(/\/rest\/[^/]+\/V1\/([^/?#]+)/, (match) => `rest.${match[1]}`),
|
|
25
|
+
m(/\/rest\/V1\/([^/?#]+)/, (match) => `rest.${match[1]}`),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve an operation name for a Magento URL. Returns `undefined` when no
|
|
30
|
+
* matcher fires, so the framework falls back to `magento.fetch`.
|
|
31
|
+
*/
|
|
32
|
+
import { extractPathname } from "@decocms/blocks/sdk/urlUtils";
|
|
33
|
+
|
|
34
|
+
export function magentoOperationRouter(url: string, _method: string): string | undefined {
|
|
35
|
+
const pathname = extractPathname(url);
|
|
36
|
+
|
|
37
|
+
for (const { pattern, operation } of MATCHERS) {
|
|
38
|
+
const match = pathname.match(pattern);
|
|
39
|
+
if (!match) continue;
|
|
40
|
+
return typeof operation === "function" ? operation(match, _method) : operation;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
package/src/utils/transform.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* Subset of `deco-cx/apps/magento/utils/transform.ts` — only the
|
|
7
7
|
* functions the PDP loader needs (toProduct, toOffer, toImages, toURL,
|
|
8
8
|
* toBreadcrumbList, toSeo). The GraphQL-side helpers (toProductGraphQL,
|
|
9
|
-
* toAggOfferGraphQL, toProductListingPageGraphQL, …) and the
|
|
10
|
-
* specific helpers (toReviewAmasty, toLiveloPoints) are intentionally
|
|
9
|
+
* toAggOfferGraphQL, toProductListingPageGraphQL, …) and the
|
|
10
|
+
* storefront-specific helpers (toReviewAmasty, toLiveloPoints) are intentionally
|
|
11
11
|
* excluded — they land in separate follow-up PRs alongside the loaders
|
|
12
12
|
* that consume them.
|
|
13
13
|
*
|