feat: Create the Medusa API SDK as js-sdk package (#7276)
This commit is contained in:
@@ -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" })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Client } from "../client"
|
||||
|
||||
export class Admin {
|
||||
private client: Client
|
||||
constructor(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
}
|
||||
@@ -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}` } : {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user