@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 +19 -0
- package/dist/index.js +26 -13
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +45 -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
|
@@ -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
|
-
|
|
69
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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" } }),
|