💡 This is a personal project. It's heavily inspired by
ky
but 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/sofetch
import { 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 aGET
requestf.put()
sends aPUT
requestf.post()
sends aPOST
requestf.patch()
sends aPATCH
requestf.delete()
sends aDELETE
requestf.options()
sends anOPTIONS
requestf.head()
sends anHEAD
request
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-type
header will automatically be set toapplication/json
unless 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 callback
Called before the start of every request.
arg:
request: Request
Called after receiving a response with a 2xx
HTTP status code.
arg:
request: Request
response: Response
Called after receiving a response with a 4xx
or 5xx
HTTP status code.
arg:
request: Request
error: 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: Request
error: 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.request
The full request instanceerror.response
The full response instanceerror.statusCode
The 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.request
The full request instanceerror.originalError
The 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.request
The full request instanceerror.data
The object passed asjson
error.originalError
The 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.request
The full request instanceerror.response
The full response instanceerror.originalError
The 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>;
};