wrastle
v0.0.4
Published
> :warning: **This package is under development and probably going to change (the next version will be a complete rework).** > :warning: **Use at your own risk**
Downloads
8
Readme
:warning: This package is under development and probably going to change (the next version will be a complete rework).
:warning: Use at your own risk
Wrastle
Wrastle your REST.
Ok, that's a horrible tagline. But coming up with a name in 2023 is nigh impossible.
Ok let's try again:
Define your JSON API routes like HTTP requests, validate your data with Zod, and let TypeScript do the rest.
Marginally better. Let's go with that.
Usage
Create an api client by specifying your route definitions, along with validation for the data you expect to receive from that route For request bodies, you can specify a validation schema for the data that the route accepts.
import { z } from 'zod';
import { api } from 'wrastle';
const PostSchema = z.object({
id: z.number(),
userId: z.number(),
title: z.string(),
body: z.string(),
});
const client = api('https://jsonplaceholder.typicode.com/', {
'GET /posts': {
expects: { ok: z.array(PostSchema) },
},
'GET /posts/:id': {
expects: { ok: PostSchema },
},
'POST /posts': {
expects: { ok: z.any() },
accepts: PostSchema.omit({ id: true }),
},
'GET /search': {
expects: { ok: z.array(PostSchema) },
},
});
Typed route paths
With the above configuration, client
is now a type-checked HTTP client for the routes that have been defined, and has methods for each HTTP method defined in your route config. The available routes are type checked for each method.
const result = await client.get('/posts');
// Trying to access a route that isn't defined won't type-check:
// @ts-expect-error
await client.get('/users');
Dynamic route segments
Calling a route with dynamic segments requires you pass an options object with routeParams
defined, and those routeParams will be type-checked as well:
const post = await client.get('/posts/:id', {
routeParams: { id: 123 },
});
const post = await client.get('/posts/:id', {
// @ts-expect-error `title` isn't defined by the route as a route param
routeParams: { title: 'Where was Gondor when the Westfold fell?' },
});
Request bodies
For request bodies, you must pass a body
param in the options object. It is also type-checked and validated using the schema specified in accepts
.
const newPost = await client.post('/posts', {
body: {
userId: 123,
title: 'Second Breakfast',
body: "We've had one breakfast, yes. But what about Second Breakfast?",
},
});
const newPost = await client.post('/posts', {
// @ts-expect-error `title` is missing in the request body
body: {
userId: 123,
body: "We've had one breakfast, yes. But what about Second Breakfast?",
},
});
Querystrings / search params
Any route can optionally accept a searchParams
property. This accepts anything that the URLSearchParams constructor accepts. It will get converted to the request's querystring:
const results = await client.get('/search', {
searchParams: { s: 'breakfast' },
});
The final url would be https://jsonplaceholder.typicode.com/search?q=breakfast
API Results
Results are also type checked. They come back as a discriminated union based on the schemas provided in the expects
key for the route's config.
Here's a hypothetical api client for getting a user object:
const UserSchema = z.object({
id: z.number(),
username: z.string(),
});
const NotFoundSchema = z.object({
code: z.literal('not_found'),
message: z.string(),
});
const UnauthorizedSchema = z.object({
code: z.literal('unauthorized'),
message: z.string(),
});
type User = z.infer<typeof UserSchema>;
type NotFound = z.infer<typeof NotFoundSchema>;
type Unauthorized = z.infer<typeof Unauthorized>;
// API hosted on same domain:
enum HttpStatus {
Ok = 200,
NotFound = 404,
Unauthorized = 401,
}
const userClient = api(location.origin, {
'GET /api/user/:id': {
expects: {
// The `ok` key will be checked against the Response's `ok` property
ok: UserSchema,
// But you can also use specific HTTP response codes (must be constant)
[HttpStatus.NotFound]: NotFoundSchema,
[HttpStatus.Unauthorized]: UnauthorizedSchema,
},
},
});
When you have multiple status codes defined in your expects
, the return type is a union of all the expected return types, inferred from their Zod schemas, and paired with their status codes:
const result = await userClient.get('/api/user/:id', {
routeParams: { id: 123 },
});
The type of result
here is:
type ResultType =
| {
status: 'ok';
data: User;
}
| {
status: HttpStatus.NotFound; // 404
data: NotFound;
}
| {
status: HttpStatus.Unauthorized; // 401
data: Unauthorized;
};
This let's you narrow the type based on the status
property, and TS will give
you the proper type of data
:
if (result.status === 'ok') {
const user = result.data;
// ^? User
} else if ((result.status === 404) | (result.status === 401)) {
const code = result.data.code;
// ^? 'not_found' | 'unauthorized'
const error = result.data.message;
}
Client configuration options
Currently the only supported option is createRequest
, which is a hook that
gets called with the resolved URL. It is expected to return a Request
object,
or undefined
. If createRequest
is omitted, or it returns undefined
, then
the new Request(url)
is used.
This option can be used to set default options for all requests the client makes, if desired. This function can be async if necessary. See: https://developer.mozilla.org/en-US/docs/Web/API/Request/Request
const client = api(
location.origin,
{
// routes definition
},
{
async createRequest(url: URL) {
return new Request(url, {
mode: 'cors',
headers: {
Authorization: `Bearer ${await getToken()}`,
'Content-Type': 'application/json',
},
credentials: 'include',
});
},
}
);
When making a specific request, you can provide an onRequest
handler that will be passed the outgoing request. You can use this to return a new Request
instance that will be used instead. If you return undefined
, then the passed Request
will be used.
This lets you add additional headers to the outgoing request fairly easily:
const client = api(/* ... config ... */);
await client.get('/foo', {
onRequest(req: Request) {
req.headers.append('X-Version', '3');
},
});
You can also return a new Request
instance entirely, if you want to override headers or other properties:
const client = api(/* ... config ... */);
await client.get('/foo', {
onRequest(req: Request) {
return new Request(req, {
mode: 'no-cors',
credentials: 'include'
// remove default headers
headers: {}
});
},
});
Motivation
The more I work in software, the more I become sensitive to the concept of Cognitive Overhead:
Cognitive Overhead: How many logical connections or jumps your brain has to make in order to understand or contextualize the thing you’re looking at. — David Demaree
I find that most api clients that translate HTTP reqests into named methods are an unnecessary abstraction that adds cognitive overhead to the application.
For example, it's very common to see a HTTP request such as
GET /users/bob
turned into a method/function such as:
client.getUser('bob');
The basic assumption that software that follows this pattern makes is that abstracting the details of how to acquire a business object from an HTTP/REST api is a Good Thing™.
In most of my engineering career, I haven't found that to be the case.
Most abstractions leak. Almost invariably, when working with any web application, I need to know what the network request being made is. The more hoops I have to jump through to find that, the harder it is to maintain and/or extend the application.
However, making raw fetch()
requests all over the place doesn't scale either, there is still a use-case for global request logic and controlling the actual URLs/routes available to be called.
This library is an attempt to find a middle ground between those two extremes.
Yes, it's a bit repetitive and verbose, but the goal is to be more maintainable over the long run by keeping the HTTP bits front and center, rather than hiding them behind Yet Another Layer of Abstraction.
There is no problem in software that cannot be solved by another layer of abstraction... except for the problem of too many layers of abstraction.
— (Paraphrasing the Fundamental Theory of Software Engineering)