feat: Create the Medusa API SDK as js-sdk package (#7276)

This commit is contained in:
Stevche Radevski
2024-05-16 12:30:32 +02:00
committed by GitHub
parent e733d6b1ee
commit 845eda4643
13 changed files with 963 additions and 5 deletions
@@ -0,0 +1,145 @@
import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import { Client, FetchError } from "../client"
const baseUrl = "https://someurl.com"
// This is just a network-layer mocking, it doesn't start an actual server
const server = setupServer(
http.get(`${baseUrl}/test`, ({ request, params, cookies }) => {
return HttpResponse.json({
test: "test",
})
}),
http.get(`${baseUrl}/throw`, ({ request, params, cookies }) => {
return new HttpResponse(null, {
status: 500,
statusText: "Internal Server Error",
})
}),
http.get(`${baseUrl}/header`, ({ request, params, cookies }) => {
if (
request.headers.get("X-custom-header") === "test" &&
request.headers.get("Content-Type") === "application/json"
) {
return HttpResponse.json({
test: "test",
})
}
}),
http.get(`${baseUrl}/apikey`, ({ request, params, cookies }) => {
console.log(request.headers.get("authorization"))
if (request.headers.get("authorization")?.startsWith("Basic")) {
return HttpResponse.json({
test: "test",
})
}
}),
http.get(`${baseUrl}/pubkey`, ({ request, params, cookies }) => {
if (request.headers.get("x-medusa-pub-key") === "test-pub-key") {
return HttpResponse.json({
test: "test",
})
}
}),
http.post(`${baseUrl}/create`, async ({ request, params, cookies }) => {
return HttpResponse.json(await request.json())
}),
http.delete(`${baseUrl}/delete/123`, async ({ request, params, cookies }) => {
return HttpResponse.json({ test: "test" })
}),
http.all("*", ({ request, params, cookies }) => {
return new HttpResponse(null, {
status: 404,
statusText: "Not Found",
})
})
)
describe("Client", () => {
let client: Client
beforeAll(() => {
client = new Client({
baseUrl,
})
server.listen()
})
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe("header configuration", () => {
it("should allow passing custom request headers while the defaults are preserved", async () => {
const resp = await client.fetch<any>("header", {
headers: { "X-custom-header": "test" },
})
expect(resp).toEqual({ test: "test" })
})
it("should allow passing global headers", async () => {
const headClient = new Client({
baseUrl,
globalHeaders: {
"X-custom-header": "test",
},
})
const resp = await headClient.fetch<any>("header")
expect(resp).toEqual({ test: "test" })
})
it("should allow setting an API key", async () => {
const authClient = new Client({
baseUrl,
apiKey: "test-api-key",
})
const resp = await authClient.fetch<any>("apikey")
expect(resp).toEqual({ test: "test" })
})
it("should allow setting a publishable key", async () => {
const pubClient = new Client({
baseUrl,
publishableKey: "test-pub-key",
})
const resp = await pubClient.fetch<any>("pubkey")
expect(resp).toEqual({ test: "test" })
})
})
describe("GET requests", () => {
it("should fire a simple GET request and get back a JSON response by default", async () => {
const resp = await client.fetch<{ test: string }>("test")
expect(resp).toEqual({ test: "test" })
})
it("should throw an exception if a non-2xx status is received", async () => {
const err: FetchError = await client.fetch<any>("throw").catch((e) => e)
expect(err.status).toEqual(500)
expect(err.message).toEqual("Internal Server Error")
})
})
describe("POST requests", () => {
it("should fire a simple POST request and get back a JSON response", async () => {
const resp = await client.fetch<any>("create", {
body: { test: "test" },
method: "POST",
})
expect(resp).toEqual({ test: "test" })
})
})
describe("DELETE requests", () => {
it("should fire a simple DELETE request and get back a JSON response", async () => {
const resp = await client.fetch<any>("delete/123", {
method: "DELETE",
})
expect(resp).toEqual({ test: "test" })
})
})
})
+8
View File
@@ -0,0 +1,8 @@
import { Client } from "../client"
export class Admin {
private client: Client
constructor(client: Client) {
this.client = client
}
}
+183
View File
@@ -0,0 +1,183 @@
import qs from "qs"
import { ClientFetch, Config, FetchArgs, FetchInput, Logger } from "./types"
const isBrowser = () => typeof window !== "undefined"
const toBase64 = (str: string) => {
if (typeof window !== "undefined") {
return window.btoa(str)
}
return Buffer.from(str).toString("base64")
}
const sanitizeHeaders = (headers: Headers) => {
return {
...Object.fromEntries(headers.entries()),
Authorization: "<REDACTED>",
}
}
const normalizeRequest = (
init: FetchArgs | undefined,
headers: Headers
): RequestInit | undefined => {
let body = init?.body
if (body && headers.get("content-type")?.includes("application/json")) {
body = JSON.stringify(body)
}
return {
...init,
headers,
...(body ? { body: body as RequestInit["body"] } : {}),
} as RequestInit
}
const normalizeResponse = async (resp: Response, reqHeaders: Headers) => {
if (resp.status >= 300) {
const error = new FetchError(resp.statusText, resp.status)
throw error
}
// If we both requested JSON, we try to parse. Otherwise, we return the raw response.
const isJsonRequest = reqHeaders.get("accept")?.includes("application/json")
return isJsonRequest ? await resp.json() : resp
}
export class FetchError extends Error {
status: number | undefined
constructor(message: string, status?: number) {
super(message)
this.status = status
}
}
export class Client {
public fetch_: ClientFetch
private logger: Logger
private DEFAULT_JWT_STORAGE_KEY = "medusa_auth_token"
private token = ""
constructor(config: Config) {
const logger = config.logger || {
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
}
this.logger = {
...logger,
debug: config.debug ? logger.debug : () => {},
}
this.fetch_ = this.initClient(config)
}
// Since the response is dynamically determined, we cannot know if it is JSON or not. Therefore, it is important to pass `Response` as the return type
fetch<T extends any>(input: FetchInput, init?: FetchArgs): Promise<T> {
return this.fetch_(input, init) as unknown as Promise<T>
}
protected initClient(config: Config): ClientFetch {
const defaultHeaders = new Headers({
"content-type": "application/json",
accept: "application/json",
...this.getApiKeyHeader(config),
...this.getPublishableKeyHeader(config),
})
this.logger.debug(
"Initiating Medusa client with default headers:\n",
`${JSON.stringify(sanitizeHeaders(defaultHeaders), null, 2)}\n`
)
return (input: FetchInput, init?: FetchArgs) => {
// We always want to fetch the up-to-date JWT token before firing off a request.
const headers = new Headers(defaultHeaders)
const customHeaders = {
...config.globalHeaders,
...this.getJwtTokenHeader(config),
...init?.headers,
}
// We use `headers.set` in order to ensure headers are overwritten in a case-insensitive manner.
Object.entries(customHeaders).forEach(([key, value]) => {
headers.set(key, value)
})
let normalizedInput: RequestInfo | URL = input
if (input instanceof URL || typeof input === "string") {
normalizedInput = new URL(input, config.baseUrl)
if (init?.query) {
const existing = qs.parse(normalizedInput.search)
const stringifiedQuery = qs.stringify({ existing, ...init.query })
normalizedInput.search = stringifiedQuery
}
}
this.logger.debug(
"Performing request to:\n",
`URL: ${normalizedInput.toString()}\n`,
`Headers: ${JSON.stringify(sanitizeHeaders(headers), null, 2)}\n`
)
// Any non-request errors (eg. invalid JSON in the response) will be thrown as-is.
return fetch(normalizedInput, normalizeRequest(init, headers)).then(
(resp) => {
this.logger.debug(`Received response with status ${resp.status}\n`)
return normalizeResponse(resp, headers)
}
)
}
}
protected getApiKeyHeader = (
config: Config
): { Authorization: string } | {} => {
return config.apiKey
? { Authorization: "Basic " + toBase64(config.apiKey + ":") }
: {}
}
protected getPublishableKeyHeader = (
config: Config
): { "x-medusa-pub-key": string } | {} => {
return config.publishableKey
? { "x-medusa-pub-key": config.publishableKey }
: {}
}
protected getJwtTokenHeader = (
config: Config
): { Authorization: string } | {} => {
const storageMethod =
config.jwtToken?.storageMethod || (isBrowser() ? "local" : "memory")
const storageKey =
config.jwtToken?.storageKey || this.DEFAULT_JWT_STORAGE_KEY
switch (storageMethod) {
case "local": {
if (!isBrowser()) {
throw new Error("Local JWT storage is only available in the browser")
}
const token = window.localStorage.getItem(storageKey)
return token ? { Authorization: `Bearer ${token}` } : {}
}
case "session": {
if (!isBrowser()) {
throw new Error(
"Session JWT storage is only available in the browser"
)
}
const token = window.sessionStorage.getItem(storageKey)
return token ? { Authorization: `Bearer ${token}` } : {}
}
case "memory": {
return this.token ? { Authorization: `Bearer ${this.token}` } : {}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Admin } from "./admin"
import { Client } from "./client"
import { Store } from "./store"
import { Config } from "./types"
class Medusa {
public client: Client
public admin: Admin
public store: Store
constructor(config: Config) {
this.client = new Client(config)
this.admin = new Admin(this.client)
this.store = new Store(this.client)
}
}
export default Medusa
+250
View File
@@ -0,0 +1,250 @@
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class Store {
private client: Client
constructor(client: Client) {
this.client = client
}
public region = {
list: async (
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/regions`, {
query: queryParams,
headers,
})
},
retrieve: async (
id: string,
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/regions/${id}`, {
query: queryParams,
headers,
})
},
}
public collection = {
list: async (
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/collections`, {
query: queryParams,
headers,
})
},
retrieve: async (
id: string,
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/collections/${id}`, {
query: queryParams,
headers,
})
},
}
public category = {
list: async (
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/product-categories`, {
query: queryParams,
headers,
})
},
retrieve: async (
id: string,
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/product-categories/${id}`, {
query: queryParams,
headers,
})
},
}
public product = {
list: async (
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/products`, {
query: queryParams,
headers,
})
},
retrieve: async (
id: string,
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/products/${id}`, {
query: queryParams,
headers,
})
},
}
public order = {
retrieve: async (
id: string,
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/orders/${id}`, {
query: queryParams,
headers,
})
},
}
public cart = {
create: async (body: any, headers?: ClientHeaders) => {
return this.client.fetch<any>(`/store/carts`, {
headers,
method: "POST",
body,
})
},
update: async (id: string, body: any, headers?: ClientHeaders) => {
return this.client.fetch<any>(`/store/carts/${id}`, {
headers,
method: "POST",
body,
})
},
retrieve: async (
id: string,
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/carts/${id}`, {
query: queryParams,
headers,
})
},
createLineItem: async (
cartId: string,
body: any,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/carts/${cartId}/line-items`, {
headers,
method: "POST",
body,
})
},
updateLineItem: async (
cartId: string,
lineItemId: string,
body: any,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(
`/store/carts/${cartId}/line-items/${lineItemId}`,
{
headers,
method: "POST",
body,
}
)
},
deleteLineItem: async (
cartId: string,
lineItemId: string,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(
`/store/carts/${cartId}/line-items/${lineItemId}`,
{
headers,
method: "DELETE",
}
)
},
addShippingMethod: async (
cartId: string,
body: any,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/carts/${cartId}/shipping-methods`, {
headers,
method: "POST",
body,
})
},
complete: async (cartId: string, headers?: ClientHeaders) => {
return this.client.fetch<any>(`/store/carts/${cartId}/complete`, {
headers,
method: "POST",
})
},
}
public fulfillment = {
listCartOptions: async (
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/shipping-options`, {
query: queryParams,
headers,
})
},
}
public payment = {
listPaymentProviders: async (
queryParams?: Record<string, any>,
headers?: ClientHeaders
) => {
return this.client.fetch<any>(`/store/payment-providers`, {
query: queryParams,
headers,
})
},
initiatePaymentSession: async (
cart: any,
body: Record<string, any>,
headers?: ClientHeaders
) => {
let paymentCollectionId = (cart as any).payment_collection?.id
if (!paymentCollectionId) {
const collectionBody = {
cart_id: cart.id,
region_id: cart.region_id,
currency_code: cart.currency_code,
amount: cart.total,
}
paymentCollectionId = (
await this.client.fetch<any>(`/store/payment-collections`, {
headers,
method: "POST",
body: collectionBody,
})
).payment_collection.id
}
return this.client.fetch<any>(
`/store/payment-collections/${paymentCollectionId}/payment-sessions`,
{
headers,
method: "POST",
body,
}
)
},
}
}
+37
View File
@@ -0,0 +1,37 @@
export type Logger = {
error: (...messages: string[]) => void
warn: (...messages: string[]) => void
info: (...messages: string[]) => void
debug: (...messages: string[]) => void
}
export type Config = {
baseUrl: string
globalHeaders?: ClientHeaders
publishableKey?: string
apiKey?: string
jwtToken?: {
storageKey?: string
// TODO: Add support for cookie storage
storageMethod?: "local" | "session" | "memory"
}
logger?: Logger
debug?: boolean
}
export type FetchParams = Parameters<typeof fetch>
export type ClientHeaders = Record<string, string>
export type FetchInput = FetchParams[0]
export type FetchArgs = Omit<RequestInit, "headers" | "body"> & {
query?: Record<string, any>
headers?: ClientHeaders
body?: RequestInit["body"] | Record<string, any>
}
export type ClientFetch = (
input: FetchInput,
init?: FetchArgs
) => Promise<Response>