@decocms/apps-magento 7.72.0 → 8.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-magento",
3
- "version": "7.72.0",
3
+ "version": "8.0.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": "7.72.0",
30
- "@decocms/apps-commerce": "7.72.0",
31
- "@decocms/tanstack": "7.72.0"
29
+ "@decocms/blocks": "8.0.0-next.0",
30
+ "@decocms/apps-commerce": "8.0.0-next.0",
31
+ "@decocms/tanstack": "8.0.0-next.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
@@ -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/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,6 +11,14 @@
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";
12
23
  export {
13
24
  clearFetchCache,
@@ -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
+ }