💡 This is a personal project. It's heavily inspired by
kybut has a more functional approach, does a bit less "magic" with the request and has a few improvements to its hooks system and error handling.
Sofetch is a simple and elegant fetch client.
Key features:
- Adds nice syntax "sugar" over
fetch() - Throws errors on 4xx & 5xx HTTP responses
- Configurable default request properties
- Callbacks for hooking into requests globally
- Built with and for TypeScript
# NPM
npm install @danbahrami/sofetch
# Yarn
yarn add @danbahrami/sofetchimport { f } from "@danbahrami/sofetch";
// Make a GET request and type the response as a `User`
const user = await f.get("https://example.com/api/user").json<User>();
// Make a POST request, send some JSON and type the response as a `PasswordResetResult`
const result = await f
.post("https://example.com/api/password-reset", { json: { password: "monkey-123" } })
.json<PasswordResetResult>();SoFetch offers shortcuts for the following HTTP Methods:
f.get()sends aGETrequestf.put()sends aPUTrequestf.post()sends aPOSTrequestf.patch()sends aPATCHrequestf.delete()sends aDELETErequestf.options()sends anOPTIONSrequestf.head()sends anHEADrequest
await f.get("https://example.com/api/user");Additionally there is f.request() which you can pass a method to. If no method is given it will default to GET.
await f.request("https://example.com/api/user", { method: "delete" });You can send JSON with any request (as long as the method supports it) like this:
await f.post("/api/user", {
json: {
username: "admin",
password: "password",
},
});The data you pass will be converted to a string with JSON.stringify() and sent as the request body.
💡 When you send JSON like this the
content-typeheader will automatically be set toapplication/jsonunless you manually override it in the request options or config defaults.
sofetch provides shortcuts for converting the response body to different data types:
// convert JSON response body to a JS object
const user = await f.get("/api/user").json<User>();
// Other data types
const text = await f.get("/api/user").text();
const blob = await f.get("/api/user").blob();
const formData = await f.get("/api/user").formData();
const arrayBuffer = await f.get("/api/user").arrayBuffer();You can also do this on the response object like you would with fetch
// async/await example
const response = await f.get("/api/user");
const user = await response.json<User>();
// promise.then example
const user = await f.get("/api/user").then(response => response.json<User>());You can pass request properties such as body, headers, mode, redirect etc. to all client methods. The
await f.post("https://example.com/api/login", {
body: new FormData(loginForm),
headers: {
"X-CSRF": "123456",
},
mode: "no-cors",
});By default the f client exported from sofetch has very little configuration. You can configure the client with:
f.configure(options: SoFetchClientOptions);💡 We recommend only configuring the client once when you initialise your app. Calling
f.configure()will clear any pre-existing configuration.
You can create multiple clients with different configurations:
import { createClient } from "@danbahrami/sofetch";
export const apiClient = createClient(apiClientOptions);
export const httpClient = createClient(apiClientOptions);Using a base URL means you don't have to pass a full URL every time you make a request. Instead you can just pass a relative path.
f.configure({
baseUrl: "http://example.com/api",
});
const user = await f.get("/user").json<User>();💡 If you do pass a full URL when making a request that will take priority over the base URL.
Request defaults let you define standard request properties.
f.configure({
defaults: {
// Common defaults are used for all HTTP methods
common: {
cache: "no-cache",
},
// Per request defaults
get: {
headers: { "X-CSRF": "123456" },
},
delete: {
// timeout all delete requests after 5 seconds
signal: AbortSignal.timeout(5000),
},
},
});You can also pass factory functions if you want to inject unique values into every request. For example, this can be used to generate a unique request ID header for monitoring purposes.
f.configure({
defaults: {
common: () => ({
headers: {
traceId: sha1(),
},
}),
},
});Request properties will be applied in priority order, from highest to lowest priority:
- The options you pass when making the request
f.get(url, options) - The method specific defaults
- The common defaults
Request Headers will also be merged using the same priority order.
Callbacks let you hook into the request lifecycle globally. They can be useful for logging or adding some global handler e.g. showing a toast on error.
There are two ways to add callbacks:
- Pass a list of callbacks in client options
// Adding callbacks when configuring the client
const client = createClient({
callbacks: {
onRequestStart: [({ request }) => logRequestStart(request)],
onErrorResponse: [({ request, error }) => logErrorResponse(request, error)],
},
});- Add a callback to a pre-existing client
const unsubscribe = f.callbacks.onRequestStart(({ request }) => {
logRequestStart(request);
});
unsubscribe(); // remove the callbackCalled before the start of every request.
arg:
request: Request
Called after receiving a response with a 2xx HTTP status code.
arg:
request: Requestresponse: Response
Called after receiving a response with a 4xx or 5xx HTTP status code.
arg:
request: Requesterror: HttpError | NetworkError
Called when some error occurs on the client. Examples of rasons why this would be called include:
{JsonStringifyError}- Invalid JSON is passed to a request{JsonParseError}-.json()called on a non-JSON response{TypeError}- an invalid request URL was passed{Error}- an error thrown in a callback{Error}- some other internal error happened in the client
arg:
request: Requesterror: unknown
sofetch throws errors which represent a specific failure case:
An HttpError is thrown after receiving a response with a 4xx or 5xx HTTP status code. It contains details of the request and response.
import { HttpError } from "@danbahrami/sofetch";Properties:
error.requestThe full request instanceerror.responseThe full response instanceerror.statusCodeThe HTTP status code as an integer
A NetworkError is thrown when fetch throws an error. This usually indicates that the client and server could not successfully communicate.
import { NetworkError } from "@danbahrami/sofetch";Properties:
error.requestThe full request instanceerror.originalErrorThe error thrown byfetch
A JsonStringifyError is thrown if the object passed to the request json field cannot be serialized to a JSON string. More information can be found here.
import { JsonStringifyError } from "@danbahrami/sofetch";Properties:
error.requestThe full request instanceerror.dataThe object passed asjsonerror.originalErrorThe error thrown byJSON.stringify()
A JsonParseError is thrown when you try to parse a non-JSON response to JSON.
import { JsonParseError } from "@danbahrami/sofetch";
// if the request response is not JSON both
// of these will throw a JsonParseError
await f.get(url).json();
await f.get(url).then(response => response.json());Properties:
error.requestThe full request instanceerror.responseThe full response instanceerror.originalErrorThe error thrown byJSON.stringify()
We export the following TypeScript types:
The type of the f client or the result of createClient().
type SoFetchClient = {
request: (
input: RequestInfo | URL,
init?: RequestInit & { json?: unknown }
) => DecoratedResponsePromise;
get: (
input: RequestInfo | URL,
init?: Omit<RequestInit, "method"> & { json?: unknown }
) => DecoratedResponsePromise;
put: (
input: RequestInfo | URL,
init?: Omit<RequestInit, "method"> & { json?: unknown }
) => DecoratedResponsePromise;
post: (
input: RequestInfo | URL,
init?: Omit<RequestInit, "method"> & { json?: unknown }
) => DecoratedResponsePromise;
patch: (
input: RequestInfo | URL,
init?: Omit<RequestInit, "method"> & { json?: unknown }
) => DecoratedResponsePromise;
delete: (
input: RequestInfo | URL,
init?: Omit<RequestInit, "method"> & { json?: unknown }
) => DecoratedResponsePromise;
options: (
input: RequestInfo | URL,
init?: Omit<RequestInit, "method"> & { json?: unknown }
) => DecoratedResponsePromise;
head: (
input: RequestInfo | URL,
init?: Omit<RequestInit, "method"> & { json?: unknown }
) => DecoratedResponsePromise;
callbacks: {
onRequestStart: (cb: Callbacks["onRequestStart"]) => () => void;
onSuccessResponse: (cb: Callbacks["onSuccessResponse"]) => () => void;
onErrorResponse: (cb: Callbacks["onErrorResponse"]) => () => void;
onClientError: (cb: Callbacks["onClientError"]) => () => void;
};
configure: (options?: SoFetchClientOptions) => void;
};type SoFetchClientOptions = {
defaults?: {
get?: Omit<RequestInit, "method"> | (() => Omit<RequestInit, "method">);
put?: Omit<RequestInit, "method"> | (() => Omit<RequestInit, "method">);
post?: Omit<RequestInit, "method"> | (() => Omit<RequestInit, "method">);
patch?: Omit<RequestInit, "method"> | (() => Omit<RequestInit, "method">);
delete?: Omit<RequestInit, "method"> | (() => Omit<RequestInit, "method">);
options?: Omit<RequestInit, "method"> | (() => Omit<RequestInit, "method">);
head?: Omit<RequestInit, "method"> | (() => Omit<RequestInit, "method">);
common?: Omit<RequestInit, "method"> | (() => Omit<RequestInit, "method">);
};
callbacks?: {
onRequestStart?: Callbacks["onRequestStart"][];
onSuccessResponse?: Callbacks["onSuccessResponse"][];
onErrorResponse?: Callbacks["onErrorResponse"][];
onClientError?: Callbacks["onClientError"][];
};
baseUrl?: string;
};type Callbacks = {
onRequestStart: (details: { request: Request }) => Promise<void> | void;
onSuccessResponse: (details: { request: Request; response: Response }) => Promise<void> | void;
onErrorResponse: (details: {
request: Request;
error: HttpError | NetworkError;
}) => Promise<void> | void;
onClientError: (details: { error: unknown }) => Promise<void> | void;
};The return type when making a request. It's a Promise with the additional methods added for converting the response body into different data types.
type DecoratedResponsePromise = Promise<DecoratedResponse> & {
json: <T = unknown>() => Promise<T>;
text: () => Promise<string>;
blob: () => Promise<Blob>;
formData: () => Promise<FormData>;
arrayBuffer: () => Promise<ArrayBuffer>;
};This is how we type the response instance. The only thing it does is add the ability to pass a generic which represents the JSON response type.
type DecoratedResponse = Response & {
json: <T = unknown>() => Promise<T>;
};