🔄

50行でGo風味のDeno用のHTTPルーターを作る

2024/08/28に公開

はじめに

Goの標準ライブラリのnet/httpパッケージを見ていたら、パスにHTTPメソッドを指定できる機能が1.22から追加されていました。

mux := http.NewServeMux()
// GETを指定したハンドラの登録
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello World")
})

https://future-architect.github.io/articles/20240202a/#HTTPメソッドの指定が可能に

Denoでも同じようなことが出来ると思ったので実験的に50行で作ってみました。

実装

50行以内にコードを収めている関係で最低限の機能しか実装していないため、実用的なルーターとしては不十分なところがあると思います。下記のように使用することが出来ます。

const r = new Router();

r.handleFunc("GET /", function () {
  return new Response("Hello, Deno!");
});
r.handleFunc("GET /users/:name", function ({ params }) {
  return new Response(`Hello, ${params.name}`);
});
r.handleFunc("GET /search", function ({ query }) {
  return new Response(`Search query: ${query.q}`);
});

Deno.serve(r.serveHandler);
ソースコード
type Method = "GET" | "POST" | "PUT" | "DELETE";
type Pattern = `${Method} /${string}`;
type PathSegments<Path extends string> = Path extends
  `${infer SegmentA}/${infer SegmentB}`
  ? ParamOnly<SegmentA> | PathSegments<SegmentB>
  : ParamOnly<Path>;
type ParamOnly<Segment extends string> = Segment extends `:${infer Param}`
  ? Param
  : never;
type GetPath<Pattern extends string> = Pattern extends `${Method} ${infer Path}`
  ? Path
  : never;
type Context<P extends Pattern> = {
  request: Request;
  params: { [Key in PathSegments<GetPath<P>>]: string };
  query: { [key: string]: string | undefined };
};
type Handler<P extends Pattern> = (
  context: Context<P>,
) => Response | Promise<Response>;

export class Router {
  private routes: Array<{
    pattern: URLPattern;
    method: Method;
    // deno-lint-ignore no-explicit-any
    handler: any;
  }> = [];
  handleFunc<P extends Pattern>(pattern: P, handler: Handler<P>) {
    const method = pattern.substring(0, pattern.indexOf(' ')) as Method;
    const pathname = pattern.substring(pattern.indexOf(' ') + 1);
    this.routes.push({
      pattern: new URLPattern({ pathname }),
      method,
      handler,
    });
  }
  serveHandler: Deno.ServeHandler = (request) => {
    for (const route of this.routes) {
      if ((route.method === request.method)) {
        const result = route.pattern.exec(request.url);
        if (!result) continue;
        return route.handler({
          request,
          params: result.pathname.groups,
          query: Object.fromEntries(new URLSearchParams(result.search.input)),
        });
      }
    }
    return new Response("Not Found", { status: 404 });
  };
}

簡単な解説

例えばパスにGET /helloと指定すると、GETメソッドで/helloパスにアクセスされた際に呼ばれるハンドラを登録できます。HTTPメソッドとパスを指定する必要があるリテラル型を定義しています。

MethodとPatternの型定義
type Method = "GET" | "POST" | "PUT" | "DELETE"; // need more
type Pattern = `${Method} /${string}`;
r.handleFunc("GET /", function () {
  return new Response("Hello, Deno!");
});

handlerFuncメソッドで登録するハンドラは(context: Context) => Response | Promise<Response>という型で定義されており、contextにはリクエスト、パスパラメータ、クエリパラメータが含まれています。ハンドラ内でこれらの情報を使ってレスポンスを返すことができます。Contextは厳密にはパスをジェネリクスとして受け取るようにしており、パスパラメータについてはプロパティ名が補完されるようにしています。

HandlerとhandleFuncメソッドの定義
type Handler<P extends Pattern> = (
  context: Context<P>,
) => Response | Promise<Response>;

handleFunc<P extends Pattern>(pattern: P, handler: Handler<P>) {
  // パターンからHTTPメソッドとパスを取得
  const method = pattern.substring(0, pattern.indexOf(' ')) as Method;
  const pathname = pattern.substring(pattern.indexOf(' ') + 1);
  this.routes.push({
    pattern: new URLPattern({ pathname }),
    method,
    handler,
  });
}
// 例
r.handleFunc("GET /users/:name", function ({ params }) {
  // params.idだとエラーになる
  return new Response(`Hello, ${params.name}`);
});
r.handleFunc("GET /search", function ({ query }) {
  return new Response(`Search query: ${query.q}`);
});

パスのマッチングは自力で実装しようとすると、それだけで結構な量のコードが必要になりますが、Web APIの一つにURL Pattern APIという実験的な機能があり、これを使うことで簡単に実装が可能です。URL Pattern APIは、Expressでも使われているpath-to-regexpというライブラリをベースに作られています。

// マッチングの処理部分
for (const route of this.routes) {
  if ((route.method === req.method)) {
    const result = route.urlPattern.exec(req.url);
    if (!result) continue;
    return route.handler({...});
  }
}

おわりに

今回は50行でGo風のHTTPルーターをDenoで実装してみました。URL Pattern APIを使うことで、簡単にパスのマッチングを実装できます。パスパラメータの補完はジェネリクスの勉強にもなるので、興味がある方は一度自分で実装してみると良いかもしれません。

余談

先日Denoの標準ライブラリにもHTTPルーティング用の関数が追加されました。まだunstableなAPIなので、今後のアップデートに期待です。

https://jsr.io/@std/http/doc/~/route

参考文献

https://github.com/menduz/typed-url-params

Discussion