@gwakko/fetch-x
v1.1.1
Published
A sleek interface wrapped over fetch, offering both an intuitive syntax and robust response body validation for data assurance.
Downloads
10
Maintainers
Readme
Features
fetchx
is a small wrapper around fetch designed to simplify the way to perform network requests and handle responses.
- Intuitive - lean API, handles errors, headers and (de)serialization
- Immutable - every call creates a cloned instance that can then be reused safely
- Modular - intercept requests, responses
- Compatibility - crafted for any modern browser supporting the "Fetch API" or Node.js version 18 and newer
- Type safe - strongly typed, written in TypeScript
- Validation Scheme Integration - Seamlessly validate response bodies using the power of Zod, or with the implemented Schema type.
Table of Contents
Motivation
Because Interface or Type Alone Doesn't Guarantee Safety
In the vast realm of web development, simply providing a type or interface to a fetch operation is no silver bullet for ensuring type safety in responses. This creates an illusion of security, while hidden discrepancies might lurk beneath. That's where FetchX steps in. We recognize the crucial importance of true type safety, and we've architected our library to guarantee it. With FetchX, you're not just promised safety — it's delivered. Dive in, and experience the certainty of genuinely type-safe responses.
function api<T>(url: string): Promise<T> {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(response.statusText)
}
return response.json() as Promise<T>
})
}
FetchX Secures it with Robust Validation.
import { z } from 'zod';
import { UserSchema } from '@/user';
export const SignInSchema = z
.object({
username: z.string().nonempty(),
password: z.string().nonempty(),
})
.required();
export type SignInRequest = z.infer<typeof SignInSchema>;
export const SignInResponseSchema = z
.object({
accessToken: z.string().nonempty('Invalid Access Token'),
refreshToken: z.string().nonempty('Invalid Refresh Token'),
user: UserSchema,
})
.required();
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
const API_BASE_URL = 'http://localhost:8080';
const API_LOGIN_URL = '/api/auth/sign-in';
const signIn = async ({ email, password }): Promise<User | null> => {
try {
const response = await fetchx<SignInResponse, SignInRequest>(
API_BASE_URL,
)
.resolver(SignInResponseSchema)
.options({ cache: 'no-cache' })
.url(API_LOGIN_URL)
.post({
email,
password,
})
.validated();
return UserMapper.toAuthUser(response.user);
} catch (error) {
console.error(error);
return null;
}
};
Because manually checking and throwing every request error code is tedious.
Fetch won’t reject on HTTP error status.
fetch('dashboard')
.then(response => {
if (!response.ok) {
if (response.status === 404) throw new Error('ot found')
else if (response.status === 401) throw new Error('Unauthorized')
else if (response.status === 418) throw new Error("I'm a teapot !")
else throw new Error('Other error')
} else {// ... }
}
})
.then(data => {/* ... */
})
.catch(error => {/* ... */
})
FetchX throws when the response is not successful and contains helper methods to handle common HTTP status codes.
fetchx('dashboard')
.searchParams({ page: String(1) })
.notFound(error => {/* ... */
})
.unauthorized(error => {/* ... */
})
.onUnauthorized((self/*type of fetchx() instance*/) => {/* ... */
})
.error(HttpStatusCode.IM_A_TEAPOT, error => {/* ... */
})
.error(425, error => {/* ... */
})
.get()
.response(response => {/* ... */
})
.catch(error => {/* uncaught errors */
})
Because sending a json object should be easy.
With fetch you have to set the header, the method and the body manually.
fetch('http://api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hello: 'world' })
}).then(response => {/* handle */
})
With fetchx, you have shorthands at your disposal.
fetchx('http://api')
.url('/posts')
.post({ hello: 'world' })
.response(response => {/* handle */
})
fetchx('http://api')
.url('/posts')
.post(new FormData())
.then(response => {/* handle */
})
Because configuration should not rhyme with repetition.
A fetchx object is immutable which means that you can reuse previous instances safely.
// Cross origin authenticated requests on an external API
const apiInstance = fetchx('http://api.localhost') // Base url
// Authorization header
.authorization(`Bearer ${accessToken}`)
// Cors fetch options
.options({
credentials: "include",
mode: "cors"
}) // or .options((prevOpts) => ({ ...prevOpts, credentials: "include", mode: "cors" }))
// Handle Any Error
.fetchError((exception) => {/* handle */
});
// Fetch a resource
const resource = await apiInstance
// Add a custom header for this request
.headers({ 'If-Unmodified-Since': 'Wed, 21 Oct 2015 07:28:00 GMT' })
.url('/posts/1')
.get()
.json((jsonData) => {/* handle */
});
// Post a resource
apiInstance
.url('/resource')
.post({ title: 'new title' })
.json((jsonData) => {/* handle */
});
Installation
Package Manager
npm i @gwakko/fetch-x # or yarn/pnpm add @gwakko/fetch-x
Usage
Import
// ECMAScript modules
import fetchx from '@gwakko/fetch-x';
import { fetchx } from '@gwakko/fetch-x';
Minimal Example
import { z } from 'zod';
import { fetchx } from '@gwakko/fetch-x';
// Instantiate and configure fetchx
const api = fetchx('https://jsonplaceholder.typicode.com', { mode: 'cors' });
// or options with callback
// const api = fetchx('https://jsonplaceholder.typicode.com', (prevOptions) => ({ ...prevOptions, mode: 'cors' }));
const TodoSchema = z.object({
userId: z.number(),
id: z.number(),
title: z.string(),
completed: z.boolean()
});
const TodosSchema = z.array(TodoSchema);
const GeoSchema = z.object({
lat: z.string(),
lng: z.string()
});
const AddressSchema = z.object({
street: z.string(),
suite: z.string(),
city: z.string(),
zipcode: z.string(),
geo: GeoSchema
});
const CompanySchema = z.object({
name: z.string(),
catchPhrase: z.string(),
bs: z.string()
});
const UserSchema = z.object({
id: z.number(),
name: z.string(),
username: z.string(),
email: z.string(),
address: AddressSchema,
phone: z.string(),
website: z.string(),
company: CompanySchema
});
const UsersArraySchema = z.array(UserSchema);
try {
// Fetch todos
const todos = await api.resolver(TodosSchema).url('/todos').get().validated();
// const todos = await api.url('/todos').get().json(); // without validation
const todo = todos.at(0);
const todoUser = await api.resolver(UserSchema).url(`/users?id=${todo.userId}`).get().validated();
// Create a new todo
const newTodo = await api.url('/todos').post({
userId: todoUser.id,
title: "New Todo",
completed: false
});
// Patch it
await api.url(`/todos/${newTodo.id}`).patch({
completed: true
});
// Fetch it
const todo2 = await api.resolver(TodosSchema).url(`/todos/${newTodo.id}`).get().validated();
} catch (error) {
// The API could return an empty object - in which case the status text is logged instead.
const message =
typeof error.message === "object" && Object.keys(error.message).length > 0
? JSON.stringify(error.message)
: error.response.statusText
console.error(`${error.status}: ${message}`)
}
API
FetchX defaults
These methods are available from the main default export and can be used to instantiate fetchx and configure it globally.
import { fetchx } from '@gwakko/fetch-x';
fetchx.defaults.baseUrl = 'http://base.api';
fetchx.defaults.interceptors.request.push((request) => {
});
fetchx.defaults.interceptors.response.push((response) => {
})
fetchx.defaults.headers = {
'Some-header': 'test',
};
fetchx.defaults.requestOptions = {
cache: 'no-cache',
credentials: 'include',
};
fetchx.defaults.catches.set(404, (exception) => {
});
Helper Methods
Helper Methods are used to configure the request and program actions.
fetchx()
.url('/posts/1')
.headers({ 'Cache-Control': 'no-cache' })
// or
.headers((headers) => ({ ...headers, 'New-header': 'hello' }))
.contentType('text/html')
HTTP Methods
Sets the HTTP method and sends the request.
Calling an HTTP method ends the request chain and returns a response chain. You can pass optional body arguments to these methods.
fetchx().url('/url').get();
fetchx().url('/url').post({ propertyName: 'body' });
NOTE: The Content-Type header will be automatically set based on the datatype of the body in a fetch request:
Object/JSON:
DataType: Object
Header: application/json
FormData:
DataType: FormData
Header: multipart/form-data
Text:
DataType: string
Header: text/plain
Ensure that your server is correctly configured to handle the respective Content-Type headers for the data you're sending. Adjust fetch headers manually if a different Content-Type is required for your specific use case.
Catchers
Catchers are optional, but if none are provided an error will still be thrown for http error codes and it will be up to you to catch it.
import { fetchx } from '@gwakko/fetch-x';
fetchx('...')
.badRequest((err) => console.log(err.status))
.unauthorized((err) => console.log(err.status))
.forbidden((err) => console.log(err.status))
.notFound((err) => console.log(err.status))
.timeout((err) => console.log(err.status))
.internalError((err) => console.log(err.status))
.error(418, (err) => console.log(err.status))
.fetchError((err) => console.log(err))
.get()
.response();
The error passed to catchers is enhanced with additional properties.
interface ApiResponseException {
status: number;
response: Response;
url: string;
text?: string;
json?: unknown;
}
Response Types
Setting the final response body type ends the chain and returns a regular promise.
All these methods accept an optional callback, and will return a Promise resolved with either the return value of the provided callback or the expected type.
// Without a callback
fetchx('...').get().json().then(json => /* json is the parsed json of the response body */)
// Without a callback using await
const json = await fetchx("...").get().json()
// With a callback the value returned is passed to the Promise
fetchx('...').get().json(json => "Hello world!").then(console.log) // => Hello world!
If an error is caught by catchers, the response type handler will not be called.
QueryString
Used to construct and append the query string part of the URL from an object.
fetchx('http://example.com').searchParams({ page: String(1), hello: 'world' }); // url is now http://example.com?page=1&hello=world
fetchx('http://example.com?some=prop').searchParams({ page: String(1), hello: 'world' }); // url is now http://example.com?some=prop&page=1&hello=world
fetchx('http://example.com?some=prop').searchParams({ page: String(1), hello: 'world' }, true); // url is now http://example.com?page=1&hello=world
Abort
const abortController = new AbortController();
fetchx('...')
.abortController(abortController)
.onAbort((_) => console.log('Aborted!'))
.get()
.text((_) => console.log('should never be called'));
abortController.abort();
TODO
- Add Progress Upload & Download
License
MIT