0.0.4 • Published 1 year ago

wrastle v0.0.4

Weekly downloads
-
License
MIT
Repository
-
Last release
1 year ago

: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)

0.0.4

1 year ago

0.0.3

1 year ago

0.0.2

1 year ago

0.0.1

1 year ago