endpoint-tools

An opinionated but customizable tool for designing, enforcing and communicating rest endpoints. Allowing teams to design API-first applications where the api shape is a library to be imported and consumed like everything else.

Usage no npm install needed!

<script type="module">
  import endpointTools from 'https://cdn.skypack.dev/endpoint-tools';
</script>

README

Endpoint Tools

An opinionated but customizable tool for designing, enforcing and communicating rest endpoints. Allowing teams to design API-first applications where the api shape is a library to be imported and consumed like everything else.

Features

  • Opinionated types for Get, Post, Head, Put, Patch and Delete
  • Meek types for Get, Post, Head, Put, Patch and Delete
  • Mixin types for common properties such as WithId, WithRequestId, WithErrors
  • Tools for parsing and checking endpoints

Concepts

This repo uses a type convention called hierarchical types to gather related types in a single object. A simple example of this concept would be:

type RestGetStates = {
  _request: {
    Params: any;
  };
  _response: {
    Success: any;
    Fail: any;
  };
};

Properties that start with underscore in RestGetStates are hierarchical steps used for grouping related types such as Success and Fail states of _response. While pascal case properties such as Params are types that are intended to be used inside the application.

Usage

Endpoint Tools comes with two variants of each Rest endpoint: Opinionated and 'Meek';

import { Get, GetMeek } from 'endpoint-tools';

Opinionated types impose a certain shape on response types. You can read more about the shape of the opinionated response types here. Meek types let the user define any kind of response shapes for Success and Fail states.

Defining your endpoints with opinionated tools

Assume that we are developing a site that pulls multiple posts of some category from the server using GET. We will demonstrate the use with the opinionated Get tool.

/**
 * An interface that defines all the endpoints related to handling posts
 */
interface CategoryEndpoints {
  _posts: {
    _v1: Get<
      '/category/:categorySlug/posts/v1', // endpoint url
      { categorySlug: string }, // url params
      { start: number; count: number }, // url query
      {
        // shape for the success body
        id: string; // uuid
        title: string;
        content: string;
      }
    >;
  };
}

What we did here is to define a hierarchical interface called CategoryEndpoints that contain all the endpoints related to handling posts. During consumption, we will access _posts/v1 endpoint using index access:

type GetCategoryPostsV1 = CategoryEndpoints['_posts']['_v1'];

Now, let's have a look at what the GetCategoryPostsV1 type hierarchy for our GET endpoint looks like:

{
  // Your endpoint input
  Endpoint: '/category/:categorySlug/posts/v1',
  Type: 'get',  // string literal
  _req: {
    // Your params input
    Params: {
      categorySlug: string,
    },
    // Your query input
    query: {
      start: number,
      count: number,
    }
  },
  _res: { // _silent_snake_case for hierarchy types
    Success: {
      state: 'success', // string literal
      requestId: string,
      // shape for the success response
      body: {
        id: string,
        title: string,
        content: string,
      }
    },
    Fail: { // PascalCase means that this is an independent type
      state: 'fail', // string literal
      requestId: string,
      errors: {
        general: string,
      }
    },
    Union: ... // union of Success and fail, omitting for readability
  }
}

You can see that the created type hierarchy contains params for the request, the Success and Fail types for the response, as well as a Union type that combines the two. For accessing the independent types, you can follow the indexes:

type SinglePost = GetCategoryPostsV1['_res']['Success']['body'];
type RequestParams = GetCategoryPostsV1['_req']['Params'];

Defining your endpoints with meek tools

Meek tools allow you to define custom shapes for your responses. We will design a similar endpoint to the example above.

interface CategoryEndpoints {
  _single: {
    _v1: GetMeek<
      '/category/:categorySlug/posts/v1', // endpoint url
      { categorySlug: string }, // url params
      { start: number; count: number }, // url query
      {
        // shape for the success response (not just body)
        id: string;
        title: string;
        content: string;
      },
      {
        // shape for the error response
        errorCode: string;
      }
    >;
  };
}

Meek tools take two arguments for the response: one for success and one for fail states. The type hierarchy created by the definition above is as follows:

{
  Endpoint: '/category/:categorySlug/posts/v1',
  Type: 'get',
  _req: {
    Params: {
      postSlug: string;
    },
    query: {
      start: number;
      count: number;
    }
  },
  _res: {
    Success: {
      // the success type we provided is used without any additional props
      id: string,
      title: string,
      content: string,
    },
    Fail: {
      // the fail type we provided is used without any additional props
      errorCode: string,
    },
    Union: ... // union of Success and Fail
  }
}

Meek types allow you to use this repo with rest endpoints of all kinds. Note that checking whether the response you got from the server is an error or a success may be inconsistent when you are not relying on a single property like state used in the opinionated variants.

Preparing your endpoints

You will probably have endpoints that require parsing params or a query. To do this safely, you can use the prepareEndpoint function. We will use fetch api to demonstrate the usage.

import { prepareEndpoint, isFail } from 'endpoint-tools';

fetch(
  prepareEndpoint<GetCategoryPostsV1>( // the generic has to be given
    '/category/:categorySlug/posts/v1',
    {
      categorySlug: 'banana-for-scale',
    },
    {
      start: 0,
      count: 20,
    }
  )
) // '/category/banana-for-scale/posts/v1?start=0&count=20'
  .then((response) => response.json())
  .then((data) => {
    // data will be of type GetCategoryPostsV1['_res']['Union']
    // checking for response fail state
    if (isFail(data)) {
      handleError(data);
    } else {
      // here, the type is GetCategoryPostsV1['_res']['Success']
      // do state management
    }

    return data; // GetCategoryPostsV1['_res']['Union']
  });

This will ensure that the endpoint being used matches the endpoint defined in GetCategoryPostsV1. It will also make sure that the endpoint receives the required categorySlug param and have its query parsed as expected.

As defined by our opinionated response, the state property can be used to check for fail states. isFail is some syntactic sugar to check whether data.state === 'fail' holds true while using opinionated tools.

It should be noted that some libraries such as Axios handle parsing of query string, in that case, you may choose to handle query parsing by the tools your preferred library. But using prepareEndpoint will ensure that your query props are checked with a little bit more ease.

Validating your endpoints

On the server-side, you can use validateEndpoint to check your endpoints.

import { validateEndpoint } from 'endpoint-tools';

// IIFE is not required but it enables speedy development as it
// creates a separate scope and allows the use of the same type names.
(() => {
  type Feature = GetCategoryPostsV1;
  type Params = Feature['_req']['Params'];
  // Response has to be Union, not Success
  type Response = Feature['_res']['Union'];
  type Endpoint = Feature['Endpoint'];

  router.get<Params, Response>(
    validateEndpoint<Feature>('/category/:categorySlug/posts/v1'),
    async ({ params: { requestId }, query }, res) => {
      // the rest of your code
    }
  );
})();