react-shopwave-connect 0.2.0 → 0.4.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 +215 -24
- package/dist/core/index.cjs +903 -88
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +604 -23
- package/dist/core/index.d.ts +604 -23
- package/dist/core/index.js +845 -89
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +735 -109
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +263 -12
- package/dist/hooks/index.d.ts +263 -12
- package/dist/hooks/index.js +732 -111
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +1087 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +670 -24
- package/dist/index.d.ts +670 -24
- package/dist/index.js +1025 -109
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +560 -2
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +249 -1
- package/dist/next/index.d.ts +249 -1
- package/dist/next/index.js +556 -3
- package/dist/next/index.js.map +1 -1
- package/dist/server/index.cjs +531 -1
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +258 -1
- package/dist/server/index.d.ts +258 -1
- package/dist/server/index.js +521 -2
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/next/index.cjs
CHANGED
|
@@ -116,7 +116,8 @@ function authorizationHeader(token) {
|
|
|
116
116
|
*/
|
|
117
117
|
function isExpiredTokenResponse(status, body) {
|
|
118
118
|
if (status === 401) return true;
|
|
119
|
-
const
|
|
119
|
+
const message = body?.api?.message;
|
|
120
|
+
const errors = message?.errors ?? message?.error;
|
|
120
121
|
if (!errors || typeof errors !== "object") return false;
|
|
121
122
|
return Object.entries(errors).some(([key, value]) => key === String(SHOPWAVE_TOKEN_EXPIRED_ERROR_ID) || Number(value?.id) === SHOPWAVE_TOKEN_EXPIRED_ERROR_ID);
|
|
122
123
|
}
|
|
@@ -273,6 +274,524 @@ function safeEqual(a, b) {
|
|
|
273
274
|
return diff === 0;
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/core/entities.ts
|
|
279
|
+
/**
|
|
280
|
+
* Addressing per entity, following the Shopwave API reference
|
|
281
|
+
* (https://developer.merchantstack.com/api-reference.html) and checked against
|
|
282
|
+
* the live API where noted:
|
|
283
|
+
* - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
|
|
284
|
+
* - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
|
|
285
|
+
* - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
|
|
286
|
+
* changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
|
|
287
|
+
* - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
|
|
288
|
+
*/
|
|
289
|
+
const SHOPWAVE_ENTITIES = Object.freeze({
|
|
290
|
+
product: {
|
|
291
|
+
route: "products",
|
|
292
|
+
collection: "products",
|
|
293
|
+
upstream: "product",
|
|
294
|
+
idsHeader: "productIds",
|
|
295
|
+
idHeader: "productId",
|
|
296
|
+
writable: true,
|
|
297
|
+
deleteMode: "delete"
|
|
298
|
+
},
|
|
299
|
+
category: {
|
|
300
|
+
route: "categories",
|
|
301
|
+
collection: "categories",
|
|
302
|
+
upstream: "category",
|
|
303
|
+
idsHeader: "categoryIds",
|
|
304
|
+
idHeader: "categoryId",
|
|
305
|
+
writable: true,
|
|
306
|
+
deleteMode: "delete"
|
|
307
|
+
},
|
|
308
|
+
store: {
|
|
309
|
+
route: "stores",
|
|
310
|
+
collection: "stores",
|
|
311
|
+
upstream: "store",
|
|
312
|
+
idsHeader: "storeIds",
|
|
313
|
+
idHeader: "storeId",
|
|
314
|
+
writable: true,
|
|
315
|
+
deleteMode: "delete"
|
|
316
|
+
},
|
|
317
|
+
promotion: {
|
|
318
|
+
route: "promotions",
|
|
319
|
+
collection: "promotions",
|
|
320
|
+
upstream: "promotion",
|
|
321
|
+
idsHeader: "promotionIds",
|
|
322
|
+
idHeader: "promotionId",
|
|
323
|
+
writable: true,
|
|
324
|
+
deleteMode: "retire",
|
|
325
|
+
retireField: "endDate"
|
|
326
|
+
},
|
|
327
|
+
employee: {
|
|
328
|
+
route: "employees",
|
|
329
|
+
collection: "employees",
|
|
330
|
+
upstream: "employee",
|
|
331
|
+
idsHeader: "employeeIds",
|
|
332
|
+
idHeader: "employeeId",
|
|
333
|
+
writable: true,
|
|
334
|
+
deleteMode: "retire",
|
|
335
|
+
retireField: "exitDate"
|
|
336
|
+
},
|
|
337
|
+
consumer: {
|
|
338
|
+
route: "consumer",
|
|
339
|
+
collection: "consumers",
|
|
340
|
+
upstream: "consumer",
|
|
341
|
+
idsHeader: "ids",
|
|
342
|
+
idHeader: "consumerId",
|
|
343
|
+
writable: false,
|
|
344
|
+
deleteMode: "none"
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/core/resources.ts
|
|
350
|
+
/**
|
|
351
|
+
* - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
|
|
352
|
+
* updates it (without an `id` the API would create a second merchant, so the
|
|
353
|
+
* route requires one).
|
|
354
|
+
* - user: `GET /user` (read-only here; the API's `POST /user` invites users and
|
|
355
|
+
* needs the admin scope).
|
|
356
|
+
*/
|
|
357
|
+
const SHOPWAVE_RESOURCES = Object.freeze({
|
|
358
|
+
merchant: {
|
|
359
|
+
route: "merchant",
|
|
360
|
+
key: "merchant",
|
|
361
|
+
upstream: "merchant",
|
|
362
|
+
writable: true
|
|
363
|
+
},
|
|
364
|
+
user: {
|
|
365
|
+
route: "user",
|
|
366
|
+
key: "user",
|
|
367
|
+
upstream: "user",
|
|
368
|
+
writable: false
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
/** `UploadKind` → the API's `contentType` value. */
|
|
372
|
+
const UPLOAD_KINDS = Object.freeze({
|
|
373
|
+
merchant: "merchant",
|
|
374
|
+
merchantFeature: "merchantFeature",
|
|
375
|
+
product: "product",
|
|
376
|
+
user: "user",
|
|
377
|
+
applicationLogo: "applicaionLogo",
|
|
378
|
+
applicationImages: "applicaionImages"
|
|
379
|
+
});
|
|
380
|
+
/** How uploads are addressed (app route and Shopwave uploader). */
|
|
381
|
+
const SHOPWAVE_UPLOAD = Object.freeze({
|
|
382
|
+
route: "upload",
|
|
383
|
+
upstream: "uploader",
|
|
384
|
+
kindHeader: "contentType",
|
|
385
|
+
fileField: "file",
|
|
386
|
+
kindField: "kind"
|
|
387
|
+
});
|
|
388
|
+
function isUploadKind(value) {
|
|
389
|
+
return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/server/api.ts
|
|
394
|
+
/**
|
|
395
|
+
* The OAuth token a caller sent with the request, without its scheme, or `null`.
|
|
396
|
+
*
|
|
397
|
+
* Reads, in order: `Authorization: OAuth|Bearer <token>` (what the SDK sends
|
|
398
|
+
* from 0.3), the `token` header (SDK ≤ 0.2 writes), and `extras.token`
|
|
399
|
+
* (SDK ≤ 0.2 reads).
|
|
400
|
+
*/
|
|
401
|
+
function readRequestToken(request) {
|
|
402
|
+
const authz = request.headers.get("authorization");
|
|
403
|
+
if (authz) {
|
|
404
|
+
const match = /^(?:oauth|bearer)\s+(\S+)\s*$/i.exec(authz);
|
|
405
|
+
if (match) return match[1];
|
|
406
|
+
}
|
|
407
|
+
const legacyHeader = request.headers.get("token");
|
|
408
|
+
if (legacyHeader && legacyHeader.trim()) return legacyHeader.trim();
|
|
409
|
+
const extras = request.headers.get("extras");
|
|
410
|
+
if (extras) try {
|
|
411
|
+
const token = JSON.parse(extras)?.token;
|
|
412
|
+
if (typeof token === "string" && token.trim()) return token.trim();
|
|
413
|
+
} catch {}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* `extras` keys that are never forwarded upstream (case-insensitive).
|
|
418
|
+
* `Content-Type` is allowed (SDK clients have always sent `application/json`
|
|
419
|
+
* on reads) but is replaced by the form content type on writes.
|
|
420
|
+
*/
|
|
421
|
+
const BLOCKED_EXTRAS = new Set([
|
|
422
|
+
"authorization",
|
|
423
|
+
"token",
|
|
424
|
+
"cookie",
|
|
425
|
+
"host",
|
|
426
|
+
"x-accept-version",
|
|
427
|
+
"content-length",
|
|
428
|
+
"connection",
|
|
429
|
+
"transfer-encoding",
|
|
430
|
+
"te",
|
|
431
|
+
"upgrade",
|
|
432
|
+
"expect",
|
|
433
|
+
"keep-alive",
|
|
434
|
+
"proxy-authorization",
|
|
435
|
+
"forwarded"
|
|
436
|
+
]);
|
|
437
|
+
const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
438
|
+
function headerValue(value) {
|
|
439
|
+
if (value == null) return null;
|
|
440
|
+
let text;
|
|
441
|
+
if (Array.isArray(value)) text = value.map((v) => v != null && typeof v === "object" ? JSON.stringify(v) : String(v)).join(",");
|
|
442
|
+
else if (typeof value === "object") text = JSON.stringify(value);
|
|
443
|
+
else text = String(value);
|
|
444
|
+
return /[\r\n\0]/.test(text) ? null : text;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Parses the `extras` request header into upstream headers.
|
|
448
|
+
* Returns `null` when the header isn't a JSON object.
|
|
449
|
+
*/
|
|
450
|
+
function parseExtras(raw, blocked = BLOCKED_EXTRAS) {
|
|
451
|
+
if (!raw) return {};
|
|
452
|
+
let parsed;
|
|
453
|
+
try {
|
|
454
|
+
parsed = JSON.parse(raw);
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
459
|
+
const headers = {};
|
|
460
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
461
|
+
const lower = key.toLowerCase();
|
|
462
|
+
if (!HEADER_NAME.test(key) || blocked.has(lower) || lower.startsWith("x-forwarded-") || lower.startsWith("proxy-")) continue;
|
|
463
|
+
const text = headerValue(value);
|
|
464
|
+
if (text !== null) headers[key] = text;
|
|
465
|
+
}
|
|
466
|
+
return headers;
|
|
467
|
+
}
|
|
468
|
+
var RequestBodyError = class extends Error {};
|
|
469
|
+
function isPlainObject(value) {
|
|
470
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Normalises a write request body into the Shopwave envelope
|
|
474
|
+
* `{ <collection>: { <ref>: entity, … } }`.
|
|
475
|
+
*
|
|
476
|
+
* Accepts:
|
|
477
|
+
* - the SDK ≥ 0.3 shape `{ <collection>: { "0": {...}, "1": {...} } }` (refs kept),
|
|
478
|
+
* - the older AdminUI shapes `{ <collection>: { new: {...} } }` / `{ updated: {...} }`,
|
|
479
|
+
* - a bare entity object `{ title: … }`.
|
|
480
|
+
*
|
|
481
|
+
* With `forcedId` (item routes) exactly one entity is allowed and its `id` is
|
|
482
|
+
* set from the URL.
|
|
483
|
+
*/
|
|
484
|
+
function normalizeWriteBody(def, body, forcedId) {
|
|
485
|
+
if (!isPlainObject(body)) throw new RequestBodyError("Request body must be a JSON object");
|
|
486
|
+
let refs;
|
|
487
|
+
const wrapped = body[def.collection];
|
|
488
|
+
if (wrapped !== void 0) {
|
|
489
|
+
if (!isPlainObject(wrapped)) throw new RequestBodyError(`"${def.collection}" must be an object keyed by ref`);
|
|
490
|
+
const keys = Object.keys(wrapped);
|
|
491
|
+
const entries = keys.length === 1 && (keys[0] === "new" || keys[0] === "updated") ? [["0", wrapped[keys[0]]]] : Object.entries(wrapped);
|
|
492
|
+
refs = {};
|
|
493
|
+
for (const [ref, entity] of entries) {
|
|
494
|
+
if (!isPlainObject(entity)) throw new RequestBodyError(`"${def.collection}.${ref}" must be an object`);
|
|
495
|
+
refs[ref] = { ...entity };
|
|
496
|
+
}
|
|
497
|
+
} else refs = { "0": { ...body } };
|
|
498
|
+
const count = Object.keys(refs).length;
|
|
499
|
+
if (count === 0) throw new RequestBodyError(`No ${def.collection} to save`);
|
|
500
|
+
if (forcedId !== void 0) {
|
|
501
|
+
if (count !== 1) throw new RequestBodyError("Only one entity can be saved at an id route");
|
|
502
|
+
const only = refs[Object.keys(refs)[0]];
|
|
503
|
+
only.id = /^\d+$/.test(forcedId) ? Number(forcedId) : forcedId;
|
|
504
|
+
}
|
|
505
|
+
return { [def.collection]: refs };
|
|
506
|
+
}
|
|
507
|
+
const NO_STORE = { "Cache-Control": "no-store" };
|
|
508
|
+
function jsonError(status, error, message) {
|
|
509
|
+
return new Response(JSON.stringify({
|
|
510
|
+
error,
|
|
511
|
+
message
|
|
512
|
+
}), {
|
|
513
|
+
status,
|
|
514
|
+
headers: {
|
|
515
|
+
"Content-Type": "application/json",
|
|
516
|
+
...NO_STORE
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
/** `YYYY-MM-DD HH:mm:ss` in UTC, the datetime format Shopwave writes use (e.g. promotions). */
|
|
521
|
+
function shopwaveDateTime(date = /* @__PURE__ */ new Date()) {
|
|
522
|
+
return date.toISOString().slice(0, 19).replace("T", " ");
|
|
523
|
+
}
|
|
524
|
+
const NULL_BODY_STATUS = new Set([
|
|
525
|
+
101,
|
|
526
|
+
103,
|
|
527
|
+
204,
|
|
528
|
+
205,
|
|
529
|
+
304
|
|
530
|
+
]);
|
|
531
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
532
|
+
async function readUpstream(response) {
|
|
533
|
+
const text = await response.text();
|
|
534
|
+
if (!text) return {
|
|
535
|
+
text,
|
|
536
|
+
json: null
|
|
537
|
+
};
|
|
538
|
+
try {
|
|
539
|
+
return {
|
|
540
|
+
text,
|
|
541
|
+
json: JSON.parse(text)
|
|
542
|
+
};
|
|
543
|
+
} catch {
|
|
544
|
+
return {
|
|
545
|
+
text,
|
|
546
|
+
json: null
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async function routeParam(context, name) {
|
|
551
|
+
const value = (await context?.params)?.[name];
|
|
552
|
+
const id = Array.isArray(value) ? value[0] : value;
|
|
553
|
+
return typeof id === "string" && ID_PATTERN.test(id) ? id : null;
|
|
554
|
+
}
|
|
555
|
+
function createShopwaveApiHandlers(config) {
|
|
556
|
+
const allowRequestToken = config.allowRequestToken ?? true;
|
|
557
|
+
const apiVersion = config.apiVersion ?? "2.0";
|
|
558
|
+
const blocked = new Set([...BLOCKED_EXTRAS, ...(config.blockedExtras ?? []).map((k) => k.toLowerCase())]);
|
|
559
|
+
const onError = config.onError ?? ((error, ctx) => console.error(`[react-shopwave-connect] Shopwave API ${ctx.method} /${ctx.path} failed:`, error?.message ?? error));
|
|
560
|
+
function apiBase() {
|
|
561
|
+
const url = config.apiUrl;
|
|
562
|
+
if (!url) throw new Error("Shopwave API: apiUrl is not set (e.g. SHOPWAVE_API_SERVER_URL).");
|
|
563
|
+
return url.replace(/\/+$/, "");
|
|
564
|
+
}
|
|
565
|
+
function entity(kind) {
|
|
566
|
+
const base = SHOPWAVE_ENTITIES[kind];
|
|
567
|
+
if (!base) throw new Error(`Unknown Shopwave entity "${kind}"`);
|
|
568
|
+
return {
|
|
569
|
+
...base,
|
|
570
|
+
...config.entities?.[kind] ?? {}
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async function forward(request, init) {
|
|
574
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
575
|
+
const path = init.path.replace(/^\/+/, "");
|
|
576
|
+
const extras = init.forwardExtras === false ? {} : parseExtras(request.headers.get("extras"), blocked);
|
|
577
|
+
if (!extras) return jsonError(400, "invalid_extras", "The extras header must be a JSON object");
|
|
578
|
+
const requestToken = allowRequestToken ? readRequestToken(request) : null;
|
|
579
|
+
let authorization;
|
|
580
|
+
try {
|
|
581
|
+
authorization = requestToken ? `OAuth ${requestToken}` : await config.getAuthorization?.() ?? null;
|
|
582
|
+
} catch (error) {
|
|
583
|
+
onError(error, {
|
|
584
|
+
method,
|
|
585
|
+
path: "oauth/token"
|
|
586
|
+
});
|
|
587
|
+
return jsonError(502, "auth_unavailable", "Could not refresh the Shopwave session");
|
|
588
|
+
}
|
|
589
|
+
if (!authorization) return jsonError(401, "unauthorized", "Not logged in");
|
|
590
|
+
const body = init.postBody !== void 0 ? new URLSearchParams({ postBody: JSON.stringify(init.postBody) }) : init.formData;
|
|
591
|
+
const call = (authz) => {
|
|
592
|
+
const headers = new Headers(extras);
|
|
593
|
+
for (const [k, v] of Object.entries(init.headers ?? {})) headers.set(k, v);
|
|
594
|
+
headers.set("Authorization", authz);
|
|
595
|
+
headers.set("x-accept-version", apiVersion);
|
|
596
|
+
if (body instanceof URLSearchParams) headers.set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
|
|
597
|
+
else if (body) headers.delete("Content-Type");
|
|
598
|
+
return (config.fetch ?? fetch)(`${apiBase()}/${path}`, {
|
|
599
|
+
method,
|
|
600
|
+
headers,
|
|
601
|
+
body,
|
|
602
|
+
cache: "no-store"
|
|
603
|
+
});
|
|
604
|
+
};
|
|
605
|
+
try {
|
|
606
|
+
let response = await call(authorization);
|
|
607
|
+
let upstream = await readUpstream(response);
|
|
608
|
+
if (!requestToken && config.getAuthorization && isExpiredTokenResponse(response.status, upstream.json)) {
|
|
609
|
+
const fresh = await config.getAuthorization({ forceRefresh: true });
|
|
610
|
+
if (!fresh) return jsonError(401, "unauthorized", "The Shopwave session has expired");
|
|
611
|
+
response = await call(fresh);
|
|
612
|
+
upstream = await readUpstream(response);
|
|
613
|
+
}
|
|
614
|
+
if (NULL_BODY_STATUS.has(response.status) || !upstream.text) return new Response(null, {
|
|
615
|
+
status: response.status,
|
|
616
|
+
headers: NO_STORE
|
|
617
|
+
});
|
|
618
|
+
return new Response(upstream.text, {
|
|
619
|
+
status: response.status,
|
|
620
|
+
headers: {
|
|
621
|
+
"Content-Type": upstream.json !== null ? "application/json" : response.headers.get("content-type") ?? "text/plain",
|
|
622
|
+
...NO_STORE
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
} catch (error) {
|
|
626
|
+
onError(error, {
|
|
627
|
+
method,
|
|
628
|
+
path
|
|
629
|
+
});
|
|
630
|
+
return jsonError(502, "upstream_unavailable", "The Shopwave API could not be reached");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function readJsonBody(request) {
|
|
634
|
+
const text = await request.text();
|
|
635
|
+
if (!text) throw new RequestBodyError("Request body is empty");
|
|
636
|
+
try {
|
|
637
|
+
return JSON.parse(text);
|
|
638
|
+
} catch {
|
|
639
|
+
throw new RequestBodyError("Request body is not valid JSON");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function notSupported(def, what) {
|
|
643
|
+
return jsonError(405, "method_not_allowed", `The Shopwave API doesn't support ${what} for ${def.collection}`);
|
|
644
|
+
}
|
|
645
|
+
async function write(request, def, forcedId) {
|
|
646
|
+
if (!def.writable) return notSupported(def, "writes");
|
|
647
|
+
let postBody;
|
|
648
|
+
try {
|
|
649
|
+
postBody = normalizeWriteBody(def, await readJsonBody(request), forcedId);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (error instanceof RequestBodyError) return jsonError(400, "invalid_body", error.message);
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
return forward(request, {
|
|
655
|
+
method: "POST",
|
|
656
|
+
path: def.upstream,
|
|
657
|
+
postBody
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
function resourceDef(kind) {
|
|
661
|
+
const def = SHOPWAVE_RESOURCES[kind];
|
|
662
|
+
if (!def) throw new Error(`Unknown Shopwave resource "${kind}"`);
|
|
663
|
+
return def;
|
|
664
|
+
}
|
|
665
|
+
async function writeResource(request, def) {
|
|
666
|
+
if (!def.writable) return jsonError(405, "method_not_allowed", `The Shopwave API doesn't support writes for ${def.key}`);
|
|
667
|
+
let record;
|
|
668
|
+
try {
|
|
669
|
+
const body = await readJsonBody(request);
|
|
670
|
+
if (!isPlainObject(body)) throw new RequestBodyError("Request body must be a JSON object");
|
|
671
|
+
const inner = body[def.key];
|
|
672
|
+
if (inner !== void 0 && !isPlainObject(inner)) throw new RequestBodyError(`"${def.key}" must be an object`);
|
|
673
|
+
record = { ...inner ?? body };
|
|
674
|
+
const id = record.id;
|
|
675
|
+
if (id == null || id === "" || !ID_PATTERN.test(String(id))) throw new RequestBodyError(`Missing or invalid ${def.key} id`);
|
|
676
|
+
record.id = /^\d+$/.test(String(id)) ? Number(id) : id;
|
|
677
|
+
} catch (error) {
|
|
678
|
+
if (error instanceof RequestBodyError) return jsonError(400, "invalid_body", error.message);
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
681
|
+
return forward(request, {
|
|
682
|
+
method: "POST",
|
|
683
|
+
path: def.upstream,
|
|
684
|
+
postBody: { [def.key]: record }
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function acceptsType(type, accept) {
|
|
688
|
+
const t = type.toLowerCase().split(";")[0].trim();
|
|
689
|
+
return accept.some((a) => {
|
|
690
|
+
const rule = a.toLowerCase();
|
|
691
|
+
return rule.endsWith("/") ? t.startsWith(rule) : t === rule;
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
async function uploadFile(request, options) {
|
|
695
|
+
const maxBytes = options.maxBytes ?? 10 * 1024 * 1024;
|
|
696
|
+
const accept = options.accept ?? ["image/"];
|
|
697
|
+
if (Number(request.headers.get("content-length") ?? 0) > maxBytes + 64 * 1024) return jsonError(413, "too_large", `The file is larger than ${maxBytes} bytes`);
|
|
698
|
+
let form;
|
|
699
|
+
try {
|
|
700
|
+
form = await request.formData();
|
|
701
|
+
} catch {
|
|
702
|
+
return jsonError(400, "invalid_body", "Expected a multipart/form-data body");
|
|
703
|
+
}
|
|
704
|
+
const file = form.get(SHOPWAVE_UPLOAD.fileField);
|
|
705
|
+
if (!file || typeof file === "string" || file.size === 0) return jsonError(400, "invalid_body", `Missing "${SHOPWAVE_UPLOAD.fileField}"`);
|
|
706
|
+
const kind = form.get(SHOPWAVE_UPLOAD.kindField);
|
|
707
|
+
if (!isUploadKind(kind)) return jsonError(400, "invalid_body", `"${SHOPWAVE_UPLOAD.kindField}" must be one of ${Object.keys(UPLOAD_KINDS).join(", ")}`);
|
|
708
|
+
if (file.size > maxBytes) return jsonError(413, "too_large", `The file is larger than ${maxBytes} bytes`);
|
|
709
|
+
if (!acceptsType(file.type || "", accept)) return jsonError(415, "unsupported_type", `Files of type "${file.type || "unknown"}" aren't accepted`);
|
|
710
|
+
const upstreamForm = new FormData();
|
|
711
|
+
upstreamForm.append(SHOPWAVE_UPLOAD.fileField, file, file.name || "upload");
|
|
712
|
+
return forward(request, {
|
|
713
|
+
method: "PUT",
|
|
714
|
+
path: SHOPWAVE_UPLOAD.upstream,
|
|
715
|
+
headers: { [SHOPWAVE_UPLOAD.kindHeader]: UPLOAD_KINDS[kind] },
|
|
716
|
+
formData: upstreamForm,
|
|
717
|
+
forwardExtras: false
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
function badId() {
|
|
721
|
+
return jsonError(400, "invalid_id", "Missing or invalid id");
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
forward,
|
|
725
|
+
entity,
|
|
726
|
+
collection(kind) {
|
|
727
|
+
const def = entity(kind);
|
|
728
|
+
const save = (request) => write(request, def);
|
|
729
|
+
return {
|
|
730
|
+
GET: (request) => forward(request, { path: def.upstream }),
|
|
731
|
+
POST: save,
|
|
732
|
+
PUT: save
|
|
733
|
+
};
|
|
734
|
+
},
|
|
735
|
+
item(kind, options = {}) {
|
|
736
|
+
const def = entity(kind);
|
|
737
|
+
const param = options.param ?? "id";
|
|
738
|
+
return {
|
|
739
|
+
async GET(request, context) {
|
|
740
|
+
const id = await routeParam(context, param);
|
|
741
|
+
if (!id) return badId();
|
|
742
|
+
return forward(request, {
|
|
743
|
+
path: def.upstream,
|
|
744
|
+
headers: { [def.idsHeader]: id }
|
|
745
|
+
});
|
|
746
|
+
},
|
|
747
|
+
async PUT(request, context) {
|
|
748
|
+
const id = await routeParam(context, param);
|
|
749
|
+
if (!id) return badId();
|
|
750
|
+
return write(request, def, id);
|
|
751
|
+
},
|
|
752
|
+
async DELETE(request, context) {
|
|
753
|
+
const id = await routeParam(context, param);
|
|
754
|
+
if (!id) return badId();
|
|
755
|
+
if (def.deleteMode === "none") return notSupported(def, "deletes");
|
|
756
|
+
if (def.deleteMode === "retire") {
|
|
757
|
+
const field = def.retireField ?? "exitDate";
|
|
758
|
+
return forward(request, {
|
|
759
|
+
method: "POST",
|
|
760
|
+
path: def.upstream,
|
|
761
|
+
postBody: { [def.collection]: { 0: {
|
|
762
|
+
id: /^\d+$/.test(id) ? Number(id) : id,
|
|
763
|
+
[field]: shopwaveDateTime()
|
|
764
|
+
} } },
|
|
765
|
+
forwardExtras: false
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
return forward(request, {
|
|
769
|
+
method: "DELETE",
|
|
770
|
+
path: def.upstream,
|
|
771
|
+
headers: { [def.idHeader]: id },
|
|
772
|
+
forwardExtras: false
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
},
|
|
777
|
+
passthrough(path) {
|
|
778
|
+
return { GET: (request) => forward(request, { path }) };
|
|
779
|
+
},
|
|
780
|
+
resource(kind) {
|
|
781
|
+
const def = resourceDef(kind);
|
|
782
|
+
const save = (request) => writeResource(request, def);
|
|
783
|
+
return {
|
|
784
|
+
GET: (request) => forward(request, { path: def.upstream }),
|
|
785
|
+
PUT: save,
|
|
786
|
+
POST: save
|
|
787
|
+
};
|
|
788
|
+
},
|
|
789
|
+
upload(options = {}) {
|
|
790
|
+
return { POST: (request) => uploadFile(request, options) };
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
276
795
|
//#endregion
|
|
277
796
|
//#region src/next/index.ts
|
|
278
797
|
function normalizePath(p) {
|
|
@@ -551,8 +1070,10 @@ function createShopwaveAuth(config) {
|
|
|
551
1070
|
sessionPath,
|
|
552
1071
|
...options.publicPaths ?? []
|
|
553
1072
|
])) return;
|
|
1073
|
+
const isApi = pathMatches(pathname, options.apiPaths ?? ["/api"]);
|
|
1074
|
+
if (isApi && options.allowRequestToken && readRequestToken(request)) return void 0;
|
|
554
1075
|
if (await isAuthenticated(request)) return void 0;
|
|
555
|
-
if (
|
|
1076
|
+
if (isApi) return next_server.NextResponse.json({ error: "unauthorized" }, {
|
|
556
1077
|
status: 401,
|
|
557
1078
|
headers: noStore
|
|
558
1079
|
});
|
|
@@ -576,10 +1097,47 @@ function createShopwaveAuth(config) {
|
|
|
576
1097
|
}
|
|
577
1098
|
};
|
|
578
1099
|
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Ready-made Next.js route handlers for the SDK's `/api/*` contract, using the
|
|
1102
|
+
* session from `createShopwaveAuth`. Create once (e.g. `lib/shopwave.ts`):
|
|
1103
|
+
*
|
|
1104
|
+
* ```ts
|
|
1105
|
+
* export const shopwave = createShopwaveApi({ auth, apiUrl: process.env.SHOPWAVE_API_SERVER_URL! });
|
|
1106
|
+
* ```
|
|
1107
|
+
*
|
|
1108
|
+
* then each route file is one line:
|
|
1109
|
+
*
|
|
1110
|
+
* ```ts
|
|
1111
|
+
* // app/api/products/route.ts
|
|
1112
|
+
* export const { GET, POST, PUT } = shopwave.collection("product");
|
|
1113
|
+
* // app/api/products/[id]/route.ts
|
|
1114
|
+
* export const { GET, PUT, DELETE } = shopwave.item("product");
|
|
1115
|
+
* // app/api/report/route.ts
|
|
1116
|
+
* export const { GET } = shopwave.passthrough("report");
|
|
1117
|
+
* // app/api/merchant/route.ts, app/api/user/route.ts
|
|
1118
|
+
* export const { GET, PUT } = shopwave.resource("merchant");
|
|
1119
|
+
* export const { GET } = shopwave.resource("user");
|
|
1120
|
+
* // app/api/upload/route.ts
|
|
1121
|
+
* export const { POST } = shopwave.upload();
|
|
1122
|
+
* ```
|
|
1123
|
+
*/
|
|
1124
|
+
function createShopwaveApi(config) {
|
|
1125
|
+
if (typeof window !== "undefined") throw new Error("react-shopwave-connect/next is server-only. Import it from route handlers or server code.");
|
|
1126
|
+
const { auth,...rest } = config;
|
|
1127
|
+
return createShopwaveApiHandlers({
|
|
1128
|
+
...rest,
|
|
1129
|
+
getAuthorization: (options) => auth.getAuthorizationHeader(options)
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
579
1132
|
|
|
580
1133
|
//#endregion
|
|
1134
|
+
exports.SHOPWAVE_ENTITIES = SHOPWAVE_ENTITIES;
|
|
1135
|
+
exports.SHOPWAVE_RESOURCES = SHOPWAVE_RESOURCES;
|
|
581
1136
|
exports.ShopwaveAuthError = ShopwaveAuthError;
|
|
1137
|
+
exports.UPLOAD_KINDS = UPLOAD_KINDS;
|
|
582
1138
|
exports.authorizationHeader = authorizationHeader;
|
|
1139
|
+
exports.createShopwaveApi = createShopwaveApi;
|
|
583
1140
|
exports.createShopwaveAuth = createShopwaveAuth;
|
|
584
1141
|
exports.isExpiredTokenResponse = isExpiredTokenResponse;
|
|
1142
|
+
exports.readRequestToken = readRequestToken;
|
|
585
1143
|
//# sourceMappingURL=index.cjs.map
|