From 32ad13813bc267a04ea95d34a479efa5309b3051 Mon Sep 17 00:00:00 2001 From: Ranjith kumar Date: Tue, 18 Feb 2025 13:08:23 +0530 Subject: [PATCH] feat(js-sdk): implement custom storage config to support react native (#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: :white_check_mark: add unit tests for custom storage --- .changeset/late-comics-turn.md | 5 + packages/core/js-sdk/package.json | 2 +- .../core/js-sdk/src/__tests__/client.spec.ts | 112 +++++++++++++++++- packages/core/js-sdk/src/client.ts | 46 +++++-- packages/core/js-sdk/src/types.ts | 11 +- 5 files changed, 155 insertions(+), 21 deletions(-) create mode 100644 .changeset/late-comics-turn.md diff --git a/.changeset/late-comics-turn.md b/.changeset/late-comics-turn.md new file mode 100644 index 0000000000..e8ec2337af --- /dev/null +++ b/.changeset/late-comics-turn.md @@ -0,0 +1,5 @@ +--- +"@medusajs/js-sdk": minor +--- + +Implement custom storage configuration option in js-sdk to support react native diff --git a/packages/core/js-sdk/package.json b/packages/core/js-sdk/package.json index ee912777d1..bd9de7e81a 100644 --- a/packages/core/js-sdk/package.json +++ b/packages/core/js-sdk/package.json @@ -41,7 +41,7 @@ }, "scripts": { "build": "rimraf dist && tsc -p tsconfig.json && tsc -p tsconfig.esm.json", - "test": "jest --passWithNoTests --runInBand --bail --forceExit", + "test": "jest --passWithNoTests --runInBand --bail --forceExit --detectOpenHandles", "watch": "tsc --build --watch" } } diff --git a/packages/core/js-sdk/src/__tests__/client.spec.ts b/packages/core/js-sdk/src/__tests__/client.spec.ts index 5e35771678..3cc7ac66a3 100644 --- a/packages/core/js-sdk/src/__tests__/client.spec.ts +++ b/packages/core/js-sdk/src/__tests__/client.spec.ts @@ -4,6 +4,8 @@ import { setupServer } from "msw/node" import { Client, FetchError, PUBLISHABLE_KEY_HEADER } from "../client" const baseUrl = "https://someurl.com" +const token = "token-123" +const jwtTokenStorageKey = "medusa_auth_token" // This is just a network-layer mocking, it doesn't start an actual server const server = setupServer( @@ -77,7 +79,7 @@ const server = setupServer( return HttpResponse.json({ test: "test" }) }), http.get(`${baseUrl}/jwt`, ({ request }) => { - if (request.headers.get("authorization") === "Bearer token-123") { + if (request.headers.get("authorization") === `Bearer ${token}`) { return HttpResponse.json({ test: "test", }) @@ -259,9 +261,8 @@ describe("Client", () => { }) }) - describe("Authrized requests", () => { + describe("Authorized requests", () => { it("should not store the token by default", async () => { - const token = "token-123" // Eg. from a response after a successful authentication client.setToken(token) const resp = await client.fetch("nostore") @@ -274,13 +275,12 @@ describe("Client", () => { localStorage: { setItem: jest.fn(), getItem: () => token } as any, } as any - const token = "token-123" // Eg. from a response after a successful authentication client.setToken(token) const resp = await client.fetch("jwt") expect(resp).toEqual({ test: "test" }) expect(global.window.localStorage.setItem).toHaveBeenCalledWith( - "medusa_auth_token", + jwtTokenStorageKey, token ) @@ -289,5 +289,105 @@ describe("Client", () => { }) }) - + describe("Custom Storage", () => { + const mockSyncStorage = { + storage: new Map(), + getItem: jest.fn( + (key: string) => mockSyncStorage.storage.get(key) || null + ), + setItem: jest.fn((key: string, value: string) => + mockSyncStorage.storage.set(key, value) + ), + removeItem: jest.fn((key: string) => mockSyncStorage.storage.delete(key)), + } + + const mockAsyncStorage = { + storage: new Map(), + getItem: jest.fn( + async (key: string) => mockAsyncStorage.storage.get(key) || null + ), + setItem: jest.fn(async (key: string, value: string) => + mockAsyncStorage.storage.set(key, value) + ), + removeItem: jest.fn(async (key: string) => + mockAsyncStorage.storage.delete(key) + ), + } + + describe("Synchronous Custom Storage", () => { + let client: Client + + beforeEach(() => { + mockSyncStorage.storage.clear() + client = new Client({ + baseUrl, + auth: { + type: "jwt", + jwtTokenStorageMethod: "custom", + storage: mockSyncStorage, + }, + }) + }) + + it("should store and retrieve token", async () => { + await client.setToken(token) + expect(mockSyncStorage.setItem).toHaveBeenCalledWith( + jwtTokenStorageKey, + token + ) + const resp = await client.fetch("jwt") + expect(resp).toEqual({ test: "test" }) + expect(mockSyncStorage.getItem).toHaveBeenCalledWith(jwtTokenStorageKey) + }) + + it("should clear token", async () => { + await client.setToken(token) + await client.clearToken() + const resp = await client.fetch("nostore") + expect(resp).toEqual({ test: "test" }) + }) + }) + + describe("Asynchronous Custom Storage", () => { + let client: Client + + beforeEach(() => { + mockAsyncStorage.storage.clear() + jest.clearAllMocks() + client = new Client({ + baseUrl, + auth: { + type: "jwt", + jwtTokenStorageMethod: "custom", + storage: mockAsyncStorage, + }, + }) + }) + + it("should store and retrieve token asynchronously", async () => { + await client.setToken(token) + + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + jwtTokenStorageKey, + token + ) + + const resp = await client.fetch("jwt") + expect(resp).toEqual({ test: "test" }) + expect(mockAsyncStorage.getItem).toHaveBeenCalled() + }) + + it("should clear token asynchronously", async () => { + await client.setToken(token) + await client.clearToken() + + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + jwtTokenStorageKey + ) + + const resp = await client.fetch("nostore") + expect(resp).toEqual({ test: "test" }) + }) + }) + }) }) diff --git a/packages/core/js-sdk/src/client.ts b/packages/core/js-sdk/src/client.ts index 6b7d2c7b1c..d967cd2feb 100644 --- a/packages/core/js-sdk/src/client.ts +++ b/packages/core/js-sdk/src/client.ts @@ -175,15 +175,15 @@ export class Client { return { stream: null, abort: abortFunc } } - setToken(token: string) { - this.setToken_(token) + async setToken(token: string) { + await this.setToken_(token) } - clearToken() { - this.clearToken_() + async clearToken() { + await this.clearToken_() } - protected clearToken_() { + protected async clearToken_() { const { storageMethod, storageKey } = this.getTokenStorageInfo_() switch (storageMethod) { case "local": { @@ -194,6 +194,10 @@ export class Client { window.sessionStorage.removeItem(storageKey) break } + case "custom": { + await this.config.auth?.storage?.removeItem(storageKey) + break + } case "memory": { this.token = "" break @@ -219,7 +223,7 @@ export class Client { const headers = new Headers(defaultHeaders) const customHeaders = { ...this.config.globalHeaders, - ...this.getJwtHeader_(), + ...(await this.getJwtHeader_()), ...init?.headers, } // We use `headers.set` in order to ensure headers are overwritten in a case-insensitive manner. @@ -278,17 +282,17 @@ export class Client { : {} } - protected getJwtHeader_ = (): { Authorization: string } | {} => { + protected async getJwtHeader_(): Promise<{ Authorization: string } | {}> { // If the user has requested for session storage, we don't want to send the JWT token in the header. if (this.config.auth?.type === "session") { return {} } - const token = this.getToken_() + const token = await this.getToken_() return token ? { Authorization: `Bearer ${token}` } : {} } - protected setToken_ = (token: string) => { + protected async setToken_(token: string) { const { storageMethod, storageKey } = this.getTokenStorageInfo_() switch (storageMethod) { case "local": { @@ -299,6 +303,10 @@ export class Client { window.sessionStorage.setItem(storageKey, token) break } + case "custom": { + await this.config.auth?.storage?.setItem(storageKey, token) + break + } case "memory": { this.token = token break @@ -306,7 +314,7 @@ export class Client { } } - protected getToken_ = () => { + protected async getToken_() { const { storageMethod, storageKey } = this.getTokenStorageInfo_() switch (storageMethod) { case "local": { @@ -315,17 +323,21 @@ export class Client { case "session": { return window.sessionStorage.getItem(storageKey) } + case "custom": { + return await this.config.auth?.storage?.getItem(storageKey) + } case "memory": { return this.token } } - return + return null } protected getTokenStorageInfo_ = () => { const hasLocal = hasStorage("localStorage") const hasSession = hasStorage("sessionStorage") + const hasCustom = Boolean(this.config.auth?.storage) const storageMethod = this.config.auth?.jwtTokenStorageMethod || @@ -334,10 +346,13 @@ export class Client { this.config.auth?.jwtTokenStorageKey || this.DEFAULT_JWT_STORAGE_KEY if (!hasLocal && storageMethod === "local") { - throw new Error("Local JWT storage is only available in the browser") + this.throwError_("Local JWT storage is only available in the browser") } if (!hasSession && storageMethod === "session") { - throw new Error("Session JWT storage is only available in the browser") + this.throwError_("Session JWT storage is only available in the browser") + } + if (!hasCustom && storageMethod === "custom") { + this.throwError_("Custom storage was not provided in the config") } return { @@ -345,4 +360,9 @@ export class Client { storageKey, } } + + protected throwError_(message: string) { + this.logger.error(message) + throw new Error(message) + } } diff --git a/packages/core/js-sdk/src/types.ts b/packages/core/js-sdk/src/types.ts index c4b08291f5..6728570b6c 100644 --- a/packages/core/js-sdk/src/types.ts +++ b/packages/core/js-sdk/src/types.ts @@ -13,13 +13,22 @@ export type Config = { auth?: { type?: "jwt" | "session" jwtTokenStorageKey?: string - jwtTokenStorageMethod?: "local" | "session" | "memory" | "nostore" + jwtTokenStorageMethod?: "local" | "session" | "memory" | "custom" | "nostore" fetchCredentials?: "include" | "omit" | "same-origin" + storage?: CustomStorage } logger?: Logger debug?: boolean } +export type Awaitable = T | Promise + +export interface CustomStorage { + getItem(key: string): Awaitable + setItem(key: string, value: string): Awaitable + removeItem(key: string): Awaitable +} + export type FetchParams = Parameters export type ClientHeaders = Record<