react-shopwave-connect 0.2.0 → 0.3.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.
@@ -84,6 +84,143 @@ interface ShopwaveOAuthClient {
84
84
  refreshToken(token: ShopwaveToken | string): Promise<ShopwaveToken>;
85
85
  }
86
86
  //#endregion
87
+ //#region src/core/entities.d.ts
88
+ /**
89
+ * How each Shopwave entity is addressed, shared by the client functions below
90
+ * and the server route handlers in `react-shopwave-connect/next`, so the two
91
+ * halves of the contract can't drift apart.
92
+ */
93
+ interface EntityDefinition {
94
+ /** App route segment: `/api/<route>` and `/api/<route>/<id>`. */
95
+ route: string;
96
+ /** Key of the entity map in requests and responses (`{ products: { … } }`). */
97
+ collection: string;
98
+ /** Shopwave API path (`GET/POST/DELETE {apiUrl}/<upstream>`). */
99
+ upstream: string;
100
+ /** Header that filters reads by id (`productIds: 1,2`). */
101
+ idsHeader: string;
102
+ /** Header that names the record to delete (`productId: 1`). */
103
+ idHeader: string;
104
+ /** False for read-only entities (the API has no POST for them). */
105
+ writable: boolean;
106
+ /**
107
+ * How a delete is done upstream:
108
+ * - `"delete"`: `DELETE {apiUrl}/<upstream>` with the `idHeader` (205, empty body)
109
+ * - `"retire"`: the API has no DELETE; the record is retired by POSTing
110
+ * `{ id, [retireField]: <now> }` (employees: `exitDate`, promotions: `endDate`)
111
+ * - `"none"`: can't be deleted
112
+ */
113
+ deleteMode: "delete" | "retire" | "none";
114
+ /** Date field set to "now" when `deleteMode` is `"retire"`. */
115
+ retireField?: string;
116
+ }
117
+ type EntityKind = "product" | "category" | "store" | "promotion" | "employee" | "consumer";
118
+ /**
119
+ * Addressing per entity, following the Shopwave API reference
120
+ * (https://developer.merchantstack.com/api-reference.html) and checked against
121
+ * the live API where noted:
122
+ * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
123
+ * - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
124
+ * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
125
+ * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
126
+ * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
127
+ */
128
+ declare const SHOPWAVE_ENTITIES: Readonly<Record<EntityKind, EntityDefinition>>;
129
+ //#endregion
130
+ //#region src/server/api.d.ts
131
+ /**
132
+ * The OAuth token a caller sent with the request, without its scheme, or `null`.
133
+ *
134
+ * Reads, in order: `Authorization: OAuth|Bearer <token>` (what the SDK sends
135
+ * from 0.3), the `token` header (SDK ≤ 0.2 writes), and `extras.token`
136
+ * (SDK ≤ 0.2 reads).
137
+ */
138
+ declare function readRequestToken(request: Request): string | null;
139
+ interface ShopwaveApiConfig {
140
+ /** Shopwave API base URL, e.g. `process.env.SHOPWAVE_API_SERVER_URL`. */
141
+ apiUrl: string;
142
+ /**
143
+ * `Authorization` header value for the logged-in user (`"OAuth <token>"`),
144
+ * or `null` when logged out. Called again with `forceRefresh: true` when the
145
+ * API reports the token expired.
146
+ */
147
+ getAuthorization?: (options?: {
148
+ forceRefresh?: boolean;
149
+ }) => Promise<string | null>;
150
+ /**
151
+ * Accept a token sent by the caller (`Authorization: OAuth <token>`, or the
152
+ * legacy `token` header / `extras.token`) instead of the session. The token
153
+ * is only forwarded to the Shopwave API, which validates it. Defaults to `true`
154
+ * (SDK integration tests, scripts and server-to-server calls rely on it).
155
+ */
156
+ allowRequestToken?: boolean;
157
+ /** `x-accept-version` sent upstream. Defaults to `"2.0"`. */
158
+ apiVersion?: string;
159
+ /** Custom fetch for the upstream call. */
160
+ fetch?: typeof fetch;
161
+ /** Override how an entity is addressed (e.g. a different id header). */
162
+ entities?: Partial<Record<EntityKind, Partial<EntityDefinition>>>;
163
+ /** Extra `extras` keys to drop, on top of {@link BLOCKED_EXTRAS}. */
164
+ blockedExtras?: string[];
165
+ /**
166
+ * Called when the upstream call throws (network error). Defaults to a
167
+ * `console.error` with the method and path only — never headers or tokens.
168
+ */
169
+ onError?: (error: unknown, context: {
170
+ method: string;
171
+ path: string;
172
+ }) => void;
173
+ }
174
+ interface ForwardInit {
175
+ /** HTTP method sent upstream. Defaults to `GET`. */
176
+ method?: string;
177
+ /** Shopwave API path, e.g. `"product"`. */
178
+ path: string;
179
+ /** Extra upstream headers; they win over `extras`. */
180
+ headers?: Record<string, string>;
181
+ /** Sent as the form field `postBody=<JSON>`. */
182
+ postBody?: unknown;
183
+ /** Forward the caller's `extras` header as upstream headers. Defaults to `true`. */
184
+ forwardExtras?: boolean;
185
+ }
186
+ /** Second argument Next.js (and similar) pass to dynamic route handlers. */
187
+ interface RouteContext {
188
+ params?: Promise<Record<string, string | string[] | undefined>> | Record<string, string | string[] | undefined>;
189
+ }
190
+ type RouteHandler = (request: Request, context?: RouteContext) => Promise<Response>;
191
+ interface CollectionHandlers {
192
+ /** List/filter via `extras` (e.g. `productIds`, `deleted`). */
193
+ GET: RouteHandler;
194
+ /** Create or update (Shopwave upserts: an `id` means update). */
195
+ POST: RouteHandler;
196
+ /** Same as POST (kept for older clients). */
197
+ PUT: RouteHandler;
198
+ }
199
+ interface ItemHandlers {
200
+ /** Read one record by the `[id]` route param. */
201
+ GET: RouteHandler;
202
+ /** Update the record at `[id]` (the id in the URL wins). */
203
+ PUT: RouteHandler;
204
+ /** Delete the record at `[id]`. Shopwave answers 205 with an empty body. */
205
+ DELETE: RouteHandler;
206
+ }
207
+ interface ShopwaveApiHandlers {
208
+ /** Calls the Shopwave API for this request (token, extras, refresh-retry) and returns its answer. */
209
+ forward(request: Request, init: ForwardInit): Promise<Response>;
210
+ /** Handlers for `app/api/<route>/route.ts`. */
211
+ collection(kind: EntityKind): CollectionHandlers;
212
+ /** Handlers for `app/api/<route>/[id]/route.ts`. */
213
+ item(kind: EntityKind, options?: {
214
+ param?: string;
215
+ }): ItemHandlers;
216
+ /** GET-only proxy for a Shopwave path (e.g. `"report"`, `"user"`, `"merchant"`). */
217
+ passthrough(path: string): {
218
+ GET: RouteHandler;
219
+ };
220
+ /** The entity table in use (defaults merged with `config.entities`). */
221
+ entity(kind: EntityKind): EntityDefinition;
222
+ }
223
+ //#endregion
87
224
  //#region src/core/session.d.ts
88
225
  /**
89
226
  * Login status returned by the app's `/api/session` route. It deliberately
@@ -192,6 +329,13 @@ interface ProtectOptions {
192
329
  publicPaths?: string[];
193
330
  /** Paths that get a 401 JSON response instead of a login redirect. Defaults to `["/api"]`. */
194
331
  apiPaths?: string[];
332
+ /**
333
+ * Let API requests that carry their own token (`Authorization: OAuth <token>`,
334
+ * or the legacy `token` header / `extras.token`) through without a session
335
+ * cookie. The route handlers from {@link createShopwaveApi} forward that token
336
+ * to the Shopwave API, which validates it. Defaults to `false`.
337
+ */
338
+ allowRequestToken?: boolean;
195
339
  }
196
340
  interface ShopwaveAuth {
197
341
  /** The underlying framework-agnostic OAuth client. */
@@ -250,6 +394,30 @@ interface ShopwaveAuth {
250
394
  * a missing env var doesn't fail `next build`.
251
395
  */
252
396
  declare function createShopwaveAuth(config: ShopwaveAuthConfig): ShopwaveAuth;
397
+ interface ShopwaveNextApiConfig extends Omit<ShopwaveApiConfig, "getAuthorization"> {
398
+ /** The app's auth instance: supplies (and refreshes) the logged-in user's token. */
399
+ auth: Pick<ShopwaveAuth, "getAuthorizationHeader">;
400
+ }
401
+ /**
402
+ * Ready-made Next.js route handlers for the SDK's `/api/*` contract, using the
403
+ * session from `createShopwaveAuth`. Create once (e.g. `lib/shopwave.ts`):
404
+ *
405
+ * ```ts
406
+ * export const shopwave = createShopwaveApi({ auth, apiUrl: process.env.SHOPWAVE_API_SERVER_URL! });
407
+ * ```
408
+ *
409
+ * then each route file is one line:
410
+ *
411
+ * ```ts
412
+ * // app/api/products/route.ts
413
+ * export const { GET, POST, PUT } = shopwave.collection("product");
414
+ * // app/api/products/[id]/route.ts
415
+ * export const { GET, PUT, DELETE } = shopwave.item("product");
416
+ * // app/api/report/route.ts
417
+ * export const { GET } = shopwave.passthrough("report");
418
+ * ```
419
+ */
420
+ declare function createShopwaveApi(config: ShopwaveNextApiConfig): ShopwaveApiHandlers;
253
421
  //#endregion
254
- export { AuthContext, ProtectOptions, type SessionStatus, ShopwaveAuth, ShopwaveAuthConfig, ShopwaveAuthError, ShopwaveSessionConfig, ShopwaveSessionData, type ShopwaveToken, authorizationHeader, createShopwaveAuth, isExpiredTokenResponse };
422
+ export { AuthContext, type CollectionHandlers, type EntityDefinition, type EntityKind, type ForwardInit, type ItemHandlers, ProtectOptions, type RouteContext, type RouteHandler, SHOPWAVE_ENTITIES, type SessionStatus, type ShopwaveApiHandlers, ShopwaveAuth, ShopwaveAuthConfig, ShopwaveAuthError, ShopwaveNextApiConfig, ShopwaveSessionConfig, ShopwaveSessionData, type ShopwaveToken, authorizationHeader, createShopwaveApi, createShopwaveAuth, isExpiredTokenResponse, readRequestToken };
255
423
  //# sourceMappingURL=index.d.ts.map
@@ -90,7 +90,8 @@ function authorizationHeader(token) {
90
90
  */
91
91
  function isExpiredTokenResponse(status, body) {
92
92
  if (status === 401) return true;
93
- const errors = body?.api?.message?.errors;
93
+ const message = body?.api?.message;
94
+ const errors = message?.errors ?? message?.error;
94
95
  if (!errors || typeof errors !== "object") return false;
95
96
  return Object.entries(errors).some(([key, value]) => key === String(SHOPWAVE_TOKEN_EXPIRED_ERROR_ID) || Number(value?.id) === SHOPWAVE_TOKEN_EXPIRED_ERROR_ID);
96
97
  }
@@ -247,6 +248,407 @@ function safeEqual(a, b) {
247
248
  return diff === 0;
248
249
  }
249
250
 
251
+ //#endregion
252
+ //#region src/core/entities.ts
253
+ /**
254
+ * Addressing per entity, following the Shopwave API reference
255
+ * (https://developer.merchantstack.com/api-reference.html) and checked against
256
+ * the live API where noted:
257
+ * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
258
+ * - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
259
+ * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
260
+ * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
261
+ * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
262
+ */
263
+ const SHOPWAVE_ENTITIES = Object.freeze({
264
+ product: {
265
+ route: "products",
266
+ collection: "products",
267
+ upstream: "product",
268
+ idsHeader: "productIds",
269
+ idHeader: "productId",
270
+ writable: true,
271
+ deleteMode: "delete"
272
+ },
273
+ category: {
274
+ route: "categories",
275
+ collection: "categories",
276
+ upstream: "category",
277
+ idsHeader: "categoryIds",
278
+ idHeader: "categoryId",
279
+ writable: true,
280
+ deleteMode: "delete"
281
+ },
282
+ store: {
283
+ route: "stores",
284
+ collection: "stores",
285
+ upstream: "store",
286
+ idsHeader: "storeIds",
287
+ idHeader: "storeId",
288
+ writable: true,
289
+ deleteMode: "delete"
290
+ },
291
+ promotion: {
292
+ route: "promotions",
293
+ collection: "promotions",
294
+ upstream: "promotion",
295
+ idsHeader: "promotionIds",
296
+ idHeader: "promotionId",
297
+ writable: true,
298
+ deleteMode: "retire",
299
+ retireField: "endDate"
300
+ },
301
+ employee: {
302
+ route: "employees",
303
+ collection: "employees",
304
+ upstream: "employee",
305
+ idsHeader: "employeeIds",
306
+ idHeader: "employeeId",
307
+ writable: true,
308
+ deleteMode: "retire",
309
+ retireField: "exitDate"
310
+ },
311
+ consumer: {
312
+ route: "consumer",
313
+ collection: "consumers",
314
+ upstream: "consumer",
315
+ idsHeader: "ids",
316
+ idHeader: "consumerId",
317
+ writable: false,
318
+ deleteMode: "none"
319
+ }
320
+ });
321
+
322
+ //#endregion
323
+ //#region src/server/api.ts
324
+ /**
325
+ * The OAuth token a caller sent with the request, without its scheme, or `null`.
326
+ *
327
+ * Reads, in order: `Authorization: OAuth|Bearer <token>` (what the SDK sends
328
+ * from 0.3), the `token` header (SDK ≤ 0.2 writes), and `extras.token`
329
+ * (SDK ≤ 0.2 reads).
330
+ */
331
+ function readRequestToken(request) {
332
+ const authz = request.headers.get("authorization");
333
+ if (authz) {
334
+ const match = /^(?:oauth|bearer)\s+(\S+)\s*$/i.exec(authz);
335
+ if (match) return match[1];
336
+ }
337
+ const legacyHeader = request.headers.get("token");
338
+ if (legacyHeader && legacyHeader.trim()) return legacyHeader.trim();
339
+ const extras = request.headers.get("extras");
340
+ if (extras) try {
341
+ const token = JSON.parse(extras)?.token;
342
+ if (typeof token === "string" && token.trim()) return token.trim();
343
+ } catch {}
344
+ return null;
345
+ }
346
+ /**
347
+ * `extras` keys that are never forwarded upstream (case-insensitive).
348
+ * `Content-Type` is allowed (SDK clients have always sent `application/json`
349
+ * on reads) but is replaced by the form content type on writes.
350
+ */
351
+ const BLOCKED_EXTRAS = new Set([
352
+ "authorization",
353
+ "token",
354
+ "cookie",
355
+ "host",
356
+ "x-accept-version",
357
+ "content-length",
358
+ "connection",
359
+ "transfer-encoding",
360
+ "te",
361
+ "upgrade",
362
+ "expect",
363
+ "keep-alive",
364
+ "proxy-authorization",
365
+ "forwarded"
366
+ ]);
367
+ const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
368
+ function headerValue(value) {
369
+ if (value == null) return null;
370
+ let text;
371
+ if (Array.isArray(value)) text = value.map((v) => v != null && typeof v === "object" ? JSON.stringify(v) : String(v)).join(",");
372
+ else if (typeof value === "object") text = JSON.stringify(value);
373
+ else text = String(value);
374
+ return /[\r\n\0]/.test(text) ? null : text;
375
+ }
376
+ /**
377
+ * Parses the `extras` request header into upstream headers.
378
+ * Returns `null` when the header isn't a JSON object.
379
+ */
380
+ function parseExtras(raw, blocked = BLOCKED_EXTRAS) {
381
+ if (!raw) return {};
382
+ let parsed;
383
+ try {
384
+ parsed = JSON.parse(raw);
385
+ } catch {
386
+ return null;
387
+ }
388
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
389
+ const headers = {};
390
+ for (const [key, value] of Object.entries(parsed)) {
391
+ const lower = key.toLowerCase();
392
+ if (!HEADER_NAME.test(key) || blocked.has(lower) || lower.startsWith("x-forwarded-") || lower.startsWith("proxy-")) continue;
393
+ const text = headerValue(value);
394
+ if (text !== null) headers[key] = text;
395
+ }
396
+ return headers;
397
+ }
398
+ var RequestBodyError = class extends Error {};
399
+ function isPlainObject(value) {
400
+ return !!value && typeof value === "object" && !Array.isArray(value);
401
+ }
402
+ /**
403
+ * Normalises a write request body into the Shopwave envelope
404
+ * `{ <collection>: { <ref>: entity, … } }`.
405
+ *
406
+ * Accepts:
407
+ * - the SDK ≥ 0.3 shape `{ <collection>: { "0": {...}, "1": {...} } }` (refs kept),
408
+ * - the older AdminUI shapes `{ <collection>: { new: {...} } }` / `{ updated: {...} }`,
409
+ * - a bare entity object `{ title: … }`.
410
+ *
411
+ * With `forcedId` (item routes) exactly one entity is allowed and its `id` is
412
+ * set from the URL.
413
+ */
414
+ function normalizeWriteBody(def, body, forcedId) {
415
+ if (!isPlainObject(body)) throw new RequestBodyError("Request body must be a JSON object");
416
+ let refs;
417
+ const wrapped = body[def.collection];
418
+ if (wrapped !== void 0) {
419
+ if (!isPlainObject(wrapped)) throw new RequestBodyError(`"${def.collection}" must be an object keyed by ref`);
420
+ const keys = Object.keys(wrapped);
421
+ const entries = keys.length === 1 && (keys[0] === "new" || keys[0] === "updated") ? [["0", wrapped[keys[0]]]] : Object.entries(wrapped);
422
+ refs = {};
423
+ for (const [ref, entity] of entries) {
424
+ if (!isPlainObject(entity)) throw new RequestBodyError(`"${def.collection}.${ref}" must be an object`);
425
+ refs[ref] = { ...entity };
426
+ }
427
+ } else refs = { "0": { ...body } };
428
+ const count = Object.keys(refs).length;
429
+ if (count === 0) throw new RequestBodyError(`No ${def.collection} to save`);
430
+ if (forcedId !== void 0) {
431
+ if (count !== 1) throw new RequestBodyError("Only one entity can be saved at an id route");
432
+ const only = refs[Object.keys(refs)[0]];
433
+ only.id = /^\d+$/.test(forcedId) ? Number(forcedId) : forcedId;
434
+ }
435
+ return { [def.collection]: refs };
436
+ }
437
+ const NO_STORE = { "Cache-Control": "no-store" };
438
+ function jsonError(status, error, message) {
439
+ return new Response(JSON.stringify({
440
+ error,
441
+ message
442
+ }), {
443
+ status,
444
+ headers: {
445
+ "Content-Type": "application/json",
446
+ ...NO_STORE
447
+ }
448
+ });
449
+ }
450
+ /** `YYYY-MM-DD HH:mm:ss` in UTC, the datetime format Shopwave writes use (e.g. promotions). */
451
+ function shopwaveDateTime(date = /* @__PURE__ */ new Date()) {
452
+ return date.toISOString().slice(0, 19).replace("T", " ");
453
+ }
454
+ const NULL_BODY_STATUS = new Set([
455
+ 101,
456
+ 103,
457
+ 204,
458
+ 205,
459
+ 304
460
+ ]);
461
+ const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
462
+ async function readUpstream(response) {
463
+ const text = await response.text();
464
+ if (!text) return {
465
+ text,
466
+ json: null
467
+ };
468
+ try {
469
+ return {
470
+ text,
471
+ json: JSON.parse(text)
472
+ };
473
+ } catch {
474
+ return {
475
+ text,
476
+ json: null
477
+ };
478
+ }
479
+ }
480
+ async function routeParam(context, name) {
481
+ const value = (await context?.params)?.[name];
482
+ const id = Array.isArray(value) ? value[0] : value;
483
+ return typeof id === "string" && ID_PATTERN.test(id) ? id : null;
484
+ }
485
+ function createShopwaveApiHandlers(config) {
486
+ const allowRequestToken = config.allowRequestToken ?? true;
487
+ const apiVersion = config.apiVersion ?? "2.0";
488
+ const blocked = new Set([...BLOCKED_EXTRAS, ...(config.blockedExtras ?? []).map((k) => k.toLowerCase())]);
489
+ const onError = config.onError ?? ((error, ctx) => console.error(`[react-shopwave-connect] Shopwave API ${ctx.method} /${ctx.path} failed:`, error?.message ?? error));
490
+ function apiBase() {
491
+ const url = config.apiUrl;
492
+ if (!url) throw new Error("Shopwave API: apiUrl is not set (e.g. SHOPWAVE_API_SERVER_URL).");
493
+ return url.replace(/\/+$/, "");
494
+ }
495
+ function entity(kind) {
496
+ const base = SHOPWAVE_ENTITIES[kind];
497
+ if (!base) throw new Error(`Unknown Shopwave entity "${kind}"`);
498
+ return {
499
+ ...base,
500
+ ...config.entities?.[kind] ?? {}
501
+ };
502
+ }
503
+ async function forward(request, init) {
504
+ const method = (init.method ?? "GET").toUpperCase();
505
+ const path = init.path.replace(/^\/+/, "");
506
+ const extras = init.forwardExtras === false ? {} : parseExtras(request.headers.get("extras"), blocked);
507
+ if (!extras) return jsonError(400, "invalid_extras", "The extras header must be a JSON object");
508
+ const requestToken = allowRequestToken ? readRequestToken(request) : null;
509
+ let authorization;
510
+ try {
511
+ authorization = requestToken ? `OAuth ${requestToken}` : await config.getAuthorization?.() ?? null;
512
+ } catch (error) {
513
+ onError(error, {
514
+ method,
515
+ path: "oauth/token"
516
+ });
517
+ return jsonError(502, "auth_unavailable", "Could not refresh the Shopwave session");
518
+ }
519
+ if (!authorization) return jsonError(401, "unauthorized", "Not logged in");
520
+ const body = init.postBody === void 0 ? void 0 : new URLSearchParams({ postBody: JSON.stringify(init.postBody) });
521
+ const call = (authz) => {
522
+ const headers = new Headers(extras);
523
+ for (const [k, v] of Object.entries(init.headers ?? {})) headers.set(k, v);
524
+ headers.set("Authorization", authz);
525
+ headers.set("x-accept-version", apiVersion);
526
+ if (body) headers.set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
527
+ return (config.fetch ?? fetch)(`${apiBase()}/${path}`, {
528
+ method,
529
+ headers,
530
+ body,
531
+ cache: "no-store"
532
+ });
533
+ };
534
+ try {
535
+ let response = await call(authorization);
536
+ let upstream = await readUpstream(response);
537
+ if (!requestToken && config.getAuthorization && isExpiredTokenResponse(response.status, upstream.json)) {
538
+ const fresh = await config.getAuthorization({ forceRefresh: true });
539
+ if (!fresh) return jsonError(401, "unauthorized", "The Shopwave session has expired");
540
+ response = await call(fresh);
541
+ upstream = await readUpstream(response);
542
+ }
543
+ if (NULL_BODY_STATUS.has(response.status) || !upstream.text) return new Response(null, {
544
+ status: response.status,
545
+ headers: NO_STORE
546
+ });
547
+ return new Response(upstream.text, {
548
+ status: response.status,
549
+ headers: {
550
+ "Content-Type": upstream.json !== null ? "application/json" : response.headers.get("content-type") ?? "text/plain",
551
+ ...NO_STORE
552
+ }
553
+ });
554
+ } catch (error) {
555
+ onError(error, {
556
+ method,
557
+ path
558
+ });
559
+ return jsonError(502, "upstream_unavailable", "The Shopwave API could not be reached");
560
+ }
561
+ }
562
+ async function readJsonBody(request) {
563
+ const text = await request.text();
564
+ if (!text) throw new RequestBodyError("Request body is empty");
565
+ try {
566
+ return JSON.parse(text);
567
+ } catch {
568
+ throw new RequestBodyError("Request body is not valid JSON");
569
+ }
570
+ }
571
+ function notSupported(def, what) {
572
+ return jsonError(405, "method_not_allowed", `The Shopwave API doesn't support ${what} for ${def.collection}`);
573
+ }
574
+ async function write(request, def, forcedId) {
575
+ if (!def.writable) return notSupported(def, "writes");
576
+ let postBody;
577
+ try {
578
+ postBody = normalizeWriteBody(def, await readJsonBody(request), forcedId);
579
+ } catch (error) {
580
+ if (error instanceof RequestBodyError) return jsonError(400, "invalid_body", error.message);
581
+ throw error;
582
+ }
583
+ return forward(request, {
584
+ method: "POST",
585
+ path: def.upstream,
586
+ postBody
587
+ });
588
+ }
589
+ function badId() {
590
+ return jsonError(400, "invalid_id", "Missing or invalid id");
591
+ }
592
+ return {
593
+ forward,
594
+ entity,
595
+ collection(kind) {
596
+ const def = entity(kind);
597
+ const save = (request) => write(request, def);
598
+ return {
599
+ GET: (request) => forward(request, { path: def.upstream }),
600
+ POST: save,
601
+ PUT: save
602
+ };
603
+ },
604
+ item(kind, options = {}) {
605
+ const def = entity(kind);
606
+ const param = options.param ?? "id";
607
+ return {
608
+ async GET(request, context) {
609
+ const id = await routeParam(context, param);
610
+ if (!id) return badId();
611
+ return forward(request, {
612
+ path: def.upstream,
613
+ headers: { [def.idsHeader]: id }
614
+ });
615
+ },
616
+ async PUT(request, context) {
617
+ const id = await routeParam(context, param);
618
+ if (!id) return badId();
619
+ return write(request, def, id);
620
+ },
621
+ async DELETE(request, context) {
622
+ const id = await routeParam(context, param);
623
+ if (!id) return badId();
624
+ if (def.deleteMode === "none") return notSupported(def, "deletes");
625
+ if (def.deleteMode === "retire") {
626
+ const field = def.retireField ?? "exitDate";
627
+ return forward(request, {
628
+ method: "POST",
629
+ path: def.upstream,
630
+ postBody: { [def.collection]: { 0: {
631
+ id: /^\d+$/.test(id) ? Number(id) : id,
632
+ [field]: shopwaveDateTime()
633
+ } } },
634
+ forwardExtras: false
635
+ });
636
+ }
637
+ return forward(request, {
638
+ method: "DELETE",
639
+ path: def.upstream,
640
+ headers: { [def.idHeader]: id },
641
+ forwardExtras: false
642
+ });
643
+ }
644
+ };
645
+ },
646
+ passthrough(path) {
647
+ return { GET: (request) => forward(request, { path }) };
648
+ }
649
+ };
650
+ }
651
+
250
652
  //#endregion
251
653
  //#region src/next/index.ts
252
654
  function normalizePath(p) {
@@ -525,8 +927,10 @@ function createShopwaveAuth(config) {
525
927
  sessionPath,
526
928
  ...options.publicPaths ?? []
527
929
  ])) return;
930
+ const isApi = pathMatches(pathname, options.apiPaths ?? ["/api"]);
931
+ if (isApi && options.allowRequestToken && readRequestToken(request)) return void 0;
528
932
  if (await isAuthenticated(request)) return void 0;
529
- if (pathMatches(pathname, options.apiPaths ?? ["/api"])) return NextResponse.json({ error: "unauthorized" }, {
933
+ if (isApi) return NextResponse.json({ error: "unauthorized" }, {
530
934
  status: 401,
531
935
  headers: noStore
532
936
  });
@@ -550,7 +954,34 @@ function createShopwaveAuth(config) {
550
954
  }
551
955
  };
552
956
  }
957
+ /**
958
+ * Ready-made Next.js route handlers for the SDK's `/api/*` contract, using the
959
+ * session from `createShopwaveAuth`. Create once (e.g. `lib/shopwave.ts`):
960
+ *
961
+ * ```ts
962
+ * export const shopwave = createShopwaveApi({ auth, apiUrl: process.env.SHOPWAVE_API_SERVER_URL! });
963
+ * ```
964
+ *
965
+ * then each route file is one line:
966
+ *
967
+ * ```ts
968
+ * // app/api/products/route.ts
969
+ * export const { GET, POST, PUT } = shopwave.collection("product");
970
+ * // app/api/products/[id]/route.ts
971
+ * export const { GET, PUT, DELETE } = shopwave.item("product");
972
+ * // app/api/report/route.ts
973
+ * export const { GET } = shopwave.passthrough("report");
974
+ * ```
975
+ */
976
+ function createShopwaveApi(config) {
977
+ if (typeof window !== "undefined") throw new Error("react-shopwave-connect/next is server-only. Import it from route handlers or server code.");
978
+ const { auth,...rest } = config;
979
+ return createShopwaveApiHandlers({
980
+ ...rest,
981
+ getAuthorization: (options) => auth.getAuthorizationHeader(options)
982
+ });
983
+ }
553
984
 
554
985
  //#endregion
555
- export { ShopwaveAuthError, authorizationHeader, createShopwaveAuth, isExpiredTokenResponse };
986
+ export { SHOPWAVE_ENTITIES, ShopwaveAuthError, authorizationHeader, createShopwaveApi, createShopwaveAuth, isExpiredTokenResponse, readRequestToken };
556
987
  //# sourceMappingURL=index.js.map