@porulle/adapter-shopify 0.11.0 → 0.13.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/README.md +21 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +35 -2
- package/dist/push-catalog.d.ts +18 -0
- package/dist/push-catalog.js +401 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +59 -3
- package/src/push-catalog.ts +574 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-shopify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.13.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/typescript-config": "0.1.0",
|
|
21
|
+
"@porulle/eslint-config": "0.1.0"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
|
24
24
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -6,11 +6,26 @@ import type {
|
|
|
6
6
|
ChannelConnector,
|
|
7
7
|
ChannelConnectorError,
|
|
8
8
|
ChannelInventoryLevel,
|
|
9
|
+
ChannelPushCatalogItem,
|
|
10
|
+
ChannelPushCatalogResult,
|
|
9
11
|
ChannelStore,
|
|
10
12
|
ChannelOrderSlice,
|
|
11
13
|
ChannelOrderStatus,
|
|
12
14
|
Result,
|
|
13
15
|
} from "@porulle/core";
|
|
16
|
+
import {
|
|
17
|
+
pushCatalog as executePushCatalog,
|
|
18
|
+
} from "./push-catalog.js";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
PORULLE_METAFIELD_NAMESPACE,
|
|
22
|
+
PUSH_CATALOG_SCOPE,
|
|
23
|
+
SHOPIFY_NATIVE_PRODUCT_FIELDS,
|
|
24
|
+
SHOPIFY_NATIVE_VARIANT_FIELDS,
|
|
25
|
+
shopifyGrantedScopes,
|
|
26
|
+
shopifyPushCatalogEnabled,
|
|
27
|
+
shopifyWriteProductsScopeMissingError,
|
|
28
|
+
} from "./push-catalog.js";
|
|
14
29
|
|
|
15
30
|
export interface ShopifyConnectorOptions {
|
|
16
31
|
fetchImpl?: typeof fetch;
|
|
@@ -27,6 +42,7 @@ export const REQUIRED_SCOPES = [
|
|
|
27
42
|
"read_orders",
|
|
28
43
|
"write_orders",
|
|
29
44
|
"read_fulfillments",
|
|
45
|
+
"write_products",
|
|
30
46
|
] as const;
|
|
31
47
|
|
|
32
48
|
type ShopifyProduct = {
|
|
@@ -126,6 +142,16 @@ function apiBase(store: ChannelStore, version: string): string {
|
|
|
126
142
|
return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
|
|
127
143
|
}
|
|
128
144
|
|
|
145
|
+
function shopifyOAuthStartUrl(appUrl: string, storeDomain: string): string | undefined {
|
|
146
|
+
try {
|
|
147
|
+
const url = new URL("/api/channels/oauth/shopify/start", appUrl);
|
|
148
|
+
url.searchParams.set("shop", storeDomain);
|
|
149
|
+
return url.toString();
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
129
155
|
async function request<T>(fetchImpl: typeof fetch, url: string, accessToken: string, init?: RequestInit): Promise<Result<{ data: T; response: Response }>> {
|
|
130
156
|
try {
|
|
131
157
|
const response = await fetchImpl(url, {
|
|
@@ -183,13 +209,29 @@ function oauthError(code: string, message: string): Result<never, ChannelConnect
|
|
|
183
209
|
return Err({ code, message, retriable: false });
|
|
184
210
|
}
|
|
185
211
|
|
|
212
|
+
export function shopifyReauthorizeUrl(
|
|
213
|
+
options: ShopifyConnectorOptions,
|
|
214
|
+
params: {
|
|
215
|
+
storeDomain: string;
|
|
216
|
+
state: string;
|
|
217
|
+
redirectUri: string;
|
|
218
|
+
callbackUri: string;
|
|
219
|
+
scopes?: string[];
|
|
220
|
+
},
|
|
221
|
+
): Result<string, ChannelConnectorError> {
|
|
222
|
+
return shopifyConnector(options).buildAuthUrl!({
|
|
223
|
+
...params,
|
|
224
|
+
scopes: params.scopes ?? [],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
186
228
|
export function shopifyConnector(options: ShopifyConnectorOptions = {}): ChannelConnector {
|
|
187
229
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
188
230
|
const version = options.apiVersion ?? "2024-10";
|
|
189
231
|
const currencyCache = new Map<string, Promise<string | undefined>>();
|
|
190
232
|
return defineChannelConnector({
|
|
191
233
|
providerId: "shopify",
|
|
192
|
-
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
|
|
234
|
+
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
|
|
193
235
|
buildAuthUrl(params) {
|
|
194
236
|
if (!options.clientId || !options.clientSecret || !options.appUrl) {
|
|
195
237
|
return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
|
|
@@ -230,9 +272,12 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
230
272
|
body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
|
|
231
273
|
});
|
|
232
274
|
if (!response.ok) return oauthError("SHOPIFY_TOKEN_EXCHANGE_FAILED", `Shopify token exchange failed (${response.status}).`);
|
|
233
|
-
const body = await response.json() as { access_token?: unknown };
|
|
275
|
+
const body = await response.json() as { access_token?: unknown; scope?: unknown };
|
|
234
276
|
if (typeof body.access_token !== "string" || !body.access_token) return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify token exchange did not return an access token.");
|
|
235
|
-
|
|
277
|
+
const grantedScopes = typeof body.scope === "string"
|
|
278
|
+
? body.scope.split(",").map((scope) => scope.trim()).filter(Boolean)
|
|
279
|
+
: [];
|
|
280
|
+
return Ok({ credentials: { accessToken: body.access_token, grantedScopes }, storeDomain: shopDomain });
|
|
236
281
|
} catch (error) {
|
|
237
282
|
return Err({ code: "SHOPIFY_TOKEN_EXCHANGE_FAILED", message: error instanceof Error ? error.message : "Shopify token exchange failed.", retriable: true });
|
|
238
283
|
}
|
|
@@ -313,6 +358,17 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
313
358
|
if (!result.ok) return result;
|
|
314
359
|
return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
|
|
315
360
|
},
|
|
361
|
+
async pushCatalog(store: ChannelStore, items: ChannelPushCatalogItem[], opts?: { dryRun?: boolean }): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>> {
|
|
362
|
+
const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;
|
|
363
|
+
return executePushCatalog({
|
|
364
|
+
fetchImpl,
|
|
365
|
+
apiBase: (target) => apiBase(target, version),
|
|
366
|
+
credentials,
|
|
367
|
+
}, store, items, {
|
|
368
|
+
...(opts?.dryRun === true ? { dryRun: true } : {}),
|
|
369
|
+
...(oauthStartUrl ? { reauthorizeUrl: oauthStartUrl } : {}),
|
|
370
|
+
});
|
|
371
|
+
},
|
|
316
372
|
async pushOrder(store, slice: ChannelOrderSlice) {
|
|
317
373
|
const token = credentials(store);
|
|
318
374
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
|