Skip to content

Commit 32ad138

Browse files
feat(js-sdk): implement custom storage config to support react native (medusajs#11467)
* feat(js-sdk): implement custom storage config to support react native * chore: add changeset * feat(js-sdk): implement custom storage config to support react native * chore: add changeset * test: ✅ add unit tests for custom storage
1 parent ee848bf commit 32ad138

File tree

5 files changed

+155
-21
lines changed

5 files changed

+155
-21
lines changed

.changeset/late-comics-turn.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@medusajs/js-sdk": minor
3+
---
4+
5+
Implement custom storage configuration option in js-sdk to support react native

packages/core/js-sdk/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
},
4242
"scripts": {
4343
"build": "rimraf dist && tsc -p tsconfig.json && tsc -p tsconfig.esm.json",
44-
"test": "jest --passWithNoTests --runInBand --bail --forceExit",
44+
"test": "jest --passWithNoTests --runInBand --bail --forceExit --detectOpenHandles",
4545
"watch": "tsc --build --watch"
4646
}
4747
}

packages/core/js-sdk/src/__tests__/client.spec.ts

+106-6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { setupServer } from "msw/node"
44
import { Client, FetchError, PUBLISHABLE_KEY_HEADER } from "../client"
55

66
const baseUrl = "https://someurl.com"
7+
const token = "token-123"
8+
const jwtTokenStorageKey = "medusa_auth_token"
79

810
// This is just a network-layer mocking, it doesn't start an actual server
911
const server = setupServer(
@@ -77,7 +79,7 @@ const server = setupServer(
7779
return HttpResponse.json({ test: "test" })
7880
}),
7981
http.get(`${baseUrl}/jwt`, ({ request }) => {
80-
if (request.headers.get("authorization") === "Bearer token-123") {
82+
if (request.headers.get("authorization") === `Bearer ${token}`) {
8183
return HttpResponse.json({
8284
test: "test",
8385
})
@@ -259,9 +261,8 @@ describe("Client", () => {
259261
})
260262
})
261263

262-
describe("Authrized requests", () => {
264+
describe("Authorized requests", () => {
263265
it("should not store the token by default", async () => {
264-
const token = "token-123" // Eg. from a response after a successful authentication
265266
client.setToken(token)
266267

267268
const resp = await client.fetch<any>("nostore")
@@ -274,13 +275,12 @@ describe("Client", () => {
274275
localStorage: { setItem: jest.fn(), getItem: () => token } as any,
275276
} as any
276277

277-
const token = "token-123" // Eg. from a response after a successful authentication
278278
client.setToken(token)
279279

280280
const resp = await client.fetch<any>("jwt")
281281
expect(resp).toEqual({ test: "test" })
282282
expect(global.window.localStorage.setItem).toHaveBeenCalledWith(
283-
"medusa_auth_token",
283+
jwtTokenStorageKey,
284284
token
285285
)
286286

@@ -289,5 +289,105 @@ describe("Client", () => {
289289
})
290290
})
291291

292-
292+
describe("Custom Storage", () => {
293+
const mockSyncStorage = {
294+
storage: new Map<string, string>(),
295+
getItem: jest.fn(
296+
(key: string) => mockSyncStorage.storage.get(key) || null
297+
),
298+
setItem: jest.fn((key: string, value: string) =>
299+
mockSyncStorage.storage.set(key, value)
300+
),
301+
removeItem: jest.fn((key: string) => mockSyncStorage.storage.delete(key)),
302+
}
303+
304+
const mockAsyncStorage = {
305+
storage: new Map<string, string>(),
306+
getItem: jest.fn(
307+
async (key: string) => mockAsyncStorage.storage.get(key) || null
308+
),
309+
setItem: jest.fn(async (key: string, value: string) =>
310+
mockAsyncStorage.storage.set(key, value)
311+
),
312+
removeItem: jest.fn(async (key: string) =>
313+
mockAsyncStorage.storage.delete(key)
314+
),
315+
}
316+
317+
describe("Synchronous Custom Storage", () => {
318+
let client: Client
319+
320+
beforeEach(() => {
321+
mockSyncStorage.storage.clear()
322+
client = new Client({
323+
baseUrl,
324+
auth: {
325+
type: "jwt",
326+
jwtTokenStorageMethod: "custom",
327+
storage: mockSyncStorage,
328+
},
329+
})
330+
})
331+
332+
it("should store and retrieve token", async () => {
333+
await client.setToken(token)
334+
expect(mockSyncStorage.setItem).toHaveBeenCalledWith(
335+
jwtTokenStorageKey,
336+
token
337+
)
338+
const resp = await client.fetch<any>("jwt")
339+
expect(resp).toEqual({ test: "test" })
340+
expect(mockSyncStorage.getItem).toHaveBeenCalledWith(jwtTokenStorageKey)
341+
})
342+
343+
it("should clear token", async () => {
344+
await client.setToken(token)
345+
await client.clearToken()
346+
const resp = await client.fetch<any>("nostore")
347+
expect(resp).toEqual({ test: "test" })
348+
})
349+
})
350+
351+
describe("Asynchronous Custom Storage", () => {
352+
let client: Client
353+
354+
beforeEach(() => {
355+
mockAsyncStorage.storage.clear()
356+
jest.clearAllMocks()
357+
client = new Client({
358+
baseUrl,
359+
auth: {
360+
type: "jwt",
361+
jwtTokenStorageMethod: "custom",
362+
storage: mockAsyncStorage,
363+
},
364+
})
365+
})
366+
367+
it("should store and retrieve token asynchronously", async () => {
368+
await client.setToken(token)
369+
370+
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
371+
jwtTokenStorageKey,
372+
token
373+
)
374+
375+
const resp = await client.fetch<any>("jwt")
376+
expect(resp).toEqual({ test: "test" })
377+
expect(mockAsyncStorage.getItem).toHaveBeenCalled()
378+
})
379+
380+
it("should clear token asynchronously", async () => {
381+
await client.setToken(token)
382+
await client.clearToken()
383+
384+
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(
385+
jwtTokenStorageKey
386+
)
387+
388+
const resp = await client.fetch<any>("nostore")
389+
expect(resp).toEqual({ test: "test" })
390+
})
391+
})
392+
})
293393
})

packages/core/js-sdk/src/client.ts

+33-13
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,15 @@ export class Client {
175175
return { stream: null, abort: abortFunc }
176176
}
177177

178-
setToken(token: string) {
179-
this.setToken_(token)
178+
async setToken(token: string) {
179+
await this.setToken_(token)
180180
}
181181

182-
clearToken() {
183-
this.clearToken_()
182+
async clearToken() {
183+
await this.clearToken_()
184184
}
185185

186-
protected clearToken_() {
186+
protected async clearToken_() {
187187
const { storageMethod, storageKey } = this.getTokenStorageInfo_()
188188
switch (storageMethod) {
189189
case "local": {
@@ -194,6 +194,10 @@ export class Client {
194194
window.sessionStorage.removeItem(storageKey)
195195
break
196196
}
197+
case "custom": {
198+
await this.config.auth?.storage?.removeItem(storageKey)
199+
break
200+
}
197201
case "memory": {
198202
this.token = ""
199203
break
@@ -219,7 +223,7 @@ export class Client {
219223
const headers = new Headers(defaultHeaders)
220224
const customHeaders = {
221225
...this.config.globalHeaders,
222-
...this.getJwtHeader_(),
226+
...(await this.getJwtHeader_()),
223227
...init?.headers,
224228
}
225229
// We use `headers.set` in order to ensure headers are overwritten in a case-insensitive manner.
@@ -278,17 +282,17 @@ export class Client {
278282
: {}
279283
}
280284

281-
protected getJwtHeader_ = (): { Authorization: string } | {} => {
285+
protected async getJwtHeader_(): Promise<{ Authorization: string } | {}> {
282286
// If the user has requested for session storage, we don't want to send the JWT token in the header.
283287
if (this.config.auth?.type === "session") {
284288
return {}
285289
}
286290

287-
const token = this.getToken_()
291+
const token = await this.getToken_()
288292
return token ? { Authorization: `Bearer ${token}` } : {}
289293
}
290294

291-
protected setToken_ = (token: string) => {
295+
protected async setToken_(token: string) {
292296
const { storageMethod, storageKey } = this.getTokenStorageInfo_()
293297
switch (storageMethod) {
294298
case "local": {
@@ -299,14 +303,18 @@ export class Client {
299303
window.sessionStorage.setItem(storageKey, token)
300304
break
301305
}
306+
case "custom": {
307+
await this.config.auth?.storage?.setItem(storageKey, token)
308+
break
309+
}
302310
case "memory": {
303311
this.token = token
304312
break
305313
}
306314
}
307315
}
308316

309-
protected getToken_ = () => {
317+
protected async getToken_() {
310318
const { storageMethod, storageKey } = this.getTokenStorageInfo_()
311319
switch (storageMethod) {
312320
case "local": {
@@ -315,17 +323,21 @@ export class Client {
315323
case "session": {
316324
return window.sessionStorage.getItem(storageKey)
317325
}
326+
case "custom": {
327+
return await this.config.auth?.storage?.getItem(storageKey)
328+
}
318329
case "memory": {
319330
return this.token
320331
}
321332
}
322333

323-
return
334+
return null
324335
}
325336

326337
protected getTokenStorageInfo_ = () => {
327338
const hasLocal = hasStorage("localStorage")
328339
const hasSession = hasStorage("sessionStorage")
340+
const hasCustom = Boolean(this.config.auth?.storage)
329341

330342
const storageMethod =
331343
this.config.auth?.jwtTokenStorageMethod ||
@@ -334,15 +346,23 @@ export class Client {
334346
this.config.auth?.jwtTokenStorageKey || this.DEFAULT_JWT_STORAGE_KEY
335347

336348
if (!hasLocal && storageMethod === "local") {
337-
throw new Error("Local JWT storage is only available in the browser")
349+
this.throwError_("Local JWT storage is only available in the browser")
338350
}
339351
if (!hasSession && storageMethod === "session") {
340-
throw new Error("Session JWT storage is only available in the browser")
352+
this.throwError_("Session JWT storage is only available in the browser")
353+
}
354+
if (!hasCustom && storageMethod === "custom") {
355+
this.throwError_("Custom storage was not provided in the config")
341356
}
342357

343358
return {
344359
storageMethod,
345360
storageKey,
346361
}
347362
}
363+
364+
protected throwError_(message: string) {
365+
this.logger.error(message)
366+
throw new Error(message)
367+
}
348368
}

packages/core/js-sdk/src/types.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,22 @@ export type Config = {
1313
auth?: {
1414
type?: "jwt" | "session"
1515
jwtTokenStorageKey?: string
16-
jwtTokenStorageMethod?: "local" | "session" | "memory" | "nostore"
16+
jwtTokenStorageMethod?: "local" | "session" | "memory" | "custom" | "nostore"
1717
fetchCredentials?: "include" | "omit" | "same-origin"
18+
storage?: CustomStorage
1819
}
1920
logger?: Logger
2021
debug?: boolean
2122
}
2223

24+
export type Awaitable<T> = T | Promise<T>
25+
26+
export interface CustomStorage {
27+
getItem(key: string): Awaitable<string | null>
28+
setItem(key: string, value: string): Awaitable<void>
29+
removeItem(key: string): Awaitable<void>
30+
}
31+
2332
export type FetchParams = Parameters<typeof fetch>
2433

2534
export type ClientHeaders = Record<

0 commit comments

Comments
 (0)