0.1.3Updated 6 months ago
import type { InfinityContext } from "@infinity-beyond/infinity.ts";

type ExtractParams<T extends string> = 
  T extends `${string}:${infer Param}/${infer Rest}` 
    ? Param | ExtractParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type PathParams<Path extends string, Params = {[K in ExtractParams<Path>]: K extends string ? string : never}> = Params

type WithParams = <T extends string, K extends Record<string, string> = PathParams<T>>(path: T, handler: RouteHandler<K>) => void

export class REST_Wrapper {
  readonly routes: Map<RegExp, Route> = new Map<RegExp, Route>()

  protected get     = this.add_handler.bind(this, 'GET') as WithParams;
  protected post    = this.add_handler.bind(this, 'POST') as WithParams;
  protected put     = this.add_handler.bind(this, 'PUT') as WithParams;
  protected patch   = this.add_handler.bind(this, 'PATCH') as WithParams;
  protected delete  = this.add_handler.bind(this, 'DELETE') as WithParams;
  protected head    = this.add_handler.bind(this, 'HEAD') as WithParams;
  protected options = this.add_handler.bind(this, 'OPTIONS') as WithParams;
  protected any     = this.add_handler.bind(this, 'ANY') as WithParams;

  private add_handler(method: REST_METHOD, path: string, handler: RouteHandler) {
    const param_variables: string[] = [];
    const regex = new RegExp('^' + path.replace(/\:(\w+)/g, (_, b) => {
      param_variables.push(b);
      return `(\\w+)`;
    }) + '$');

    this.routes.set(regex, {
      method,
      path,
      param_variables,
      handler,
    });
  }

  async handle(pathname: string, request: Request, ctx: InfinityContext) {
    try {
      const no_query_pathname = pathname.replace(/\?.+$/, '') || '/';

      const valid_routes = this.routes.entries().toArray().filter(([ regex, route ]) => (
        route.method == 'ANY' || route.method == request.method
      ) && regex.test(no_query_pathname));

      for(const [regex, route] of valid_routes) {

        const [_, ...params] = no_query_pathname.match(regex) || [];
        ctx.params = Object.assign({},
          ...route.param_variables.map((v, i) => ({[v]: params[i]}))
        )

        const response= await route.handler(request, ctx, () => '__NEXT__');

        if(response instanceof Response) return response;

        if(response == '__NEXT__') continue;
        throw new Deno.errors.UnexpectedEof();
      }

      throw new Deno.errors.NotFound();
    } catch(e: any) {
      console.warn(`[${request.url.replace(/\?.+$/, '')}] ${e.message || 'An unlabeled error ocurred'}`);

      if(e instanceof Deno.errors.InvalidData) {
        return new Response(undefined, {
          status: 400
        });
      }
      if(e instanceof Deno.errors.PermissionDenied) {
        return new Response(undefined, {
          status: 403
        });
      }
      if(e instanceof Deno.errors.NotFound) {
        return new Response(undefined, {
          status: 404
        });
      }

      return new Response(undefined, { status: 500 });
    }
  }
}

type Next = '__NEXT__';
type RouteHandler<T extends Record<string, any> = Record<string, any>> = (request: Request, ctx: InfinityContext<T>, next: () => Next) => Promise<Response | Next> | Response | Next;

type REST_METHOD = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'ANY'

interface Route {
  method: REST_METHOD
  path: string
  param_variables: string[]
  handler: RouteHandler<any>
}