Cryptocurrency API Gateway using Typescript+Workers


If you followed part one, I have an environment setup where I can write Typescript with tests and deploy to the Cloudflare Edge with npm run upload. For this post, I want to take one of the Worker Recipes further.

I’m going to build a mini HTTP request routing and handling framework, then use it to build a gateway to multiple cryptocurrency API providers. My point here is that in a single file, with no dependencies, you can quickly build pretty sophisticated logic and deploy fast and easily to the Edge. Furthermore, using modern Typescript with async/await and the rich type structure, you also write clean, async code.

OK, here we go…

My API will look like this:

VerbPathDescription
GET/api/pingCheck the Worker is up
GET/api/all/spot/:symbolAggregate the responses from all our configured gateways
GET/api/race/spot/:symbolReturn the response of the provider who responds fastest
GET/api/direct/:exchange/spot/:symbolPass through the request to the gateway. E.g. gdax or bitfinex

The Framework

OK, this is Typescript, I get interfaces and I’m going to use them. Here’s my ultra-mini-http-routing framework definition:

export interface IRouter {
  route(req: RequestContextBase): IRouteHandler;
}

/**
 * A route
 */
export interface IRoute {
  match(req: RequestContextBase): IRouteHandler | null;
}

/**
 * Handles a request.
 */
export interface IRouteHandler {
  handle(req: RequestContextBase): Promise<Response>;
}

/**
 * Request with additional convenience properties
 */
export class RequestContextBase {
  public static fromString(str: string) {
    return new RequestContextBase(new Request(str));
  }

  public url: URL;
  constructor(public request: Request) {
    this.url = new URL(request.url);
  }
}

So basically all requests will go to IRouter. If it finds an IRoute that returns an IRouterHandler, then it will call that and pass in RequestContextBase, which is just the request with a parsed URL for convenience.

I stopped short of dependency injection, so here’s the router implementation with 4 routes we’ve implemented (Ping, Race, All and Direct). Each route corresponds to one of the four operations I defined in the API above and returns the corresponding IRouteHandler.

export class Router implements IRouter {
  public routes: IRoute[];

  constructor() {
    this.routes = [
      new PingRoute(),
      new RaceRoute(),
      new AllRoute(),
      new DirectRoute(),
    ];
  }

  public async handle(request: Request): Promise<Response> {
    try {
      const req = new RequestContextBase(request);
      const handler = this.route(req);
      return handler.handle(req);
    } catch (e) {
      return new Response(undefined, {
        status: 500,
        statusText: `Error. ${e.message}`,
      });
    }
  }

  public route(req: RequestContextBase): IRouteHandler {
    const handler: IRouteHandler | null = this.match(req);
    if (handler) {
      logger.debug(`Found handler for ${req.url.pathname}`);
      return handler;
    }
    return new NotFoundHandler();
  }

  public match(req: RequestContextBase): IRouteHandler | null {
    for (const route of this.routes) {
      const handler = route.match(req);
      if (handler != null) {
        return handler;
      }
    }
    return null;
  }
}

You can see above I return a NotFoundHandler if we can’t find a matching route. Its implementation is below. It’s easy to see how 401, 405, 500 and all the common handlers could be implemented.

/**
 * 404 Not Found
 */
export class NotFoundHandler implements IRouteHandler {
  public async handle(req: RequestContextBase): Promise<Response> {
    return new Response(undefined, {
      status: 404,
      statusText: 'Unknown route',
    });
  }
}

Now let’s start with Ping. The framework separates matching a route and handling the request. Firstly the route:

export class PingRoute implements IRoute {
  public match(req: RequestContextBase): IRouteHandler | null {
    if (req.request.method !== 'GET') {
      return new MethodNotAllowedHandler();
    }
    if (req.url.pathname.startsWith('/api/ping')) {
      return new PingRouteHandler();
    }
    return null;
  }
}

Simple enough, if the URL starts with /api/ping, handle the request with a PingRouteHandler

export class PingRouteHandler implements IRouteHandler {
  public async handle(req: RequestContextBase): Promise<Response> {
    const pong = 'pong;';
    const res = new Response(pong);
    logger.info(`Responding with ${pong} and ${res.status}`);
    return new Response(pong);
  }
}

So at this point, if you followed along with Part 1, you can do:

$ npm run upload
$ curl https://cryptoserviceworker.com/api/ping
pong

OK, next the AllHandler, this aggregates the responses. Firstly the route matcher:

export class AllRoute implements IRoute {
  public match(req: RequestContextBase): IRouteHandler | null {
    if (req.url.pathname.startsWith('/api/all/')) {
      return new AllHandler();
    }
    return null;
  }
}

And if the route matches, we’ll handle it by farming off the requests to our downstream handlers:

export class AllHandler implements IRouteHandler {
  constructor(private readonly handlers: IRouteHandler[] = []) {
    if (handlers.length === 0) {
      const factory = new HandlerFactory();
      logger.debug('No handlers, getting from factory');
      this.handlers = factory.getProviderHandlers();
    }
  }

  public async handle(req: RequestContextBase): Promise<Response> {
    const responses = await Promise.all(
      this.handlers.map(async h => h.handle(req))
    );
    const jsonArr = await Promise.all(responses.map(async r => r.json()));
    return new Response(JSON.stringify(jsonArr));
  }
}

I’m cheating a bit here because I haven’t shown you the code for HandlerFactory or the implementation of handle for each one. You can look up the full source here.

Take a moment here to appreciate just what’s happening. You’re writing very expressive async code that in a few lines, is able to multiplex a request to multiple endpoints and aggregate the results. Furthermore, it’s running in a sandboxed environment in a data center very close to your end user. Edge-side code is a game changer.

Let’s see it in action.

$ curl https://cryptoserviceworker.com/api/all/spot/btc-usd
[  
   {  
      "symbol":"btc-usd",
      "price":"6609.06000000",
      "utcTime":"2018-06-20T05:26:19.512000Z",
      "provider":"gdax"
   },
   {  
      "symbol":"btc-usd",
      "price":"6600.7",
      "utcTime":"2018-06-20T05:26:22.284Z",
      "provider":"bitfinex"
   }
]

Cool, OK, who’s fastest? First, the route handler:

export class RaceRoute implements IRoute {
  public match(req: RequestContextBase): IRouteHandler | null {
    if (req.url.pathname.startsWith('/api/race/')) {
      return new RaceHandler();
    }
    return null;
  }
}

And the handler. Basically just using Promise.race to pick the winner

export class RaceHandler implements IRouteHandler {
  constructor(private readonly handlers: IRouteHandler[] = []) {
    const factory = new HandlerFactory();
    this.handlers = factory.getProviderHandlers();
  }

  public handle(req: RequestContextBase): Promise<Response> {
    return this.race(req, this.handlers);
  }

  public async race(
    req: RequestContextBase,
    responders: IRouteHandler[]
  ): Promise<Response> {
    const arr = responders.map(r => r.handle(req));
    return Promise.race(arr);
  }
}

So who’s fastest? Tonight it’s gdax.

curl https://cryptoserviceworker.com/api/race/spot/btc-usd
{  
   "symbol":"btc-usd",
   "price":"6607.15000000",
   "utcTime":"2018-06-20T05:33:16.074000Z",
   "provider":"gdax"
}

Summary

Using Typescript+Workers, in < 500 lines of code, we were able to

  • Define an interface for a mini HTTP routing and handling framework
  • Implement a basic implementation of that framework
  • Build Routes and Handlers to provide Ping, All, Race and Direct handlers
  • Deploy it to 160+ data centers with npm run upload

Stay tuned for more, and PRs welcome, particularly for more providers.


If you have a worker you’d like to share, or want to check out workers from other Cloudflare users, visit the “Recipe Exchange” in the Workers section of the Cloudflare Community Forum.



Source link