Last active
July 15, 2023 15:53
-
-
Save andresgutgon/6c03862a331b8cde373bb3834690d5b5 to your computer and use it in GitHub Desktop.
API wrapper over window.fetch
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { compact } from './utilities' | |
import { AnyObject, HttpMethod, ApiDataConfig, ApiError, ApiErrorBody } from './types' | |
type ContentTypeHeader = 'json' | 'urlencoded' | |
type AllowedHeaders = { | |
contentType?: ContentTypeHeader | |
} | |
type BaseConfig = { params?: AnyObject; headers?: AllowedHeaders } | |
type VerbArgs<T extends HttpMethod> = T extends 'GET' | |
? BaseConfig : T extends 'DELETE' ? BaseConfig | |
: BaseConfig & { data?: ApiDataConfig } | |
type RequestConfig<T extends HttpMethod> = { path: string } & VerbArgs<T> | |
type FetchConfig = { | |
method: HttpMethod; | |
path: string; | |
params?: AnyObject; | |
data?: ApiDataConfig | |
headers?: AllowedHeaders | |
} | |
type IClient = { | |
clientHostOrigin: string | |
apiHost: string | |
} | |
export class ApiClient { | |
private host: string | |
private mode: RequestMode = 'cors' | |
private credentials: RequestCredentials = 'include' | |
private clientHostOrigin: string | |
constructor({ clientHostOrigin, apiHost }: IClient) { | |
this.clientHostOrigin = clientHostOrigin | |
this.host = `${apiHost}/api` | |
} | |
async get<T extends AnyObject>({ path, params = {} }: RequestConfig<'GET'>) { | |
return this.fetch<T>({ method: 'GET', path, params }) | |
} | |
async post<T extends AnyObject>({ path, params, data, headers }: RequestConfig<'POST'>) { | |
return this.fetch<T>({ method: 'POST', path, params, data, headers }) | |
} | |
async put<T extends AnyObject>({ path, params, data }: RequestConfig<'PUT'>) { | |
return this.fetch<T>({ method: 'PUT', path, params, data }) | |
} | |
async rpc<T extends AnyObject>({ path, params, data }: RequestConfig<'POST'>) { | |
return this.fetch<T>({ method: 'POST', path, params, data }) | |
} | |
async remove<T extends AnyObject>({ path }: RequestConfig<'DELETE'>) { | |
return this.fetch<T>({ method: 'DELETE', path }) | |
} | |
private async fetch<T extends AnyObject>({ method, path, params = {}, headers = {}, data }: FetchConfig): Promise<T> { | |
const options = this.options(method, data, headers) | |
const response = await window.fetch( | |
this.url(path, params).href, | |
options | |
) | |
return this.response<T>(response) | |
} | |
private url(path: string, params: AnyObject = {}) { | |
const url = new URL(`${this.host}/${path}`) | |
const safeParams = compact(params) | |
Object.keys(safeParams).forEach((key) => { | |
const value = safeParams[key] | |
if (value) { | |
url.searchParams.append(key, value.toString()) | |
} | |
}) | |
return url | |
} | |
private options(method: HttpMethod, data: AnyObject | FormData = {}, headers: AllowedHeaders): RequestInit { | |
const options: RequestInit = { | |
method, | |
mode: this.mode, | |
credentials: this.credentials, | |
headers: this.headers(headers, data) | |
} | |
if (method === 'GET') return options | |
return { | |
...options, | |
body: this.body(data), | |
} | |
} | |
private async response<T extends AnyObject>(response: Response): Promise<T> { | |
if (!response.ok) { | |
let json: ApiErrorBody = { errors: [] } | |
if (response.status >= 500) { | |
json = { | |
errors: [{ title: 'API error', detail: response.statusText }], | |
} | |
} else { | |
json = await response.json() | |
} | |
throw new ApiError( | |
response.statusText, | |
response.status, | |
json | |
) | |
} | |
return response.json() | |
} | |
private body(data: ApiDataConfig) { | |
const isFormData = data instanceof FormData | |
const isUrlSearchParams = data instanceof URLSearchParams | |
const isJSON = !isFormData && !isUrlSearchParams | |
if (isJSON) return JSON.stringify(data) | |
if (isUrlSearchParams) return data | |
const isEmpty = data.entries().next().done | |
return isEmpty ? '' : data | |
} | |
private headers(allowedHeaders: AllowedHeaders, data?: FormData | AnyObject): HeadersInit { | |
const headers = new Headers() | |
headers.set('Origin', this.clientHostOrigin) | |
const contentType = this.buildContentType(allowedHeaders) | |
if (contentType) { | |
headers.set('Content-Type', contentType) | |
} | |
if (data instanceof FormData) return headers | |
headers.append('Accept', 'application/json') | |
return headers | |
} | |
private buildContentType(headers: AllowedHeaders) { | |
const type = headers.contentType | |
if (!type) return null | |
if (type === 'urlencoded') return 'application/x-www-form-urlencoded' | |
return 'application/json' | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export type AnyObject = Record<string, unknown> | |
type ApiErrorItem = { title: string; detail: string } | |
export type ApiErrorBody = { | |
errors: ApiErrorItem[] | |
} | |
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | |
export type ApiDataConfig = AnyObject | FormData | URLSearchParams | |
export class ApiError extends Error { | |
constructor( | |
message: string, | |
public status: number, | |
public json: ApiErrorBody | |
) { | |
super(message) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { isNil, isUndefined, omitBy } from 'lodash' | |
export function compact(obj: Record<string, unknown>) { | |
return omitBy(omitBy(obj, isNil), isUndefined) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment