0.1.6Updated a month ago
import InfinityHeaders from "@infinity-beyond/modules/networking/infinity_headers.ts";
import type { FreshContext } from "$fresh/server.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) => 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 `([^\\\/]+)`;
    }).replace(/\/$/, '') || '/') + '$');

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

  /**
   * Must be exported from `[...path].tsx` in the relevant path
   * 
   * Example:
   * `export const handler = [Ledger].REST.handler;`
   */
  get handler() {
    return this.__handler.bind(this);
  }

  private async __handler(request: Request, ctx: FreshContext<Infinity.Context>) {
    const relative_pathname = ctx.url.pathname
      .replace(ctx.route.replace(/\/:path\*$/, ''), '')
      .replace(/^\/api\/v\d+\/\w+/, '')
      .replace(/\/$/, '');

    const response = await this.handle(relative_pathname, request, ctx) || Response.json({}, { status: 404 });

    InfinityHeaders.apply(response.headers, {
      'x-request-id': request.state.uuid,
      'x-time-taken': `${Date.now() - request.state.start.getTime()}`,
    }, InfinityHeaders.Default);

    return response;
  }

  private async handle(pathname: string, request: Request, ctx: FreshContext<Infinity.Context>) {
    try {
      const no_query_pathname = pathname.replace(/\?.+$/, '') || '/';

      const allow_methods = this.routes.entries().toArray().filter(([ regex ]) => regex.test(no_query_pathname)).map(([ _, route ]) => route.method).join(', ');
      const allow_headers = "Access-Control-Allow-Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, Msisdn";

      if(request.method == 'OPTIONS') {
        const cors_response = new Response(undefined, {
          status: 204
        });

        InfinityHeaders.apply(cors_response.headers, {
          "Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
          "Access-Control-Allow-Methods": allow_methods,
          'Access-Control-Allow-Headers': allow_headers,
          'Access-Control-Allow-Credentials': "true",
        })

        return cors_response;
      }

      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) {
          InfinityHeaders.apply(response.headers, {
            "Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
            "Access-Control-Allow-Methods": allow_methods,
            'Access-Control-Allow-Headers': allow_headers,
            'Access-Control-Allow-Credentials': "true",
          })
  
          return response;
        }

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

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

      console.warn(`[${request.url.replace(/\?.+$/, '')}] ${e.message || 'An unexpected error ocurred'}`);
      console.warn(e.stack);

      return Response.json({}, { status: 500 });
    }
  }
}

type Next = '__NEXT__';
type RouteHandler = (request: Request, ctx: FreshContext<Infinity.Context>, 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
}