react-shopwave-connect 0.1.2 → 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.
- package/README.md +281 -39
- package/dist/core/index.cjs +435 -93
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +241 -28
- package/dist/core/index.d.ts +241 -28
- package/dist/core/index.js +405 -94
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +328 -116
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +57 -12
- package/dist/hooks/index.d.ts +57 -12
- package/dist/hooks/index.js +329 -118
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +553 -112
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +265 -29
- package/dist/index.d.ts +265 -29
- package/dist/index.js +523 -114
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +1019 -0
- package/dist/next/index.cjs.map +1 -0
- package/dist/next/index.d.cts +423 -0
- package/dist/next/index.d.ts +423 -0
- package/dist/next/index.js +987 -0
- package/dist/next/index.js.map +1 -0
- package/dist/server/index.cjs +670 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +346 -0
- package/dist/server/index.d.ts +346 -0
- package/dist/server/index.js +650 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +41 -8
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@ Shopwave API client split into two clearly separated layers:
|
|
|
4
4
|
|
|
5
5
|
- **`core`** — framework-agnostic async API functions + all TypeScript types. No React anywhere. Works in any TS/JS project (Node script, Vue, Angular, CLI, …).
|
|
6
6
|
- **`hooks`** — thin React wrappers around `core`, with a consistent `{ data, loading, error, refetch }` shape. React is a **peer dependency** and is never bundled.
|
|
7
|
+
- **`server` / `next`** — the other half of the contract: OAuth login and the `/api/*` route handlers that `core` calls, so an app's API routes are one line each.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
browser / Node ──core──▶ your app's /api/<route> ──next route handlers──▶ Shopwave API
|
|
11
|
+
(session cookie or Authorization: OAuth <token>)
|
|
12
|
+
```
|
|
7
13
|
|
|
8
14
|
```
|
|
9
15
|
src/
|
|
@@ -28,6 +34,8 @@ npm install react react-dom
|
|
|
28
34
|
| `react-shopwave-connect/core` | Framework-agnostic functions + types only |
|
|
29
35
|
| `react-shopwave-connect/hooks` | React hooks only |
|
|
30
36
|
| `react-shopwave-connect` | Both (re-exports `core` + `hooks`) |
|
|
37
|
+
| `react-shopwave-connect/server` | **Server-only.** Framework-agnostic Shopwave OAuth client + token helpers |
|
|
38
|
+
| `react-shopwave-connect/next` | **Server-only.** Next.js login/callback/logout/session handlers, token refresh, proxy guard, and the `/api/*` route handlers (`createShopwaveApi`) |
|
|
31
39
|
|
|
32
40
|
Prefer the subpath imports when you want a hard boundary — e.g. a Node service should import from `/core` so React never enters the dependency graph.
|
|
33
41
|
|
|
@@ -39,13 +47,18 @@ Every `core` function (and every hook) accepts an optional `options` argument:
|
|
|
39
47
|
interface RequestOptions {
|
|
40
48
|
baseUrl?: string; // prefix for every path; omit in the browser to use
|
|
41
49
|
// relative URLs like "/api/products"
|
|
42
|
-
token?: string; //
|
|
50
|
+
token?: string; // sent as `Authorization: OAuth <token>` on every
|
|
51
|
+
// request (GET, POST, PUT, DELETE alike)
|
|
43
52
|
fetch?: typeof fetch; // custom fetch (Node < 18, tests, interceptors)
|
|
44
53
|
signal?: AbortSignal; // cancellation (hooks pass this automatically)
|
|
45
54
|
}
|
|
46
55
|
```
|
|
47
56
|
|
|
48
|
-
In the browser / Next.js you can usually omit `options` entirely (relative URLs resolve against the current origin). Outside the browser, pass
|
|
57
|
+
In the browser / Next.js you can usually omit `options` entirely (relative URLs resolve against the current origin, and the app's routes use the session cookie). Outside the browser, pass the absolute `baseUrl` of an app that mounts the SDK routes (see [API routes](#4-api-routes-nextjs)) and a `token`.
|
|
58
|
+
|
|
59
|
+
`core` always calls `/api/<route>` paths with the request metadata in an `extras` header, so `baseUrl` must point at such an app, not at the Shopwave API itself.
|
|
60
|
+
|
|
61
|
+
> **0.3 change:** the token used to travel in `extras.token` for reads and a separate `token` header for writes. It is now always the `Authorization` header. The 0.3 route handlers still accept both old forms, so older clients keep working.
|
|
49
62
|
|
|
50
63
|
---
|
|
51
64
|
|
|
@@ -62,8 +75,8 @@ import {
|
|
|
62
75
|
} from "react-shopwave-connect/core";
|
|
63
76
|
|
|
64
77
|
const options = {
|
|
65
|
-
baseUrl: "https://
|
|
66
|
-
token: process.env.SHOPWAVE_TOKEN,
|
|
78
|
+
baseUrl: "https://admin.example.com", // an app with the SDK's /api routes
|
|
79
|
+
token: process.env.SHOPWAVE_TOKEN, // bare access token
|
|
67
80
|
};
|
|
68
81
|
|
|
69
82
|
async function main() {
|
|
@@ -92,23 +105,80 @@ On Node < 18 (no global `fetch`), inject one:
|
|
|
92
105
|
import { fetchStores } from "react-shopwave-connect/core";
|
|
93
106
|
import fetch from "node-fetch";
|
|
94
107
|
|
|
95
|
-
await fetchStores({}, { baseUrl: "https://
|
|
108
|
+
await fetchStores({}, { baseUrl: "https://admin.example.com", token, fetch });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Functions **throw** `ShopwaveApiError` on failure:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { saveProduct, ShopwaveApiError } from "react-shopwave-connect/core";
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await saveProduct({ name: "Tea", barcode: "123" });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (e instanceof ShopwaveApiError) {
|
|
120
|
+
e.status; // HTTP status (0 = no response). 200/201 when the API answered with errors.
|
|
121
|
+
e.errors; // api.message.errors, e.g. { 908: { id: 908, title: … } }
|
|
122
|
+
e.isUnauthorized; // 401 or error 908 → send the user to log in
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The SDK never logs requests (older versions `console.log`ged headers, including tokens).
|
|
128
|
+
|
|
129
|
+
### Saving and deleting (typed)
|
|
130
|
+
|
|
131
|
+
Each entity has `save…`, `delete…` and `fetch…(id)`:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { saveCategory, deleteCategory, fetchCategory } from "react-shopwave-connect/core";
|
|
135
|
+
|
|
136
|
+
const created = await saveCategory({ title: "Drinks", parentId: null }); // no id → create
|
|
137
|
+
created.id; // the new id, straight from the save
|
|
138
|
+
|
|
139
|
+
const updated = await saveCategory({ id: created.id, title: "Hot drinks" }); // id → update
|
|
140
|
+
|
|
141
|
+
await fetchCategory(created.id); // Category | null
|
|
142
|
+
await fetchCategory(created.id, { deleted: true }); // include soft-deleted
|
|
143
|
+
|
|
144
|
+
await deleteCategory(created.id);
|
|
96
145
|
```
|
|
97
146
|
|
|
98
|
-
|
|
147
|
+
| Entity | Save / delete / read one | App routes |
|
|
148
|
+
| --------- | --------------------------------------------------------- | ----------------------------- |
|
|
149
|
+
| product | `saveProduct`, `deleteProduct`, `fetchProduct` | `/api/products[/:id]` |
|
|
150
|
+
| category | `saveCategory`, `deleteCategory`, `fetchCategory` | `/api/categories[/:id]` |
|
|
151
|
+
| store | `saveStore`, `deleteStore`, `fetchStore` | `/api/stores[/:id]` |
|
|
152
|
+
| promotion | `savePromotion`, `deletePromotion` (ends it), `fetchPromotion` | `/api/promotions[/:id]` |
|
|
153
|
+
| employee | `saveEmployee`, `deleteEmployee` (retires), `fetchEmployee` | `/api/employees[/:id]` |
|
|
154
|
+
| consumer | `fetchConsumer` only — read-only in the API | `/api/consumer[/:id]` (GET) |
|
|
155
|
+
|
|
156
|
+
How they behave (matches what the Shopwave API does):
|
|
157
|
+
|
|
158
|
+
- **Save** sends `{ <collection>: { "0": entity } }`. Shopwave upserts (an `id` means update), answers **201** and echoes the entity under the same ref with its `id` and server fields. The function checks `api.message.errors` and that the ref came back, and returns the saved entity (fields the echo leaves out are kept from your input). `saveEntities(kind, [a, b])` saves several at once and matches results back by ref.
|
|
159
|
+
- **Delete** is a soft delete (read it back with `deleted: true`). Shopwave answers **205 with an empty body — also for ids that don't exist**, so a resolved promise means "accepted", not "a record was deleted".
|
|
160
|
+
- **Read one** calls `GET /api/<route>/:id` and returns the record or `null`. List reads return `[]` when nothing matches (Shopwave answers with an empty body).
|
|
161
|
+
- **Promotions:** Shopwave has no promotion DELETE; `deletePromotion` (and `DELETE /api/promotions/:id`) ends the promotion by setting `endDate` to now.
|
|
162
|
+
- **Employees:** Shopwave has no employee DELETE, so `deleteEmployee` (and `DELETE /api/employees/:id`) retires the employee by setting `exitDate`. Updating an existing employee only changes `roleId`, `joinedDate` and `exitDate` — names and email are fixed — and the echo carries only those fields, so re-read with `fetchEmployee` if you need the stored record.
|
|
163
|
+
- **Consumers** are read-only (`GET /consumer` by `ids`); their write/delete routes answer 405.
|
|
164
|
+
- Errors are read from `api.message.errors` and also `api.message.error` (the name used in the [API reference](https://developer.merchantstack.com/api-reference.html)).
|
|
165
|
+
|
|
166
|
+
The generic forms are `saveEntity(kind, item)`, `deleteEntityById(kind, id)` and `fetchEntityById(kind, id)`; `SHOPWAVE_ENTITIES` lists each entity's route, collection key, Shopwave path and id headers.
|
|
99
167
|
|
|
100
168
|
### Available `core` functions
|
|
101
169
|
|
|
102
170
|
| Domain | Function(s) |
|
|
103
171
|
| --------- | ------------------------------------------------------- |
|
|
104
|
-
| category | `fetchCategories`
|
|
105
|
-
| consumer | `fetchConsumers`
|
|
106
|
-
| employee | `fetchEmployees`
|
|
107
|
-
| product | `fetchProducts`, `fetchProductsMap` (batched, keyed)
|
|
108
|
-
|
|
|
172
|
+
| category | `fetchCategories`, `fetchCategory`, `saveCategory`, `deleteCategory` |
|
|
173
|
+
| consumer | `fetchConsumers`, `fetchConsumer` (read-only) |
|
|
174
|
+
| employee | `fetchEmployees`, `fetchEmployee`, `saveEmployee`, `deleteEmployee` |
|
|
175
|
+
| product | `fetchProducts`, `fetchProductsMap` (batched, keyed), `fetchProduct`, `saveProduct`, `deleteProduct` |
|
|
176
|
+
| promotion | `fetchPromotions`, `fetchPromotion`, `savePromotion`, `deletePromotion` |
|
|
177
|
+
| store | `fetchStores` (now with `storeIds`), `fetchStore`, `saveStore`, `deleteStore` |
|
|
109
178
|
| report | `fetchReport` |
|
|
110
|
-
| session | `logout`
|
|
111
|
-
| entity | `
|
|
179
|
+
| session | `fetchSession`, `loginPath`, `logoutPath`, `logout` |
|
|
180
|
+
| entity | `saveEntity`, `saveEntities`, `deleteEntityById`, `fetchEntityById`; low-level `submitEntity` / `deleteEntity` (raw endpoint) |
|
|
181
|
+
| errors | `ShopwaveApiError`, `getApiErrorMap`, `assertNoApiErrors` |
|
|
112
182
|
| basket | `buildBasketReportQuery`, `parseBasketReportData`, `combineBasketRows`, `computeBasketSummary`, … (pure transforms) |
|
|
113
183
|
|
|
114
184
|
All types/interfaces (`Product`, `Store`, `Category`, `Consumer`, `Employee`, `ReportQueryMap`, `Basket*`, `apiResponse`, …) are exported from `core` too.
|
|
@@ -120,9 +190,13 @@ All types/interfaces (`Product`, `Store`, `Category`, `Consumer`, `Employee`, `R
|
|
|
120
190
|
Every auto-fetching hook returns the same shape:
|
|
121
191
|
|
|
122
192
|
```ts
|
|
123
|
-
{ data, loading, error, refetch }
|
|
193
|
+
{ data, loading, fetching, error, errorStatus, refetch }
|
|
124
194
|
```
|
|
125
195
|
|
|
196
|
+
- `loading` is true only while there's nothing to show yet (first load, or the params changed).
|
|
197
|
+
- `refetch()` keeps the current `data` on screen until the new data arrives (`fetching` is true meanwhile), so lists don't flash a skeleton after every save. A failed refetch keeps the old data and sets `error`.
|
|
198
|
+
- `errorStatus` is the HTTP status of the last error.
|
|
199
|
+
|
|
126
200
|
```tsx
|
|
127
201
|
"use client";
|
|
128
202
|
import { useProduct, useStore } from "react-shopwave-connect/hooks";
|
|
@@ -150,12 +224,14 @@ const { data } = useProduct({ storeId }, { baseUrl: "https://api.merchantstack.c
|
|
|
150
224
|
|
|
151
225
|
### Auto-fetch hooks (`useEffect`-based)
|
|
152
226
|
|
|
153
|
-
`useCategory`, `useConsumer`, `useEmployee`, `useProduct`, `useStore`, `useReport`.
|
|
227
|
+
`useCategory`, `useConsumer`, `useEmployee`, `useProduct`, `usePromotion`, `useStore`, `useReport`, `useSession`.
|
|
154
228
|
They fetch on mount and re-run when their arguments change. `useConsumer` and `useReport` stay idle until you pass ids / a query (they return `loading: false`, `data: null` until then). `refetch()` replaces the old `reloadFlag` argument.
|
|
155
229
|
|
|
156
230
|
### Manually-triggered hooks (`useCallback`-based)
|
|
157
231
|
|
|
158
|
-
|
|
232
|
+
For saves and deletes, prefer calling the typed `core` functions (`saveProduct`, `deleteStore`, …) from your event handlers — they return the saved entity with its id.
|
|
233
|
+
|
|
234
|
+
`useDelete`, `useSubmit`, `useLogout` return `{ mutate, data, loading, error, errorStatus }` — nothing fires until you call `mutate`:
|
|
159
235
|
|
|
160
236
|
```tsx
|
|
161
237
|
import { useDelete, useSubmit } from "react-shopwave-connect/hooks";
|
|
@@ -179,37 +255,188 @@ const saved = await save({
|
|
|
179
255
|
|
|
180
256
|
---
|
|
181
257
|
|
|
182
|
-
##
|
|
258
|
+
## 3. Authentication (Shopwave OAuth)
|
|
259
|
+
|
|
260
|
+
Login is the same for every Shopwave app; only the **config** differs (client id/secret, redirect URL, session secret). The Shopwave auth server uses the authorization-code flow **with a client secret** (no PKCE), so the exchange must happen on a server — the browser only follows redirects and never sees a token.
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
browser ──/auth?returnTo=/products──▶ your app ──302──▶ {authServer}/login?…&state=…
|
|
264
|
+
◀──────────── user signs in on Shopwave ─────────────┘
|
|
265
|
+
browser ──/auth?code=…&state=…──▶ your app ──POST /oauth/token (secret)──▶ auth server
|
|
266
|
+
◀──302 /products + encrypted httpOnly cookie (tokens stay server-side)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
What the SDK handles for you:
|
|
270
|
+
|
|
271
|
+
- `state` check (login-CSRF protection) and a safe `returnTo` (same-origin paths only).
|
|
272
|
+
- One callback URL per app: `returnTo` rides along in the session, so tools/sub-sections don't need their own redirect URIs.
|
|
273
|
+
- Tokens stored as `{ accessToken, refreshToken, tokenType, expiresAt }` in an **iron-session** cookie (httpOnly, SameSite=Lax, Secure in production).
|
|
274
|
+
- Refresh when the access token has expired (Shopwave issues a new one only after expiry; the refresh token is not rotated), de-duplicated across concurrent requests. A rejected refresh token logs the user out.
|
|
275
|
+
- `GET /api/session` returns `{ loggedIn, expiresAt }` — **never tokens**.
|
|
276
|
+
- Sessions written by older apps (raw `{ access_token, refresh_token, … }` in `session.token`) keep working.
|
|
277
|
+
|
|
278
|
+
### Next.js (App Router) setup
|
|
279
|
+
|
|
280
|
+
Install the peer dependencies once: `npm install react-shopwave-connect iron-session`.
|
|
183
281
|
|
|
184
|
-
|
|
282
|
+
**1. One config file per app**
|
|
185
283
|
|
|
186
|
-
|
|
187
|
-
|
|
284
|
+
```ts
|
|
285
|
+
// lib/auth.ts
|
|
286
|
+
import { createShopwaveAuth } from "react-shopwave-connect/next";
|
|
287
|
+
|
|
288
|
+
export const auth = createShopwaveAuth({
|
|
289
|
+
authServerUrl: process.env.SHOPWAVE_AUTH_SERVER_URL!, // e.g. https://secure.merchantstack.com
|
|
290
|
+
clientId: process.env.SHOPWAVE_CLIENT_ID!,
|
|
291
|
+
clientSecret: process.env.SHOPWAVE_CLIENT_SECRET!,
|
|
292
|
+
redirectUri: process.env.SHOPWAVE_REDIRECT_URL!, // e.g. https://admin.example.com/auth (registered on the auth server)
|
|
293
|
+
session: {
|
|
294
|
+
password: process.env.SESSION_SECRET!, // ≥ 32 random chars
|
|
295
|
+
cookieName: "shopwave_session_cookie", // keep your existing name to keep users logged in
|
|
296
|
+
},
|
|
297
|
+
// authPath: "/auth", logoutPath: "/auth/logout", sessionPath: "/api/session",
|
|
298
|
+
// defaultReturnTo: "/", requireState: true, refreshSkewSeconds: 0,
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Config is validated on first use, so a missing env var doesn't break `next build`.
|
|
303
|
+
|
|
304
|
+
**2. Three route files**
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
// app/auth/route.ts — the redirect-URI path: starts login AND receives the callback
|
|
308
|
+
import { auth } from "@/lib/auth";
|
|
309
|
+
export const GET = auth.handlers.auth;
|
|
310
|
+
|
|
311
|
+
// app/auth/logout/route.ts — clears the session, then logs out of the auth server
|
|
312
|
+
import { auth } from "@/lib/auth";
|
|
313
|
+
export const GET = auth.handlers.logout;
|
|
314
|
+
|
|
315
|
+
// app/api/session/route.ts — { loggedIn, expiresAt } for the browser; DELETE ends the session
|
|
316
|
+
import { auth } from "@/lib/auth";
|
|
317
|
+
export const { GET, DELETE } = auth.handlers.session;
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**3. Protect pages and APIs** (`proxy.ts` in Next 16, `middleware.ts` before that)
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
324
|
+
import { auth } from "@/lib/auth";
|
|
325
|
+
|
|
326
|
+
export async function proxy(request: NextRequest) {
|
|
327
|
+
return (await auth.protect(request, { publicPaths: ["/tools/tag-joiner"] })) ?? NextResponse.next();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Logged-out page requests are redirected to `/auth?returnTo=<page>`; paths under `/api` get a `401` JSON instead. The auth, logout and session paths are always public. `protect` only reads the cookie — it never calls the auth server.
|
|
334
|
+
|
|
335
|
+
**4. Use the token in route handlers**
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
import { auth } from "@/lib/auth";
|
|
339
|
+
import { isExpiredTokenResponse } from "react-shopwave-connect/next";
|
|
340
|
+
|
|
341
|
+
export const GET = auth.withAuth(async (request, context, { authorization }) => {
|
|
342
|
+
const call = (header: string) =>
|
|
343
|
+
fetch(`${process.env.SHOPWAVE_API_SERVER_URL}/product`, {
|
|
344
|
+
headers: { Authorization: header, "x-accept-version": "2.0" },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
let res = await call(authorization);
|
|
348
|
+
let body = await res.json();
|
|
349
|
+
|
|
350
|
+
// Clock said valid but the API says expired (HTTP 401 / API error 908): refresh once and retry.
|
|
351
|
+
if (isExpiredTokenResponse(res.status, body)) {
|
|
352
|
+
const fresh = await auth.getAuthorizationHeader({ forceRefresh: true });
|
|
353
|
+
if (!fresh) return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
354
|
+
res = await call(fresh);
|
|
355
|
+
body = await res.json();
|
|
356
|
+
}
|
|
357
|
+
return Response.json(body, { status: res.status });
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Also available: `auth.getAccessToken()`, `auth.getAuthorizationHeader()`, `auth.getToken()`, `auth.getStatus()`, `auth.loginPath(returnTo)`, `auth.logoutPath`, `auth.isAuthenticated(request)`. Refreshed tokens are saved automatically from route handlers, server actions and proxy (Server Components can read tokens but can't write cookies).
|
|
362
|
+
|
|
363
|
+
**5. In the browser**
|
|
188
364
|
|
|
189
365
|
```tsx
|
|
190
366
|
"use client";
|
|
191
|
-
import {
|
|
192
|
-
|
|
193
|
-
|
|
367
|
+
import { useSession, loginPath, logoutPath } from "react-shopwave-connect";
|
|
368
|
+
|
|
369
|
+
export function AccountButton() {
|
|
370
|
+
const { data: session, loading } = useSession();
|
|
371
|
+
if (loading) return null;
|
|
372
|
+
return session?.loggedIn
|
|
373
|
+
? <a href={logoutPath()}>Log out</a>
|
|
374
|
+
: <a href={loginPath(window.location.pathname)}>Log in</a>;
|
|
375
|
+
}
|
|
376
|
+
```
|
|
194
377
|
|
|
195
|
-
|
|
196
|
-
const router = useRouter();
|
|
197
|
-
const { mutate: endSession } = useLogout();
|
|
378
|
+
Use plain links / `window.location` for login and logout — they are full-page redirects to the auth server, not client-side navigations.
|
|
198
379
|
|
|
199
|
-
|
|
200
|
-
await endSession();
|
|
201
|
-
router.push(await getLogoutUrl(window.location.origin + "/auth"));
|
|
202
|
-
};
|
|
380
|
+
### 4. API routes (Next.js)
|
|
203
381
|
|
|
204
|
-
|
|
205
|
-
|
|
382
|
+
`core` calls `/api/<route>` on your app. `createShopwaveApi` provides those routes, using the session from step 1:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
// lib/shopwave.ts
|
|
386
|
+
import { createShopwaveApi } from "react-shopwave-connect/next";
|
|
387
|
+
import { auth } from "@/lib/auth";
|
|
388
|
+
|
|
389
|
+
export const shopwave = createShopwaveApi({ auth, apiUrl: process.env.SHOPWAVE_API_SERVER_URL! });
|
|
206
390
|
```
|
|
207
391
|
|
|
392
|
+
```ts
|
|
393
|
+
// app/api/products/route.ts — list/filter, create/update
|
|
394
|
+
import { shopwave } from "@/lib/shopwave";
|
|
395
|
+
export const { GET, POST, PUT } = shopwave.collection("product");
|
|
396
|
+
|
|
397
|
+
// app/api/products/[id]/route.ts — read one, update, delete
|
|
398
|
+
import { shopwave } from "@/lib/shopwave";
|
|
399
|
+
export const { GET, PUT, DELETE } = shopwave.item("product");
|
|
400
|
+
|
|
401
|
+
// app/api/report/route.ts — any other Shopwave path, read-only
|
|
402
|
+
import { shopwave } from "@/lib/shopwave";
|
|
403
|
+
export const { GET } = shopwave.passthrough("report");
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Entities: `product`, `category`, `store`, `promotion`, `employee` (DELETE retires via `exitDate`), `promotion` (DELETE ends it via `endDate`), `consumer` (mounted at `/api/consumer`; GET only, writes answer 405). For anything else, `shopwave.forward(request, { method, path, headers, postBody })` does the same token/refresh/encoding work for a custom handler.
|
|
407
|
+
|
|
408
|
+
Every handler:
|
|
409
|
+
|
|
410
|
+
- uses the caller's `Authorization: OAuth <token>` when present (also the legacy `token` header / `extras.token`), otherwise the logged-in user's token; `401` when there's neither. Turn caller tokens off with `allowRequestToken: false`.
|
|
411
|
+
- turns the `extras` JSON header into upstream headers, dropping `Authorization`, `token`, `Cookie`, `Host`, `x-accept-version` and other transport headers; `400` if it isn't a JSON object.
|
|
412
|
+
- sends writes as the form field `postBody=<JSON>`, accepting the SDK shape `{ products: { "0": {…} } }`, the older `{ products: { new | updated: {…} } }`, or a bare entity. Item `PUT` forces the id from the URL.
|
|
413
|
+
- on HTTP 401 or API error 908 with the session token, refreshes once and retries.
|
|
414
|
+
- answers with Shopwave's own status and body (201 + echo for saves, 205 + empty body for deletes, error envelopes as-is); `502` if the API can't be reached. Only the method and path are ever logged.
|
|
415
|
+
|
|
416
|
+
For calls that bring their own token (scripts, the integration tests), let them past the proxy guard:
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
await auth.protect(request, { publicPaths: [...], allowRequestToken: true });
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Options: `apiVersion` (default `"2.0"`), `fetch`, `entities` (override a route/header, e.g. `{ employee: { idHeader: "userId" } }`), `blockedExtras`, `onError`.
|
|
423
|
+
|
|
424
|
+
### Other frameworks
|
|
425
|
+
|
|
426
|
+
`react-shopwave-connect/server` has the same building blocks without Next.js: `createShopwaveOAuth(config)` → `buildLoginUrl({ state })`, `exchangeCode(code)`, `refreshToken(token)`, `buildLogoutUrl()`, plus `createState`, `sanitizeReturnTo`, `isTokenExpired`, `isExpiredTokenResponse` and `authorizationHeader`. The API routes are `createShopwaveApiHandlers({ apiUrl, getAuthorization })` — plain `Request → Response` handlers, the same ones `createShopwaveApi` wraps. Store the token in your framework's session (Express `express-session`, iron-session's `getIronSession(req, res)`, …).
|
|
427
|
+
|
|
428
|
+
### Moving an existing app over
|
|
429
|
+
|
|
430
|
+
- Keep `redirectUri` equal to the URL already registered on the auth server (AdminUI: `…/auth`) and mount `handlers.auth` at that path.
|
|
431
|
+
- Keep `cookieName` the same and use the same `SESSION_SECRET` value as the old iron-session password — current users stay logged in; their old token shape is read and upgraded on the next refresh.
|
|
432
|
+
- Per-tool auth routes can go: link to `auth.loginPath("/tools/where-to-next")` instead.
|
|
433
|
+
- `core.logout()` / `useLogout()` still work (`DELETE /api/session`), but prefer linking to `logoutPath()` so the auth-server session ends too.
|
|
434
|
+
|
|
208
435
|
---
|
|
209
436
|
|
|
210
437
|
## Build
|
|
211
438
|
|
|
212
|
-
Built with [`tsdown`](https://tsdown.dev) into dual ESM + CJS with `.d.ts` types, via
|
|
439
|
+
Built with [`tsdown`](https://tsdown.dev) into dual ESM + CJS with `.d.ts` types, via separate entries: `core` (no externals), `hooks` (`react`/`react-dom` external), `server` (no externals) and `next` (`next`/`iron-session` external — optional peer dependencies). `server` and `next` are never re-exported from the root entry, so they can't end up in client bundles.
|
|
213
440
|
|
|
214
441
|
```bash
|
|
215
442
|
npm run build # emit dist/
|
|
@@ -220,26 +447,33 @@ npm run typecheck # tsc --noEmit
|
|
|
220
447
|
|
|
221
448
|
## Testing
|
|
222
449
|
|
|
223
|
-
|
|
450
|
+
Unit tests (offline, no credentials) cover the OAuth client, token handling, the Next.js handlers (with an in-memory cookie store), the session client, the request layer (token header, errors, no logging), the typed save/delete/read functions, the API route handlers (extras filtering, refresh-and-retry, body normalisation, 205 pass-through) and the hooks' query state:
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
npm test # = npm run test:unit
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Integration tests use [Vitest](https://vitest.dev) and run against **an app that mounts the SDK routes** (e.g. AdminUI on `npm run dev`), which forwards to the Shopwave API. They cover create → read (by id and by filter) → update → delete with the typed functions for Products and Categories, create → read → role update → retire for Employees, read-only checks for Consumers, plus unknown ids and the legacy `submitEntity` body. They create and delete real records on the account behind the token.
|
|
224
457
|
|
|
225
458
|
### Environment Variables
|
|
226
459
|
|
|
227
460
|
| Variable | Description | Example |
|
|
228
461
|
|-------------|--------------------------------------|-------------------------------------------|
|
|
229
|
-
| `API_URL` |
|
|
230
|
-
| `API_TOKEN` |
|
|
462
|
+
| `API_URL` | Origin of the app with the SDK routes | `http://localhost:3000` |
|
|
463
|
+
| `API_TOKEN` | Bare Shopwave access token (no `OAuth`/`Bearer` prefix) | `111ad…` |
|
|
231
464
|
|
|
232
465
|
### Running Tests
|
|
233
466
|
|
|
234
467
|
```bash
|
|
235
468
|
# Set environment variables
|
|
236
|
-
|
|
237
|
-
export
|
|
469
|
+
# with the app running (AdminUI: npm run dev)
|
|
470
|
+
export API_URL=http://localhost:3000
|
|
471
|
+
export API_TOKEN=<bare access token>
|
|
238
472
|
|
|
239
473
|
# Run all integration tests
|
|
240
474
|
npm run test:integration
|
|
241
475
|
|
|
242
|
-
# Run tests
|
|
476
|
+
# Run unit tests
|
|
243
477
|
npm test
|
|
244
478
|
```
|
|
245
479
|
|
|
@@ -247,6 +481,14 @@ npm test
|
|
|
247
481
|
|
|
248
482
|
```
|
|
249
483
|
tests/
|
|
484
|
+
unit/
|
|
485
|
+
server.test.ts # OAuth client, tokens, returnTo/state
|
|
486
|
+
next.test.ts # Next.js handlers, refresh, proxy guard
|
|
487
|
+
session-client.test.ts # fetchSession / loginPath / logoutPath
|
|
488
|
+
request.test.ts # token header, ShopwaveApiError, no logging
|
|
489
|
+
entities.test.ts # save/delete/fetch-by-id per entity
|
|
490
|
+
api-routes.test.ts # /api route handlers
|
|
491
|
+
query-state.test.ts # useQuery keeps data while refetching
|
|
250
492
|
integration/
|
|
251
493
|
setup.ts # Shared config and helpers
|
|
252
494
|
products.integration.test.ts # Products CRUD lifecycle
|