@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 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
- function apiBase(store, version) {
69
- return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
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(`https://${shopDomain}/admin/oauth/authorize`);
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(`https://${shopDomain}/admin/oauth/access_token`, {
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
- const next = link.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
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" } }),