Skip to content

astashov/yatro

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

28 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Yet Another Typesafe Router - Yatro

It's a prototype of a typesafe router. Basically, it's a combination of several ideas:

  • Everything is typesafe as much as possible
  • Use Template Literal Types and recursive types we can extract parameters from express-like path syntax
  • Use io-ts for describing the expected types of path and querystring parameters
  • Allow to iteratively build endpoints

Usage

Basics

You can create typesafe endpoints like this:

import {Endpoint} from "yatro";

const postCommentsEndpoint = Endpoint.build("/posts/:postName/comments", {
  page: "number",
  perPage: "number",
});

That will create an endpoint with type: Endpoint<{postName: "string", page: "number", "perPage: "number"}>. Note that it even extracts postName from the path string - all thanks to the TS 4.1 Template Literal Types feature!

The endpoint has 2 main methods - match and toUrl. match allows to match a path with the endpoint, and it will return the extracted parameters if successful, or undefined if it doesn't match:

postCommentsEndpoint.match("/foo/bar");
// => undefined
postCommentsEndpoint.match("/posts/cool-post/comments?page=3&perPage=blah");
// => undefined, because perPage should be a number
postCommentsEndpoint.match("/posts/cool-post/comments?page=3&perPage=8&foo=bar");
// => {params: {postName: "cool-post", page: 3, perPage: 8}, rest: {foo: "bar"}}

We can also create string URLs from the endpoint param using toUrl method:

postCommentsEndpoint.toUrl({postName: "cool-post", page: 3, perPage: 8});
// => "/posts/cool-post/comments?page=3&perPage=8"
postCommentsEndpoint.toUrl({postName: 32, page: 3});
// Results in a type error because it expects `postName` to be a string, and also missing `perPage` param.

Now, we can add those Endpoints instances to a Router:

import {Endpoint, Router} from "yatro";

const postCommentsEndpoint = Endpoint.build("/posts/:postName/comments", {
  author: "string",
  page: "number",
  perPage: "number",
});
const addComment = Endpoint.build("/posts/:postName/comments");

const router = new Router({})
  .get(postCommentsEndpoint, (args) => {
    // args.match.params here will be of type {postName: string; page: number; perPage: number, author: string}
  })
  .post(addComment, () => {
    // handle post here
  });

To handle an incoming request in a router, you need to pass the HTTP method and the path to it:

await router.route("GET", "/posts/cool-post/comments?page=3&perPage=8&author=john");

That will delegate handling to the first route that matches this method and path.

There's also short syntax for adding routes, without explicit creating of endpoint instances:

// prettier-ignore
const router = new Router()
  .get("/posts/:postName/comments", {page: "number"}, (args) => {
    // args.match.params here will be of type {postName: string; page: number}
  });

You can also initialize Router with any object, and it will be passed into the routes (and will be typesafe too!)

const router = new Router({foo: "bar"}).get("/posts/:postName", {}, (args) => {
  console.log(args.payload);
  // {foo: "bar"}
});

You can also specify the desired request and response types when creating a router, then it will be enforced in route handlers. You could use it to pass e.g. the request object into the route handlers. All of that together could look like this:

interface IRequest {
  path: string;
  method: string;
  headers: Partial<Record<string, string>>
}

interface IResponse {
  body: string;
  statusCode: number;
}

const router = new Router<IRequest, IResponse>(request)
  .get("/posts/:postName", {}, (args) => {
    return { statusCode: 200, body: `Post ${args.match.params.postName}` };
  });

const result = await router.route(request.method, request.path);
if (result.success) {
  const response = result.data;
  // handle response somehow
} else
  // handle 404 somehow
}

Advanced

By default, if you specify a path param like: /posts/:postName/comments, it will be a type of string. If you want to make it a number, you could add |i to the param name, like: /posts/:id|i/comments. It will get {id: number} type.

const postCommentsEndpoint = Endpoint.build("/posts/:id|i/comments", {
  page: "number",
  perPage: "number",
});
// Endpoint<"postComments", {id: "number", page: "number", "perPage: "number"}>

We support two built-in types - "string" and "number", and if you want them to be optional, you can add ? to the end, like: "string?" and "number?". You also can express any type with io-ts type builder. For example, let's say we want to add categoryIds to our postComments endpoint, which is an array of numbers. It'll look like this:

import * as t from "io-ts";

const postCommentsEndpoint = Endpoint.build("/posts/:postName/comments", {
  page: "number?",
  perPage: "number?",
  categoryIds: t.array(t.number),
});

It will match a route like this:

postCommentsEndpoint.match("/posts/cool-post/comments?page=3&perPage=8&categoryIds=[1,2,3]");
// {params: {postName: "cool-post", page: 3, perPage: 8, categoryIds: [1, 2, 3]}, rest: {}}

and aldo will match a route without page or perPage since they are optional:

postCommentsEndpoint.match("/posts/cool-post/comments?categoryIds=[1,2,3]");
// {params: {postName: "cool-post", page: undefined, perPage: undefined, categoryIds: [1, 2, 3]}, rest: {}}

There's also another way of building endpoints - iteratively, by the .p() method:

const endpoint = new Endpoint()
  .p("posts")
  .p(":postName", "string"),
  .p("comments")
  .p({
    page: "number?",
    perPage: "number?",
    categoryIds: t.array(t.number),
  })
});

This way, you can also specify any io-ts type for the postName path param, not only "string" or "number".

Web server example

You could use it together with e.g. Node's http library like this:

import http from "http";
import {Endpoint, Router, Method, RouteHandler} from ".";

interface IRequest {
  req: http.IncomingMessage;
  res: http.ServerResponse;
}

const addCommentEndpoint = Endpoint.build("/posts/:postName/comment/create");
const handleAddComment: RouteHandler<IRequest, void, typeof addCommentEndpoint> = (args) => {
  // ---
  // Add comment to the database somehow here
  // ---
  const {req, res} = args.payload;
  const {params} = args.match;
  res.statusCode = 302;
  const location = postCommentsEndpoint.toUrl(
    {postName: params.postName, page: 1, perPage: 8},
    "http://localhost:3000"
  );
  res.setHeader("Location", location);
  res.end("");
};

const postCommentsEndpoint = Endpoint.build("/posts/:postName/comments", {
  page: "number",
  perPage: "number",
});
const handlePostComments: RouteHandler<IRequest, void, typeof postCommentsEndpoint> = (args) => {
  // ---
  // Fetch comments from the database somehow here
  // ---
  const {req, res} = args.payload;
  res.statusCode = 200;
  res.setHeader("Content-Type", "text/html");
  res.end("Comments");
};

const server = http.createServer(async (req, res) => {
  try {
    const url = new URL(req.url!, "http://localhost:3000");

    const router = new Router({req, res})
      .get(postCommentsEndpoint, handlePostComments)
      .get(addCommentEndpoint, handleAddComment);

    const result = await router.route(req.method as Method, url.pathname + url.search);
    if (!result.success) {
      res.statusCode = 404;
      res.end("Not Found");
    }
  } catch (e) {
    res.statusCode = 500;
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify({name: e.name, error: e.message, stack: e.stack}));
  }
});
server.listen(3000, "localhost", () => {
  console.log(`--------- Server is running ----------`);
});

About

Yet Another Typesafe Router

Resources

License

Stars

Watchers

Forks

Packages

No packages published