@porulle/adapter-shopify 0.38.0 → 0.40.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/dist/index.d.ts +19 -0
- package/dist/index.js +62 -13
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +90 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-shopify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@porulle/core": "0.
|
|
13
|
+
"@porulle/core": "0.40.0"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^24.5.2",
|
|
17
17
|
"eslint": "^9.39.1",
|
|
18
18
|
"typescript": "5.9.2",
|
|
19
19
|
"vitest": "^3.2.4",
|
|
20
|
-
"@porulle/
|
|
21
|
-
"@porulle/
|
|
20
|
+
"@porulle/eslint-config": "0.1.0",
|
|
21
|
+
"@porulle/typescript-config": "0.1.0"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
|
24
24
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,25 @@ export interface ShopifyConnectorOptions {
|
|
|
34
34
|
clientSecret?: string;
|
|
35
35
|
appUrl?: string;
|
|
36
36
|
scopes?: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Origin override for every Shopify call — the Admin API and both OAuth endpoints.
|
|
39
|
+
*
|
|
40
|
+
* ABSENT IN PRODUCTION, where it resolves to `https://{store.storeDomain}` and the request is
|
|
41
|
+
* byte-identical to the one this adapter has always made. Present, it points the same code at a
|
|
42
|
+
* local stand-in built from Shopify's documentation, so that a missing app credential stops
|
|
43
|
+
* blocking development.
|
|
44
|
+
*
|
|
45
|
+
* It is an ORIGIN and not a base URL because Shopify's host is per-store: the shop still rides
|
|
46
|
+
* inside the path the adapter appends. A caller pointing at a mock passes
|
|
47
|
+
* `http://127.0.0.1:<port>/shopify`, the adapter appends `/admin/api/{version}/products.json`,
|
|
48
|
+
* and the stand-in serves Shopify's own address table unprefixed.
|
|
49
|
+
*
|
|
50
|
+
* There is deliberately NO `mock` flag and no URL rewriting inside `fetchImpl`. The shipped path
|
|
51
|
+
* must be the tested path; a branch inside the adapter means the code exercised by a test is not
|
|
52
|
+
* the code that runs, and reaching a stand-in by rewriting URLs inside an injected fetch is the
|
|
53
|
+
* same failure wearing a hook.
|
|
54
|
+
*/
|
|
55
|
+
baseUrl?: string;
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
export const REQUIRED_SCOPES = [
|
|
@@ -75,6 +94,9 @@ type ShopifyProduct = {
|
|
|
75
94
|
option1?: string | null;
|
|
76
95
|
option2?: string | null;
|
|
77
96
|
option3?: string | null;
|
|
97
|
+
grams?: number | null;
|
|
98
|
+
weight?: number | null;
|
|
99
|
+
weight_unit?: string | null;
|
|
78
100
|
}>;
|
|
79
101
|
};
|
|
80
102
|
|
|
@@ -125,6 +147,46 @@ function pricesForVariant(
|
|
|
125
147
|
}];
|
|
126
148
|
}
|
|
127
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Shopify carries a variant's weight twice: `grams`, which it normalises itself,
|
|
152
|
+
* and `weight` with a `weight_unit`. Prefer `grams`; fall back to converting
|
|
153
|
+
* `weight` only when `grams` is absent or zero.
|
|
154
|
+
*
|
|
155
|
+
* Returns `undefined` — never `0` — when no weight is known, so the caller omits
|
|
156
|
+
* the key entirely. A written `0` is indistinguishable from a genuinely
|
|
157
|
+
* weightless item and would defeat a downstream default-parcel substitution.
|
|
158
|
+
*
|
|
159
|
+
* An unrecognised `weight_unit` is refused rather than assumed to be grams:
|
|
160
|
+
* reading "lbs" (a spelling Shopify does not use, but a proxy might) as grams
|
|
161
|
+
* under-prices a parcel by a factor of 453.
|
|
162
|
+
*/
|
|
163
|
+
function weightGramsForVariant(
|
|
164
|
+
variant: NonNullable<ShopifyProduct["variants"]>[number],
|
|
165
|
+
): number | undefined {
|
|
166
|
+
const { grams, weight, weight_unit: weightUnit } = variant;
|
|
167
|
+
|
|
168
|
+
if (typeof grams === "number" && Number.isFinite(grams) && grams > 0) {
|
|
169
|
+
return Math.round(grams);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (typeof weight === "number" && Number.isFinite(weight) && weight > 0) {
|
|
173
|
+
switch (weightUnit ?? "g") {
|
|
174
|
+
case "g":
|
|
175
|
+
return Math.round(weight);
|
|
176
|
+
case "kg":
|
|
177
|
+
return Math.round(weight * 1000);
|
|
178
|
+
case "oz":
|
|
179
|
+
return Math.round(weight * 28.349523125);
|
|
180
|
+
case "lb":
|
|
181
|
+
return Math.round(weight * 453.59237);
|
|
182
|
+
default:
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
128
190
|
function catalogStatus(value: string | null | undefined): "draft" | "active" | "archived" | undefined {
|
|
129
191
|
return value === "draft" || value === "active" || value === "archived" ? value : undefined;
|
|
130
192
|
}
|
|
@@ -138,8 +200,14 @@ async function fetchShopCurrency(fetchImpl: typeof fetch, url: string, accessTok
|
|
|
138
200
|
return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
|
|
139
201
|
}
|
|
140
202
|
|
|
141
|
-
|
|
142
|
-
|
|
203
|
+
/** `baseUrl` when given, else the store's own origin. The single place that choice is made. */
|
|
204
|
+
function shopOrigin(storeDomain: string, baseUrl: string | undefined): string {
|
|
205
|
+
if (baseUrl !== undefined && baseUrl !== "") return baseUrl.replace(/\/$/, "");
|
|
206
|
+
return `https://${storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function apiBase(store: ChannelStore, version: string, baseUrl?: string): string {
|
|
210
|
+
return `${shopOrigin(store.storeDomain, baseUrl)}/admin/api/${version}`;
|
|
143
211
|
}
|
|
144
212
|
|
|
145
213
|
function shopifyOAuthStartUrl(appUrl: string, storeDomain: string): string | undefined {
|
|
@@ -239,7 +307,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
239
307
|
const shopDomain = params.storeDomain.toLowerCase();
|
|
240
308
|
if (!validShopDomain(shopDomain)) return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "Shopify storeDomain must be a *.myshopify.com domain.");
|
|
241
309
|
const scopes = [...new Set([...REQUIRED_SCOPES, ...(options.scopes ?? []), ...params.scopes])];
|
|
242
|
-
const url = new URL(
|
|
310
|
+
const url = new URL(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/authorize`);
|
|
243
311
|
url.searchParams.set("client_id", options.clientId);
|
|
244
312
|
url.searchParams.set("scope", scopes.join(","));
|
|
245
313
|
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
@@ -266,7 +334,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
266
334
|
const code = url.searchParams.get("code");
|
|
267
335
|
if (!code) return oauthError("SHOPIFY_OAUTH_CODE_REQUIRED", "Shopify OAuth callback code is required.");
|
|
268
336
|
try {
|
|
269
|
-
const response = await fetchImpl(
|
|
337
|
+
const response = await fetchImpl(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/access_token`, {
|
|
270
338
|
method: "POST",
|
|
271
339
|
headers: { accept: "application/json", "content-type": "application/json" },
|
|
272
340
|
body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
|
|
@@ -285,19 +353,26 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
285
353
|
async importCatalog(store, cursor): Promise<Result<ChannelCatalogPage>> {
|
|
286
354
|
const token = credentials(store);
|
|
287
355
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
|
|
288
|
-
const currencyUrl = `${apiBase(store, version)}/shop.json`;
|
|
289
|
-
const currencyKey = apiBase(store, version);
|
|
356
|
+
const currencyUrl = `${apiBase(store, version, options.baseUrl)}/shop.json`;
|
|
357
|
+
const currencyKey = apiBase(store, version, options.baseUrl);
|
|
290
358
|
let currencyPromise = currencyCache.get(currencyKey);
|
|
291
359
|
if (!currencyPromise) {
|
|
292
360
|
currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
|
|
293
361
|
currencyCache.set(currencyKey, currencyPromise);
|
|
294
362
|
}
|
|
295
363
|
const currency = await currencyPromise;
|
|
296
|
-
const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
|
|
364
|
+
const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250`;
|
|
297
365
|
const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
|
|
298
366
|
if (!result.ok) return result;
|
|
299
367
|
const link = result.value.response.headers.get("link") ?? "";
|
|
300
|
-
|
|
368
|
+
// RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both.
|
|
369
|
+
//
|
|
370
|
+
// It read only `rel="next"` before, and the asymmetry is the argument rather than the
|
|
371
|
+
// likelihood: a reader that accepts only the quoted form and meets `rel=next` does not throw
|
|
372
|
+
// — it finds no next link, ends the walk, and reports a SUCCESSFUL import of a partial
|
|
373
|
+
// catalogue. Silent truncation. Accepting both cannot make a malformed header parse as a
|
|
374
|
+
// valid one, so the permissive direction has no matching cost.
|
|
375
|
+
const next = link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
|
|
301
376
|
return Ok({
|
|
302
377
|
items: result.value.data.products.map((product) => {
|
|
303
378
|
const options = product.options?.map((option, index) => ({
|
|
@@ -313,12 +388,14 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
313
388
|
return value != null && value !== "" ? [[option.name, value] as const] : [];
|
|
314
389
|
}));
|
|
315
390
|
const prices = pricesForVariant(variant, currency);
|
|
391
|
+
const weightGrams = weightGramsForVariant(variant);
|
|
316
392
|
return {
|
|
317
393
|
externalId: String(variant.id),
|
|
318
394
|
...(variant.sku ? { sku: variant.sku } : {}),
|
|
319
395
|
...(variant.barcode ? { barcode: variant.barcode } : {}),
|
|
320
396
|
...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
|
|
321
397
|
...(prices ? { prices } : {}),
|
|
398
|
+
...(weightGrams !== undefined ? { metadata: { weightGrams } } : {}),
|
|
322
399
|
};
|
|
323
400
|
});
|
|
324
401
|
const category = product.product_type ? slugify(product.product_type) : "";
|
|
@@ -354,7 +431,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
354
431
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
|
|
355
432
|
const params = new URLSearchParams({ limit: "250" });
|
|
356
433
|
if (ids?.length) params.set("inventory_item_ids", ids.join(","));
|
|
357
|
-
const result = await request<{ inventory_levels: Array<{ inventory_item_id: number | string; available: number | null }> }>(fetchImpl, `${apiBase(store, version)}/inventory_levels.json?${params}`, token);
|
|
434
|
+
const result = await request<{ inventory_levels: Array<{ inventory_item_id: number | string; available: number | null }> }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/inventory_levels.json?${params}`, token);
|
|
358
435
|
if (!result.ok) return result;
|
|
359
436
|
return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
|
|
360
437
|
},
|
|
@@ -373,7 +450,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
373
450
|
const token = credentials(store);
|
|
374
451
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
|
|
375
452
|
const [firstName, ...lastParts] = slice.customer.name.trim().split(/\s+/);
|
|
376
|
-
const result = await request<{ order: { id: number | string; admin_graphql_api_id?: string } }>(fetchImpl, `${apiBase(store, version)}/orders.json`, token, {
|
|
453
|
+
const result = await request<{ order: { id: number | string; admin_graphql_api_id?: string } }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders.json`, token, {
|
|
377
454
|
method: "POST",
|
|
378
455
|
headers: { "content-type": "application/json", "idempotency-key": `porulle:${slice.orderId}` },
|
|
379
456
|
body: JSON.stringify({ order: {
|
|
@@ -386,12 +463,12 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
386
463
|
});
|
|
387
464
|
if (!result.ok) return result;
|
|
388
465
|
const id = String(result.value.data.order.id);
|
|
389
|
-
return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version)}/orders/${id}.json` });
|
|
466
|
+
return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version, options.baseUrl)}/orders/${id}.json` });
|
|
390
467
|
},
|
|
391
468
|
async fetchOrderStatus(store, remoteId) {
|
|
392
469
|
const token = credentials(store);
|
|
393
470
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
|
|
394
|
-
const result = await request<{ order: { financial_status?: string | null; fulfillment_status?: string | null; cancelled_at?: string | null } }>(fetchImpl, `${apiBase(store, version)}/orders/${encodeURIComponent(remoteId)}.json`, token);
|
|
471
|
+
const result = await request<{ order: { financial_status?: string | null; fulfillment_status?: string | null; cancelled_at?: string | null } }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders/${encodeURIComponent(remoteId)}.json`, token);
|
|
395
472
|
return result.ok ? Ok(shopifyStatus(result.value.data.order)) : result;
|
|
396
473
|
},
|
|
397
474
|
async verifyWebhook(_store, request) {
|
|
@@ -437,7 +514,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
437
514
|
const token = credentials(store);
|
|
438
515
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
|
|
439
516
|
for (const topic of topics) {
|
|
440
|
-
const result = await request<{ webhook: unknown }>(fetchImpl, `${apiBase(store, version)}/webhooks.json`, token, {
|
|
517
|
+
const result = await request<{ webhook: unknown }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/webhooks.json`, token, {
|
|
441
518
|
method: "POST",
|
|
442
519
|
headers: { "content-type": "application/json" },
|
|
443
520
|
body: JSON.stringify({ webhook: { topic, address: callbackUrl, format: "json" } }),
|