@porulle/adapter-shopify 0.38.0 → 0.39.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
@@ -65,8 +65,14 @@ async function fetchShopCurrency(fetchImpl, url, accessToken) {
65
65
  const result = await request(fetchImpl, url, accessToken);
66
66
  return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
67
67
  }
68
- function apiBase(store, version) {
69
- return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
68
+ /** `baseUrl` when given, else the store's own origin. The single place that choice is made. */
69
+ function shopOrigin(storeDomain, baseUrl) {
70
+ if (baseUrl !== undefined && baseUrl !== "")
71
+ return baseUrl.replace(/\/$/, "");
72
+ return `https://${storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
73
+ }
74
+ function apiBase(store, version, baseUrl) {
75
+ return `${shopOrigin(store.storeDomain, baseUrl)}/admin/api/${version}`;
70
76
  }
71
77
  function shopifyOAuthStartUrl(appUrl, storeDomain) {
72
78
  try {
@@ -156,7 +162,7 @@ export function shopifyConnector(options = {}) {
156
162
  if (!validShopDomain(shopDomain))
157
163
  return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "Shopify storeDomain must be a *.myshopify.com domain.");
158
164
  const scopes = [...new Set([...REQUIRED_SCOPES, ...(options.scopes ?? []), ...params.scopes])];
159
- const url = new URL(`https://${shopDomain}/admin/oauth/authorize`);
165
+ const url = new URL(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/authorize`);
160
166
  url.searchParams.set("client_id", options.clientId);
161
167
  url.searchParams.set("scope", scopes.join(","));
162
168
  url.searchParams.set("redirect_uri", params.redirectUri);
@@ -184,7 +190,7 @@ export function shopifyConnector(options = {}) {
184
190
  if (!code)
185
191
  return oauthError("SHOPIFY_OAUTH_CODE_REQUIRED", "Shopify OAuth callback code is required.");
186
192
  try {
187
- const response = await fetchImpl(`https://${shopDomain}/admin/oauth/access_token`, {
193
+ const response = await fetchImpl(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/access_token`, {
188
194
  method: "POST",
189
195
  headers: { accept: "application/json", "content-type": "application/json" },
190
196
  body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
@@ -207,20 +213,27 @@ export function shopifyConnector(options = {}) {
207
213
  const token = credentials(store);
208
214
  if (!token)
209
215
  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);
216
+ const currencyUrl = `${apiBase(store, version, options.baseUrl)}/shop.json`;
217
+ const currencyKey = apiBase(store, version, options.baseUrl);
212
218
  let currencyPromise = currencyCache.get(currencyKey);
213
219
  if (!currencyPromise) {
214
220
  currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
215
221
  currencyCache.set(currencyKey, currencyPromise);
216
222
  }
217
223
  const currency = await currencyPromise;
218
- const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
224
+ const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250`;
219
225
  const result = await request(fetchImpl, url, token);
220
226
  if (!result.ok)
221
227
  return result;
222
228
  const link = result.value.response.headers.get("link") ?? "";
223
- const next = link.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
229
+ // RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both.
230
+ //
231
+ // It read only `rel="next"` before, and the asymmetry is the argument rather than the
232
+ // likelihood: a reader that accepts only the quoted form and meets `rel=next` does not throw
233
+ // — it finds no next link, ends the walk, and reports a SUCCESSFUL import of a partial
234
+ // catalogue. Silent truncation. Accepting both cannot make a malformed header parse as a
235
+ // valid one, so the permissive direction has no matching cost.
236
+ const next = link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
224
237
  return Ok({
225
238
  items: result.value.data.products.map((product) => {
226
239
  const options = product.options?.map((option, index) => ({
@@ -279,7 +292,7 @@ export function shopifyConnector(options = {}) {
279
292
  const params = new URLSearchParams({ limit: "250" });
280
293
  if (ids?.length)
281
294
  params.set("inventory_item_ids", ids.join(","));
282
- const result = await request(fetchImpl, `${apiBase(store, version)}/inventory_levels.json?${params}`, token);
295
+ const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/inventory_levels.json?${params}`, token);
283
296
  if (!result.ok)
284
297
  return result;
285
298
  return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
@@ -300,7 +313,7 @@ export function shopifyConnector(options = {}) {
300
313
  if (!token)
301
314
  return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
302
315
  const [firstName, ...lastParts] = slice.customer.name.trim().split(/\s+/);
303
- const result = await request(fetchImpl, `${apiBase(store, version)}/orders.json`, token, {
316
+ const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders.json`, token, {
304
317
  method: "POST",
305
318
  headers: { "content-type": "application/json", "idempotency-key": `porulle:${slice.orderId}` },
306
319
  body: JSON.stringify({ order: {
@@ -314,13 +327,13 @@ export function shopifyConnector(options = {}) {
314
327
  if (!result.ok)
315
328
  return result;
316
329
  const id = String(result.value.data.order.id);
317
- return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version)}/orders/${id}.json` });
330
+ return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version, options.baseUrl)}/orders/${id}.json` });
318
331
  },
319
332
  async fetchOrderStatus(store, remoteId) {
320
333
  const token = credentials(store);
321
334
  if (!token)
322
335
  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);
336
+ const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders/${encodeURIComponent(remoteId)}.json`, token);
324
337
  return result.ok ? Ok(shopifyStatus(result.value.data.order)) : result;
325
338
  },
326
339
  async verifyWebhook(_store, request) {
@@ -371,7 +384,7 @@ export function shopifyConnector(options = {}) {
371
384
  if (!token)
372
385
  return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
373
386
  for (const topic of topics) {
374
- const result = await request(fetchImpl, `${apiBase(store, version)}/webhooks.json`, token, {
387
+ const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/webhooks.json`, token, {
375
388
  method: "POST",
376
389
  headers: { "content-type": "application/json" },
377
390
  body: JSON.stringify({ webhook: { topic, address: callbackUrl, format: "json" } }),