react-shopwave-connect 0.3.0 → 0.4.1

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.
@@ -127,6 +127,46 @@ type EntityKind = "product" | "category" | "store" | "promotion" | "employee" |
127
127
  */
128
128
  declare const SHOPWAVE_ENTITIES: Readonly<Record<EntityKind, EntityDefinition>>;
129
129
  //#endregion
130
+ //#region src/core/resources.d.ts
131
+ /**
132
+ * Singleton resources (one per logged-in user) and the uploader.
133
+ *
134
+ * Like `SHOPWAVE_ENTITIES`, this table is shared by the client functions
135
+ * (`fetchMerchant`, `fetchUser`, `uploadImage`, …) and the server route
136
+ * handlers in `react-shopwave-connect/next`, so the two halves of the contract
137
+ * can't drift apart. These aren't in `SHOPWAVE_ENTITIES` because they aren't
138
+ * keyed collections: `GET /merchant` answers `{ merchant: {…} }`, not a map by id.
139
+ */
140
+ interface ResourceDefinition {
141
+ /** App route: `/api/<route>`. */
142
+ route: string;
143
+ /** Key of the object in requests and responses (`{ merchant: {…} }`). */
144
+ key: string;
145
+ /** Shopwave API path. */
146
+ upstream: string;
147
+ /** False for read-only resources. */
148
+ writable: boolean;
149
+ }
150
+ type ResourceKind = "merchant" | "user";
151
+ /**
152
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
153
+ * updates it (without an `id` the API would create a second merchant, so the
154
+ * route requires one).
155
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
156
+ * needs the admin scope).
157
+ */
158
+ declare const SHOPWAVE_RESOURCES: Readonly<Record<ResourceKind, ResourceDefinition>>;
159
+ /**
160
+ * What an upload is for. Sent to Shopwave as the `contentType` header of
161
+ * `PUT /uploader`. `merchant` is for the receipt and square logos (stored
162
+ * under `…/merchant/<id>/logo/`), `merchantFeature` for the featured image.
163
+ * The API spells two of its values `applicaionLogo` / `applicaionImages`; use
164
+ * the correctly spelt names here and the server maps them.
165
+ */
166
+ type UploadKind = "merchant" | "merchantFeature" | "product" | "user" | "applicationLogo" | "applicationImages";
167
+ /** `UploadKind` → the API's `contentType` value. */
168
+ declare const UPLOAD_KINDS: Readonly<Record<UploadKind, string>>;
169
+ //#endregion
130
170
  //#region src/server/api.d.ts
131
171
  /**
132
172
  * The OAuth token a caller sent with the request, without its scheme, or `null`.
@@ -180,6 +220,8 @@ interface ForwardInit {
180
220
  headers?: Record<string, string>;
181
221
  /** Sent as the form field `postBody=<JSON>`. */
182
222
  postBody?: unknown;
223
+ /** Sent as a multipart body (e.g. uploads). Ignored when `postBody` is set. */
224
+ formData?: FormData;
183
225
  /** Forward the caller's `extras` header as upstream headers. Defaults to `true`. */
184
226
  forwardExtras?: boolean;
185
227
  }
@@ -204,6 +246,35 @@ interface ItemHandlers {
204
246
  /** Delete the record at `[id]`. Shopwave answers 205 with an empty body. */
205
247
  DELETE: RouteHandler;
206
248
  }
249
+ interface ResourceHandlers {
250
+ /** Read the resource (`GET {apiUrl}/<upstream>`). */
251
+ GET: RouteHandler;
252
+ /**
253
+ * Update it: body `{ <key>: { id, … } }` or the bare object. The `id` is
254
+ * required (without one Shopwave would create a new record). 405 for
255
+ * read-only resources.
256
+ */
257
+ PUT: RouteHandler;
258
+ /** Same as PUT. */
259
+ POST: RouteHandler;
260
+ }
261
+ interface UploadOptions {
262
+ /** Largest accepted file, in bytes. Defaults to 10 MB. */
263
+ maxBytes?: number;
264
+ /**
265
+ * Accepted MIME types. An entry ending in `/` is a prefix (`"image/"`).
266
+ * Defaults to `["image/"]`.
267
+ */
268
+ accept?: string[];
269
+ }
270
+ interface UploadHandlers {
271
+ /**
272
+ * Multipart `file` + `kind` (see `UploadKind`) → `PUT {apiUrl}/uploader`.
273
+ * Answers with Shopwave's reply (201 `{ fileName, path, api }`); 400 for a
274
+ * missing file or unknown kind, 413 when too large, 415 for other types.
275
+ */
276
+ POST: RouteHandler;
277
+ }
207
278
  interface ShopwaveApiHandlers {
208
279
  /** Calls the Shopwave API for this request (token, extras, refresh-retry) and returns its answer. */
209
280
  forward(request: Request, init: ForwardInit): Promise<Response>;
@@ -213,10 +284,14 @@ interface ShopwaveApiHandlers {
213
284
  item(kind: EntityKind, options?: {
214
285
  param?: string;
215
286
  }): ItemHandlers;
216
- /** GET-only proxy for a Shopwave path (e.g. `"report"`, `"user"`, `"merchant"`). */
287
+ /** GET-only proxy for a Shopwave path (e.g. `"report"`). */
217
288
  passthrough(path: string): {
218
289
  GET: RouteHandler;
219
290
  };
291
+ /** Handlers for a singleton resource: `app/api/merchant/route.ts`, `app/api/user/route.ts`. */
292
+ resource(kind: ResourceKind): ResourceHandlers;
293
+ /** Handler for `app/api/upload/route.ts`. */
294
+ upload(options?: UploadOptions): UploadHandlers;
220
295
  /** The entity table in use (defaults merged with `config.entities`). */
221
296
  entity(kind: EntityKind): EntityDefinition;
222
297
  }
@@ -415,9 +490,14 @@ interface ShopwaveNextApiConfig extends Omit<ShopwaveApiConfig, "getAuthorizatio
415
490
  * export const { GET, PUT, DELETE } = shopwave.item("product");
416
491
  * // app/api/report/route.ts
417
492
  * export const { GET } = shopwave.passthrough("report");
493
+ * // app/api/merchant/route.ts, app/api/user/route.ts
494
+ * export const { GET, PUT } = shopwave.resource("merchant");
495
+ * export const { GET } = shopwave.resource("user");
496
+ * // app/api/upload/route.ts
497
+ * export const { POST } = shopwave.upload();
418
498
  * ```
419
499
  */
420
500
  declare function createShopwaveApi(config: ShopwaveNextApiConfig): ShopwaveApiHandlers;
421
501
  //#endregion
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 };
502
+ export { AuthContext, type CollectionHandlers, type EntityDefinition, type EntityKind, type ForwardInit, type ItemHandlers, ProtectOptions, type ResourceHandlers, type ResourceKind, type RouteContext, type RouteHandler, SHOPWAVE_ENTITIES, SHOPWAVE_RESOURCES, type SessionStatus, type ShopwaveApiHandlers, ShopwaveAuth, ShopwaveAuthConfig, ShopwaveAuthError, ShopwaveNextApiConfig, ShopwaveSessionConfig, ShopwaveSessionData, type ShopwaveToken, UPLOAD_KINDS, type UploadHandlers, type UploadKind, type UploadOptions, authorizationHeader, createShopwaveApi, createShopwaveAuth, isExpiredTokenResponse, readRequestToken };
423
503
  //# sourceMappingURL=index.d.ts.map
@@ -319,6 +319,50 @@ const SHOPWAVE_ENTITIES = Object.freeze({
319
319
  }
320
320
  });
321
321
 
322
+ //#endregion
323
+ //#region src/core/resources.ts
324
+ /**
325
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
326
+ * updates it (without an `id` the API would create a second merchant, so the
327
+ * route requires one).
328
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
329
+ * needs the admin scope).
330
+ */
331
+ const SHOPWAVE_RESOURCES = Object.freeze({
332
+ merchant: {
333
+ route: "merchant",
334
+ key: "merchant",
335
+ upstream: "merchant",
336
+ writable: true
337
+ },
338
+ user: {
339
+ route: "user",
340
+ key: "user",
341
+ upstream: "user",
342
+ writable: false
343
+ }
344
+ });
345
+ /** `UploadKind` → the API's `contentType` value. */
346
+ const UPLOAD_KINDS = Object.freeze({
347
+ merchant: "merchant",
348
+ merchantFeature: "merchantFeature",
349
+ product: "product",
350
+ user: "user",
351
+ applicationLogo: "applicaionLogo",
352
+ applicationImages: "applicaionImages"
353
+ });
354
+ /** How uploads are addressed (app route and Shopwave uploader). */
355
+ const SHOPWAVE_UPLOAD = Object.freeze({
356
+ route: "upload",
357
+ upstream: "uploader",
358
+ kindHeader: "contentType",
359
+ fileField: "file",
360
+ kindField: "kind"
361
+ });
362
+ function isUploadKind(value) {
363
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
364
+ }
365
+
322
366
  //#endregion
323
367
  //#region src/server/api.ts
324
368
  /**
@@ -517,13 +561,14 @@ function createShopwaveApiHandlers(config) {
517
561
  return jsonError(502, "auth_unavailable", "Could not refresh the Shopwave session");
518
562
  }
519
563
  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) });
564
+ const body = init.postBody !== void 0 ? new URLSearchParams({ postBody: JSON.stringify(init.postBody) }) : init.formData;
521
565
  const call = (authz) => {
522
566
  const headers = new Headers(extras);
523
567
  for (const [k, v] of Object.entries(init.headers ?? {})) headers.set(k, v);
524
568
  headers.set("Authorization", authz);
525
569
  headers.set("x-accept-version", apiVersion);
526
- if (body) headers.set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
570
+ if (body instanceof URLSearchParams) headers.set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
571
+ else if (body) headers.delete("Content-Type");
527
572
  return (config.fetch ?? fetch)(`${apiBase()}/${path}`, {
528
573
  method,
529
574
  headers,
@@ -586,6 +631,66 @@ function createShopwaveApiHandlers(config) {
586
631
  postBody
587
632
  });
588
633
  }
634
+ function resourceDef(kind) {
635
+ const def = SHOPWAVE_RESOURCES[kind];
636
+ if (!def) throw new Error(`Unknown Shopwave resource "${kind}"`);
637
+ return def;
638
+ }
639
+ async function writeResource(request, def) {
640
+ if (!def.writable) return jsonError(405, "method_not_allowed", `The Shopwave API doesn't support writes for ${def.key}`);
641
+ let record;
642
+ try {
643
+ const body = await readJsonBody(request);
644
+ if (!isPlainObject(body)) throw new RequestBodyError("Request body must be a JSON object");
645
+ const inner = body[def.key];
646
+ if (inner !== void 0 && !isPlainObject(inner)) throw new RequestBodyError(`"${def.key}" must be an object`);
647
+ record = { ...inner ?? body };
648
+ const id = record.id;
649
+ if (id == null || id === "" || !ID_PATTERN.test(String(id))) throw new RequestBodyError(`Missing or invalid ${def.key} id`);
650
+ record.id = /^\d+$/.test(String(id)) ? Number(id) : id;
651
+ } catch (error) {
652
+ if (error instanceof RequestBodyError) return jsonError(400, "invalid_body", error.message);
653
+ throw error;
654
+ }
655
+ return forward(request, {
656
+ method: "POST",
657
+ path: def.upstream,
658
+ postBody: { [def.key]: record }
659
+ });
660
+ }
661
+ function acceptsType(type, accept) {
662
+ const t = type.toLowerCase().split(";")[0].trim();
663
+ return accept.some((a) => {
664
+ const rule = a.toLowerCase();
665
+ return rule.endsWith("/") ? t.startsWith(rule) : t === rule;
666
+ });
667
+ }
668
+ async function uploadFile(request, options) {
669
+ const maxBytes = options.maxBytes ?? 10 * 1024 * 1024;
670
+ const accept = options.accept ?? ["image/"];
671
+ if (Number(request.headers.get("content-length") ?? 0) > maxBytes + 64 * 1024) return jsonError(413, "too_large", `The file is larger than ${maxBytes} bytes`);
672
+ let form;
673
+ try {
674
+ form = await request.formData();
675
+ } catch {
676
+ return jsonError(400, "invalid_body", "Expected a multipart/form-data body");
677
+ }
678
+ const file = form.get(SHOPWAVE_UPLOAD.fileField);
679
+ if (!file || typeof file === "string" || file.size === 0) return jsonError(400, "invalid_body", `Missing "${SHOPWAVE_UPLOAD.fileField}"`);
680
+ const kind = form.get(SHOPWAVE_UPLOAD.kindField);
681
+ if (!isUploadKind(kind)) return jsonError(400, "invalid_body", `"${SHOPWAVE_UPLOAD.kindField}" must be one of ${Object.keys(UPLOAD_KINDS).join(", ")}`);
682
+ if (file.size > maxBytes) return jsonError(413, "too_large", `The file is larger than ${maxBytes} bytes`);
683
+ if (!acceptsType(file.type || "", accept)) return jsonError(415, "unsupported_type", `Files of type "${file.type || "unknown"}" aren't accepted`);
684
+ const upstreamForm = new FormData();
685
+ upstreamForm.append(SHOPWAVE_UPLOAD.fileField, file, file.name || "upload");
686
+ return forward(request, {
687
+ method: "PUT",
688
+ path: SHOPWAVE_UPLOAD.upstream,
689
+ headers: { [SHOPWAVE_UPLOAD.kindHeader]: UPLOAD_KINDS[kind] },
690
+ formData: upstreamForm,
691
+ forwardExtras: false
692
+ });
693
+ }
589
694
  function badId() {
590
695
  return jsonError(400, "invalid_id", "Missing or invalid id");
591
696
  }
@@ -645,6 +750,18 @@ function createShopwaveApiHandlers(config) {
645
750
  },
646
751
  passthrough(path) {
647
752
  return { GET: (request) => forward(request, { path }) };
753
+ },
754
+ resource(kind) {
755
+ const def = resourceDef(kind);
756
+ const save = (request) => writeResource(request, def);
757
+ return {
758
+ GET: (request) => forward(request, { path: def.upstream }),
759
+ PUT: save,
760
+ POST: save
761
+ };
762
+ },
763
+ upload(options = {}) {
764
+ return { POST: (request) => uploadFile(request, options) };
648
765
  }
649
766
  };
650
767
  }
@@ -971,6 +1088,11 @@ function createShopwaveAuth(config) {
971
1088
  * export const { GET, PUT, DELETE } = shopwave.item("product");
972
1089
  * // app/api/report/route.ts
973
1090
  * export const { GET } = shopwave.passthrough("report");
1091
+ * // app/api/merchant/route.ts, app/api/user/route.ts
1092
+ * export const { GET, PUT } = shopwave.resource("merchant");
1093
+ * export const { GET } = shopwave.resource("user");
1094
+ * // app/api/upload/route.ts
1095
+ * export const { POST } = shopwave.upload();
974
1096
  * ```
975
1097
  */
976
1098
  function createShopwaveApi(config) {
@@ -983,5 +1105,5 @@ function createShopwaveApi(config) {
983
1105
  }
984
1106
 
985
1107
  //#endregion
986
- export { SHOPWAVE_ENTITIES, ShopwaveAuthError, authorizationHeader, createShopwaveApi, createShopwaveAuth, isExpiredTokenResponse, readRequestToken };
1108
+ export { SHOPWAVE_ENTITIES, SHOPWAVE_RESOURCES, ShopwaveAuthError, UPLOAD_KINDS, authorizationHeader, createShopwaveApi, createShopwaveAuth, isExpiredTokenResponse, readRequestToken };
987
1109
  //# sourceMappingURL=index.js.map