@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/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,25 @@ export interface ShopifyConnectorOptions {
|
|
|
7
7
|
clientSecret?: string;
|
|
8
8
|
appUrl?: string;
|
|
9
9
|
scopes?: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Origin override for every Shopify call — the Admin API and both OAuth endpoints.
|
|
12
|
+
*
|
|
13
|
+
* ABSENT IN PRODUCTION, where it resolves to `https://{store.storeDomain}` and the request is
|
|
14
|
+
* byte-identical to the one this adapter has always made. Present, it points the same code at a
|
|
15
|
+
* local stand-in built from Shopify's documentation, so that a missing app credential stops
|
|
16
|
+
* blocking development.
|
|
17
|
+
*
|
|
18
|
+
* It is an ORIGIN and not a base URL because Shopify's host is per-store: the shop still rides
|
|
19
|
+
* inside the path the adapter appends. A caller pointing at a mock passes
|
|
20
|
+
* `http://127.0.0.1:<port>/shopify`, the adapter appends `/admin/api/{version}/products.json`,
|
|
21
|
+
* and the stand-in serves Shopify's own address table unprefixed.
|
|
22
|
+
*
|
|
23
|
+
* There is deliberately NO `mock` flag and no URL rewriting inside `fetchImpl`. The shipped path
|
|
24
|
+
* must be the tested path; a branch inside the adapter means the code exercised by a test is not
|
|
25
|
+
* the code that runs, and reaching a stand-in by rewriting URLs inside an injected fetch is the
|
|
26
|
+
* same failure wearing a hook.
|
|
27
|
+
*/
|
|
28
|
+
baseUrl?: string;
|
|
10
29
|
}
|
|
11
30
|
export declare const REQUIRED_SCOPES: readonly ["read_products", "read_inventory", "read_orders", "write_orders", "read_fulfillments", "write_products"];
|
|
12
31
|
export declare function shopifyReauthorizeUrl(options: ShopifyConnectorOptions, params: {
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,40 @@ function pricesForVariant(variant, currency) {
|
|
|
55
55
|
...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}),
|
|
56
56
|
}];
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Shopify carries a variant's weight twice: `grams`, which it normalises itself,
|
|
60
|
+
* and `weight` with a `weight_unit`. Prefer `grams`; fall back to converting
|
|
61
|
+
* `weight` only when `grams` is absent or zero.
|
|
62
|
+
*
|
|
63
|
+
* Returns `undefined` — never `0` — when no weight is known, so the caller omits
|
|
64
|
+
* the key entirely. A written `0` is indistinguishable from a genuinely
|
|
65
|
+
* weightless item and would defeat a downstream default-parcel substitution.
|
|
66
|
+
*
|
|
67
|
+
* An unrecognised `weight_unit` is refused rather than assumed to be grams:
|
|
68
|
+
* reading "lbs" (a spelling Shopify does not use, but a proxy might) as grams
|
|
69
|
+
* under-prices a parcel by a factor of 453.
|
|
70
|
+
*/
|
|
71
|
+
function weightGramsForVariant(variant) {
|
|
72
|
+
const { grams, weight, weight_unit: weightUnit } = variant;
|
|
73
|
+
if (typeof grams === "number" && Number.isFinite(grams) && grams > 0) {
|
|
74
|
+
return Math.round(grams);
|
|
75
|
+
}
|
|
76
|
+
if (typeof weight === "number" && Number.isFinite(weight) && weight > 0) {
|
|
77
|
+
switch (weightUnit ?? "g") {
|
|
78
|
+
case "g":
|
|
79
|
+
return Math.round(weight);
|
|
80
|
+
case "kg":
|
|
81
|
+
return Math.round(weight * 1000);
|
|
82
|
+
case "oz":
|
|
83
|
+
return Math.round(weight * 28.349523125);
|
|
84
|
+
case "lb":
|
|
85
|
+
return Math.round(weight * 453.59237);
|
|
86
|
+
default:
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
58
92
|
function catalogStatus(value) {
|
|
59
93
|
return value === "draft" || value === "active" || value === "archived" ? value : undefined;
|
|
60
94
|
}
|
|
@@ -65,8 +99,14 @@ async function fetchShopCurrency(fetchImpl, url, accessToken) {
|
|
|
65
99
|
const result = await request(fetchImpl, url, accessToken);
|
|
66
100
|
return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
|
|
67
101
|
}
|
|
68
|
-
|
|
69
|
-
|
|
102
|
+
/** `baseUrl` when given, else the store's own origin. The single place that choice is made. */
|
|
103
|
+
function shopOrigin(storeDomain, baseUrl) {
|
|
104
|
+
if (baseUrl !== undefined && baseUrl !== "")
|
|
105
|
+
return baseUrl.replace(/\/$/, "");
|
|
106
|
+
return `https://${storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
|
|
107
|
+
}
|
|
108
|
+
function apiBase(store, version, baseUrl) {
|
|
109
|
+
return `${shopOrigin(store.storeDomain, baseUrl)}/admin/api/${version}`;
|
|
70
110
|
}
|
|
71
111
|
function shopifyOAuthStartUrl(appUrl, storeDomain) {
|
|
72
112
|
try {
|
|
@@ -156,7 +196,7 @@ export function shopifyConnector(options = {}) {
|
|
|
156
196
|
if (!validShopDomain(shopDomain))
|
|
157
197
|
return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "Shopify storeDomain must be a *.myshopify.com domain.");
|
|
158
198
|
const scopes = [...new Set([...REQUIRED_SCOPES, ...(options.scopes ?? []), ...params.scopes])];
|
|
159
|
-
const url = new URL(
|
|
199
|
+
const url = new URL(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/authorize`);
|
|
160
200
|
url.searchParams.set("client_id", options.clientId);
|
|
161
201
|
url.searchParams.set("scope", scopes.join(","));
|
|
162
202
|
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
@@ -184,7 +224,7 @@ export function shopifyConnector(options = {}) {
|
|
|
184
224
|
if (!code)
|
|
185
225
|
return oauthError("SHOPIFY_OAUTH_CODE_REQUIRED", "Shopify OAuth callback code is required.");
|
|
186
226
|
try {
|
|
187
|
-
const response = await fetchImpl(
|
|
227
|
+
const response = await fetchImpl(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/access_token`, {
|
|
188
228
|
method: "POST",
|
|
189
229
|
headers: { accept: "application/json", "content-type": "application/json" },
|
|
190
230
|
body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
|
|
@@ -207,20 +247,27 @@ export function shopifyConnector(options = {}) {
|
|
|
207
247
|
const token = credentials(store);
|
|
208
248
|
if (!token)
|
|
209
249
|
return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
|
|
210
|
-
const currencyUrl = `${apiBase(store, version)}/shop.json`;
|
|
211
|
-
const currencyKey = apiBase(store, version);
|
|
250
|
+
const currencyUrl = `${apiBase(store, version, options.baseUrl)}/shop.json`;
|
|
251
|
+
const currencyKey = apiBase(store, version, options.baseUrl);
|
|
212
252
|
let currencyPromise = currencyCache.get(currencyKey);
|
|
213
253
|
if (!currencyPromise) {
|
|
214
254
|
currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
|
|
215
255
|
currencyCache.set(currencyKey, currencyPromise);
|
|
216
256
|
}
|
|
217
257
|
const currency = await currencyPromise;
|
|
218
|
-
const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
|
|
258
|
+
const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250`;
|
|
219
259
|
const result = await request(fetchImpl, url, token);
|
|
220
260
|
if (!result.ok)
|
|
221
261
|
return result;
|
|
222
262
|
const link = result.value.response.headers.get("link") ?? "";
|
|
223
|
-
|
|
263
|
+
// RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both.
|
|
264
|
+
//
|
|
265
|
+
// It read only `rel="next"` before, and the asymmetry is the argument rather than the
|
|
266
|
+
// likelihood: a reader that accepts only the quoted form and meets `rel=next` does not throw
|
|
267
|
+
// — it finds no next link, ends the walk, and reports a SUCCESSFUL import of a partial
|
|
268
|
+
// catalogue. Silent truncation. Accepting both cannot make a malformed header parse as a
|
|
269
|
+
// valid one, so the permissive direction has no matching cost.
|
|
270
|
+
const next = link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
|
|
224
271
|
return Ok({
|
|
225
272
|
items: result.value.data.products.map((product) => {
|
|
226
273
|
const options = product.options?.map((option, index) => ({
|
|
@@ -236,12 +283,14 @@ export function shopifyConnector(options = {}) {
|
|
|
236
283
|
return value != null && value !== "" ? [[option.name, value]] : [];
|
|
237
284
|
}));
|
|
238
285
|
const prices = pricesForVariant(variant, currency);
|
|
286
|
+
const weightGrams = weightGramsForVariant(variant);
|
|
239
287
|
return {
|
|
240
288
|
externalId: String(variant.id),
|
|
241
289
|
...(variant.sku ? { sku: variant.sku } : {}),
|
|
242
290
|
...(variant.barcode ? { barcode: variant.barcode } : {}),
|
|
243
291
|
...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
|
|
244
292
|
...(prices ? { prices } : {}),
|
|
293
|
+
...(weightGrams !== undefined ? { metadata: { weightGrams } } : {}),
|
|
245
294
|
};
|
|
246
295
|
});
|
|
247
296
|
const category = product.product_type ? slugify(product.product_type) : "";
|
|
@@ -279,7 +328,7 @@ export function shopifyConnector(options = {}) {
|
|
|
279
328
|
const params = new URLSearchParams({ limit: "250" });
|
|
280
329
|
if (ids?.length)
|
|
281
330
|
params.set("inventory_item_ids", ids.join(","));
|
|
282
|
-
const result = await request(fetchImpl, `${apiBase(store, version)}/inventory_levels.json?${params}`, token);
|
|
331
|
+
const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/inventory_levels.json?${params}`, token);
|
|
283
332
|
if (!result.ok)
|
|
284
333
|
return result;
|
|
285
334
|
return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
|
|
@@ -300,7 +349,7 @@ export function shopifyConnector(options = {}) {
|
|
|
300
349
|
if (!token)
|
|
301
350
|
return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
|
|
302
351
|
const [firstName, ...lastParts] = slice.customer.name.trim().split(/\s+/);
|
|
303
|
-
const result = await request(fetchImpl, `${apiBase(store, version)}/orders.json`, token, {
|
|
352
|
+
const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders.json`, token, {
|
|
304
353
|
method: "POST",
|
|
305
354
|
headers: { "content-type": "application/json", "idempotency-key": `porulle:${slice.orderId}` },
|
|
306
355
|
body: JSON.stringify({ order: {
|
|
@@ -314,13 +363,13 @@ export function shopifyConnector(options = {}) {
|
|
|
314
363
|
if (!result.ok)
|
|
315
364
|
return result;
|
|
316
365
|
const id = String(result.value.data.order.id);
|
|
317
|
-
return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version)}/orders/${id}.json` });
|
|
366
|
+
return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version, options.baseUrl)}/orders/${id}.json` });
|
|
318
367
|
},
|
|
319
368
|
async fetchOrderStatus(store, remoteId) {
|
|
320
369
|
const token = credentials(store);
|
|
321
370
|
if (!token)
|
|
322
371
|
return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
|
|
323
|
-
const result = await request(fetchImpl, `${apiBase(store, version)}/orders/${encodeURIComponent(remoteId)}.json`, token);
|
|
372
|
+
const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders/${encodeURIComponent(remoteId)}.json`, token);
|
|
324
373
|
return result.ok ? Ok(shopifyStatus(result.value.data.order)) : result;
|
|
325
374
|
},
|
|
326
375
|
async verifyWebhook(_store, request) {
|
|
@@ -371,7 +420,7 @@ export function shopifyConnector(options = {}) {
|
|
|
371
420
|
if (!token)
|
|
372
421
|
return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
|
|
373
422
|
for (const topic of topics) {
|
|
374
|
-
const result = await request(fetchImpl, `${apiBase(store, version)}/webhooks.json`, token, {
|
|
423
|
+
const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/webhooks.json`, token, {
|
|
375
424
|
method: "POST",
|
|
376
425
|
headers: { "content-type": "application/json" },
|
|
377
426
|
body: JSON.stringify({ webhook: { topic, address: callbackUrl, format: "json" } }),
|