diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/deno-experiments.iml b/.idea/deno-experiments.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/deno-experiments.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/deno.xml b/.idea/deno.xml new file mode 100644 index 0000000..b03feb5 --- /dev/null +++ b/.idea/deno.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..28a804d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..57f48ac --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..288b36b --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/application.ts b/application.ts new file mode 100644 index 0000000..e2b8f44 --- /dev/null +++ b/application.ts @@ -0,0 +1,119 @@ +import { serve, ServeInit } from "https://deno.land/std@0.119.0/http/server.ts"; +import { Handler } from "./handler.ts"; +import {CatchFunc, HandleFunc, Matcher} from "./types.ts"; +import { Request as RoarterRequest } from "./request.ts"; + +export class Application { + handlers: Handler[] = []; + catchFunc: CatchFunc = () => { + throw new Error("Not Implemented"); + }; + + match(custom: Matcher): Handler { + const handler = new Handler().match(custom); + this.handlers.push(handler); + return handler; + } + + get get(): Handler { + const handler = new Handler().get; + this.handlers.push(handler); + return handler; + } + + get post(): Handler { + const handler = new Handler().post; + this.handlers.push(handler); + return handler; + } + + get put(): Handler { + const handler = new Handler().put; + this.handlers.push(handler); + return handler; + } + + get delete(): Handler { + const handler = new Handler().delete; + this.handlers.push(handler); + return handler; + } + + get patch(): Handler { + const handler = new Handler().patch; + this.handlers.push(handler); + return handler; + } + + path(path: string): Handler { + const handler = new Handler().path(path); + this.handlers.push(handler); + return handler; + } + + queries(queries: string[]): Handler { + const handler = new Handler().queries(queries); + this.handlers.push(handler); + return handler; + } + + handle(handler: HandleFunc | Application): Handler { + const h = new Handler().handle(handler); + this.handlers.push(h); + return h; + } + + catch(fn: CatchFunc) { + this.catchFunc = fn; + } + + // Handler runs all the registered handlers and returns the first + // response it finds. After returning the first response, it runs + // the remaining handlers. The purpose of this is to support middleware + // after a response has been sent. + async runHandlers(req: RoarterRequest): Promise { + let runRemaining = true + let i = 0; + try { + try { + for (i; i < this.handlers.length; i++) { + const handler = this.handlers[i]; + const response = await handler.run(req); + if (response) { + i++; + return response; + } + } + } catch (e) { + // If an error occurs, we wan't to skip all handlers and run the catchFunc + runRemaining = false + return await this.catchFunc(req, e); + } + // If after running all the handlers there is no response, throw the error + throw new Error( + `No Response was sent for ${req.method} ${req.url}`, + ); + } finally { + if (runRemaining) { + // Since at this point we have already returned a response, + // we run the remaining handlers ignoring their response and errors + for (i; i < this.handlers.length; i++) { + const handler = this.handlers[i]; + handler.run(req).catch(e => { + this.catchFunc(req, e) + }) + } + } + } + } + + async handler(domReq: Request): Promise { + const req = new RoarterRequest(domReq); + return this.runHandlers(req) + } + + async serve(options?: ServeInit) { + this.handler = this.handler.bind(this); + await serve(this.handler, options); + } +} diff --git a/handler.ts b/handler.ts new file mode 100644 index 0000000..62253a3 --- /dev/null +++ b/handler.ts @@ -0,0 +1,83 @@ +import { HandleFunc, Matcher } from "./types.ts"; +import { + buildPathMatcher, + buildQueryMatcher, + deleteMatcher, + getMatcher, + patchMatcher, + postMatcher, + putMatcher, +} from "./matchers.ts"; +import { Response } from "./response.ts"; +import { Request } from "./request.ts"; +import {Application} from "./application.ts"; + +export class Handler { + matchers: Matcher[] = []; + handler: (HandleFunc | Application) | null = null; + + private isHandlerASubRouter(): boolean { + return this.handler instanceof Application + } + + runMatchers(req: Request): boolean { + for (let i = 0; i < this.matchers.length; i++) { + const matcher: Matcher = this.matchers[i]; + if (!matcher(req, this.isHandlerASubRouter())) return false; + } + return true; + } + + async runHandler(req: Request) { + if (!this.handler) return; + if (this.isHandlerASubRouter()) { + return (this.handler as Application).runHandlers(req) + } else { + return (this.handler as HandleFunc)(req); + } + } + + match(custom: Matcher): Handler { + this.matchers.push(custom); + return this; + } + + get get(): Handler { + return this.match(getMatcher); + } + + get post(): Handler { + return this.match(postMatcher); + } + + get put(): Handler { + return this.match(putMatcher); + } + + get delete(): Handler { + return this.match(deleteMatcher); + } + + get patch(): Handler { + return this.match(patchMatcher); + } + + path(urlPath: string): Handler { + return this.match(buildPathMatcher(urlPath)); + } + + queries(queries: string[]): Handler { + return this.match(buildQueryMatcher(queries)); + } + + handle(handler: HandleFunc | Application) { + this.handler = handler; + return this; + } + + async run(req: Request) { + if (this.runMatchers(req)) { + return this.runHandler(req); + } + } +} diff --git a/matchers.ts b/matchers.ts new file mode 100644 index 0000000..8902dcb --- /dev/null +++ b/matchers.ts @@ -0,0 +1,65 @@ +import { Matcher, ROMap } from "./types.ts"; +import { extract, match } from "./utils/path.ts"; + +export const getMatcher: Matcher = (req) => { + return req.method === "GET"; +}; + +export const postMatcher: Matcher = (req) => { + return req.method === "POST"; +}; + +export const putMatcher: Matcher = (req) => { + return req.method === "PUT"; +}; + +export const deleteMatcher: Matcher = (req) => { + return req.method === "DELETE"; +}; + +export const patchMatcher: Matcher = (req) => { + return req.method === "PATCH"; +}; + +export const buildPathMatcher: (path: string) => Matcher = (path) => { + const prefixKey = "PathMatcher#PathPrefix" + return (req, matcherIsForASubRouter) => { + const url = new URL(req.url); + + // If the matcher is for a subrouter (as opposed to a handlerFunc), then we want to match the prefix + // of the path name and store it for later use + if (matcherIsForASubRouter) { + let pathCopy = path + if (req.vars.has(prefixKey)) { + pathCopy = req.vars.get(prefixKey) + pathCopy + } + if (!match(pathCopy, url.pathname, true)) return false; + req.vars.set(prefixKey, pathCopy) + const m = extract(pathCopy, url.pathname, true); + for (const [key, val] of m) { + req.params.set(key, val) + } + return true + } else { + let pathCopy = path + if (req.vars.has(prefixKey)) { + pathCopy = req.vars.get(prefixKey) + pathCopy + } + if (!match(pathCopy, url.pathname)) return false; + const m = extract(pathCopy, url.pathname); + for (const [key, val] of m) { + req.params.set(key, val) + } + return true; + } + }; +}; + +export const buildQueryMatcher: (queries: string[]) => Matcher = (queries) => { + return (req) => { + for (const query of queries) { + if (!req.queries.has(query)) return false; + } + return true; + }; +}; diff --git a/request.ts b/request.ts new file mode 100644 index 0000000..69b6d07 --- /dev/null +++ b/request.ts @@ -0,0 +1,17 @@ +import { ROMap } from "./types.ts"; + +class RoarterRequest extends Request { + public params: Map = new Map(); + public vars: Map = new Map(); + public queries: URLSearchParams; + public pathname: string; + + constructor(input: RequestInfo, init?: RequestInit) { + super(input, init); + const url = new URL(this.url); + this.pathname = url.pathname; + this.queries = url.searchParams; + } +} + +export { RoarterRequest as Request }; diff --git a/response.ts b/response.ts new file mode 100644 index 0000000..9edea47 --- /dev/null +++ b/response.ts @@ -0,0 +1,9 @@ +class RoarterResponse extends Response { + // @ts-ignore + constructor(body?: BodyInit | null, init?: ResponseInit) { + // @ts-ignore + super(body, init); + } +} + +export { RoarterResponse as Response }; diff --git a/tests/roarter.test.ts b/tests/roarter.test.ts new file mode 100644 index 0000000..23c09c7 --- /dev/null +++ b/tests/roarter.test.ts @@ -0,0 +1,471 @@ +import { + assert, + assertEquals, +} from "https://deno.land/std@0.119.0/testing/asserts.ts"; +import { Application } from "../application.ts"; +import { Response } from "../response.ts"; +import { Request } from "../request.ts"; + +Deno.test("matchers can go before or after handler", async () => { + let count = 0; + + const t = new Application(); + t.get.path("/hello1").handle(async (req) => { + count++; + }); + t.handle(async (req) => { + count++; + }).get.path("/hello2"); + t.get.handle(async (req) => { + count++; + }).path("/hello3"); + t.handle(async (req) => new Response("last")); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/hello1", + { + method: "GET", + }, + ), + ); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/hello2", + { + method: "GET", + }, + ), + ); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/hello3", + { + method: "GET", + }, + ), + ); + + assertEquals(count, 3); +}); + +Deno.test("verb matchers", async () => { + { + const t = new Application(); + let count = 0; + t.get.handle(async (req) => { + count++; + return new Response(); + }); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/", + { + method: "GET", + }, + ), + ); + assertEquals(count, 1); + } + + { + const t = new Application(); + let count = 0; + t.post.handle(async (req) => { + count++; + return new Response(); + }); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/", + { + method: "POST", + }, + ), + ); + assertEquals(count, 1); + } + + { + const t = new Application(); + let count = 0; + t.put.handle(async (req) => { + count++; + return new Response(); + }); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/", + { + method: "PUT", + }, + ), + ); + assertEquals(count, 1); + } + + { + const t = new Application(); + let count = 0; + t.delete.handle(async (req) => { + count++; + return new Response(); + }); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/", + { + method: "DELETE", + }, + ), + ); + assertEquals(count, 1); + } + + { + const t = new Application(); + let count = 0; + t.patch.handle(async (req) => { + count++; + return new Response(); + }); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/", + { + method: "PATCH", + }, + ), + ); + assertEquals(count, 1); + } + + { + const t = new Application(); + let count = 0; + t.delete.handle(async (req) => { + count++; + }); + t.handle(async (req) => new Response("")); + // @ts-ignore + await t.runHandlers( + new Request( + "https://example.com/", + { + method: "GET", + }, + ), + ); + assertEquals(count, 0); + } +}); + +Deno.test("path matcher", async () => { + { + const t = new Application(); + let id; + t.handle(async (req) => { + id = req.params.get("id"); + return new Response(); + }).path("/users/:id"); + // @ts-ignore + await t.runHandlers(new Request("http://example.com/users/2")); + assertEquals(id, "2"); + } + + { + const t = new Application(); + let id1; + let id2; + let id3; + t.handle(async (req) => { + id1 = req.params.get("id1"); + id2 = req.params.get("id2"); + id3 = req.params.get("id3"); + return new Response(); + }).path("/users/:id1/:id2/:id3"); + // @ts-ignore + await t.runHandlers(new Request("http://example.com/users/1/2/3")); + assertEquals(id1, "1"); + assertEquals(id2, "2"); + assertEquals(id3, "3"); + } + + { + const t = new Application(); + let id; + t.handle(async (req) => { + id = req.params.get("id"); + return new Response(); + }).path("/users/:id"); + // @ts-ignore + await t.runHandlers( + new Request( + "http://example.com/users/some-weird.key[with](funny);chars;", + ), + ); + assertEquals(id, "some-weird.key[with](funny);chars;"); + } + + { + const t = new Application(); + let id; + t.handle(async (req) => { + id = req.params.get("some-weird.key[with](funny);chars;"); + return new Response(); + }).path("/users/:some-weird.key[with](funny);chars;"); + // @ts-ignore + await t.runHandlers(new Request("http://example.com/users/1")); + assertEquals(id, "1"); + } +}); + +Deno.test("edge cases with paths", async () => { + { + const t = new Application(); + let hit; + t.handle(async (req) => { + hit = true; + return new Response(); + }).path("/"); + // @ts-ignore + await t.runHandlers(new Request("http://example.com/")); + assert(hit); + } + + { + const t = new Application(); + let hit; + t.handle(async (req) => { + hit = true; + return new Response(); + }).path("/"); + // @ts-ignore + t.runHandlers(new Request("http://example.com")); + assert(hit); + } + + { + const t = new Application(); + let hit; + t.handle(async (req) => { + hit = true; + return new Response(); + }).path("/"); + // @ts-ignore + await t.runHandlers(new Request("http://example.com?key=val")); + assert(hit); + } + + { + const t = new Application(); + let hit; + t.handle(async (req) => { + hit = true; + return new Response(); + }).path("/"); + // @ts-ignore + await t.runHandlers(new Request("http://example.com/?key=val")); + assert(hit); + } + + { + const t = new Application(); + let hit; + t.handle(async (req) => { + hit = true; + return new Response(); + }).path("/"); + t.handle(async (req) => new Response()); + // @ts-ignore + await t.runHandlers(new Request("http://example.com/hello?key=val")); + assert(!hit); + } +}); + +Deno.test("query params matching", async () => { + { + const t = new Application(); + let hit; + t.handle(async (req) => { + hit = req.queries.get("key"); + return new Response(); + }).path("/").queries(["key"]); + // @ts-ignore + await t.runHandlers(new Request("http://example.com/?key=val")); + assertEquals(hit, "val"); + } + + { + const t = new Application(); + let hit; + t.handle(async (req) => { + hit = req.queries.get("key1") as string + req.queries.get("key2"); + return new Response(); + }).path("/hello/there").queries(["key1", "key2"]); + // @ts-ignore + await t.runHandlers( + new Request("http://example.com/hello/there?key1=val1&key2=val2"), + ); + assertEquals(hit, "val1val2"); + } + + { + const t = new Application(); + let hit; + t.handle(async (req) => { + hit = req.queries.get("key1") as string + req.queries.get("key2"); + }).path("/hello/there").queries(["key"]); + t.handle(async (req) => new Response()); + // @ts-ignore + await t.runHandlers(new Request("http://example.com/hello/there?nope=val")); + assertEquals(hit, undefined); + } +}); + +Deno.test("Roarter runs all handlers sequentially UNTIL it receives the first Response. Then runs the remaining asynchronously so as to not keep the client hanging.", async () => { + const t = new Application(); + let res = ""; + + t.handle(async (req) => { + await new Promise((resolve, reject) => { + setTimeout(() => resolve(null), 100); + }); + res += "first"; + }); + t.path("/hello").handle(async (req) => { + await new Promise((resolve, reject) => { + setTimeout(() => resolve(null), 100); + }); + res += "second"; + return new Response("second"); + }); + t.path("/hello").handle(async (req) => { + await new Promise((resolve, reject) => { + setTimeout(() => resolve(null), 100); + }); + res += "third"; + return new Response("third"); + }); + t.path("/hello2").handle(async (req) => { + await new Promise((resolve, reject) => { + setTimeout(() => resolve(null), 100); + }); + res += "no"; + return new Response("no"); + }); + t.handle(async (req) => { + await new Promise((resolve, reject) => { + setTimeout(() => resolve(null), 100); + }); + res += "fourth"; + }); + t.handle(async (req) => { + await new Promise((resolve, reject) => { + setTimeout(() => resolve(null), 100); + }); + res += "fifth"; + }); + + // @ts-ignore + const response = await t.runHandlers( + new Request("https://example.com/hello", { method: "GET" }), + ); + console.log("received response"); + const text = await response.text(); + // Handler returns the first Response it finds + assertEquals(text, "second"); + // Since 'first' and 'second' were executed synchronously and the rest were not, + // we only expect 'first' and 'second' to have been waited on + assertEquals(res, "firstsecond"); + + // The rest will appear after some time + await new Promise((resolve, reject) => { + setTimeout(() => resolve(null), 1000); + }); + assertEquals(res, "firstsecondthirdfourthfifth"); +}); + +Deno.test("Roarter successfully catches the error and hands it off to .catch", async () => { + const t = new Application(); + t.handle(async (req) => { + throw new Error("error"); + }); + t.catch(async (req, err) => { + return new Response(err.message); + }); + // @ts-ignore + const response = await t.runHandlers( + new Request("https://example.com/hello", { method: "GET" }), + ); + const text = await response.text(); + assertEquals(text, "error"); +}); + +Deno.test("Roarter catches the error from postfix middleware", async () => { + const t = new Application(); + let caught = false + t.get.handle(async req => { + return new Response("done") + }) + t.handle(async (req) => { + throw new Error("error"); + }); + t.catch(async (req, err) => { + caught = true + return new Response(err.message); + }); + // @ts-ignore + const response = await t.runHandlers( + new Request("https://example.com/hello", { method: "GET" }), + ); + await new Promise((resolve) => { + setTimeout(() => resolve(null), 200) + }) + assert(caught); +}); + +Deno.test("subrouters work", async () => { + const app1 = new Application(); + + app1.path("/hello").handle(async req => { + return new Response("hello") + }) + + const app2 = new Application() + + app2.path("/api").handle(app1) + + const response = await app2.runHandlers( + new Request("https://example.com/api/hello", { method: "GET" }), + ) + + assertEquals(await response.text(), "hello") +}); + +Deno.test("subrouters work with params", async () => { + const app1 = new Application(); + + app1.path("/hello/:p2").handle(async req => { + assertEquals(req.params.get("p1"), "v1") + assertEquals(req.params.get("p2"), "v2") + return new Response("hello") + }) + + const app2 = new Application() + + app2.path("/api/:p1").handle(app1) + + const response = await app2.runHandlers( + new Request("https://example.com/api/v1/hello/v2", { method: "GET" }), + ) + + assertEquals(await response.text(), "hello") +}); diff --git a/tests/utils/path.test.ts b/tests/utils/path.test.ts new file mode 100644 index 0000000..e943380 --- /dev/null +++ b/tests/utils/path.test.ts @@ -0,0 +1,104 @@ +import { + assert, + assertEquals, + assertThrows, +} from "https://deno.land/std@0.119.0/testing/asserts.ts"; +import { extract, match } from "../../utils/path.ts"; + +Deno.test("match correctly matches different paths", () => { + assert( + match( + "/user/:id", + "/user/294", + ), + ); + + assert( + !match( + "/user/:id/profile", + "/user/294", + ), + ); + + assert( + match( + "/user/:id", + "/user/294/", + ), + ); + + assert( + match( + "/:routeParams", + "/jon-ros-lu", + ), + ); + + assert( + match( + "/api", + "/api/v1/hello", + true + ), + ); + + assert( + match( + "/", + "/api/v1/hello", + true + ), + ); + + assert( + match( + "/api/v1/hello", + "/api/v1/hello", + true + ), + ); + + assert( + match( + "/api/:v1", + "/api/v1/hello", + true + ), + ); +}); + +Deno.test("extracts correctly returns the keys and values", () => { + { + const m = extract("/user/:id", "/user/2"); + assert(m.size === 1); + assertEquals(m.get("id"), "2"); + assertEquals(m.get("notexist"), undefined); + } + + { + const m = extract("/user", "/user"); + assert(m.size === 0); + assertEquals(m.get("notexist"), undefined); + } + + { + const m = extract("/user/:id", "/user/2/hello/there", true); + assert(m.size === 1); + assertEquals(m.get("id"), "2"); + assertEquals(m.get("notexist"), undefined); + } + + { + const m = extract("/user/:id/:id2", "/user/2/hello/there", true); + assert(m.size === 2); + assertEquals(m.get("id"), "2"); + assertEquals(m.get("id2"), "hello"); + assertEquals(m.get("notexist"), undefined); + } + + { + assertThrows(() => extract("/user/:id/wow", "/user/2")); + assertThrows(() => extract("/user", "/user/2")); + assertThrows(() => extract("/user/id", "/user/2")); + } +}); diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..1cc4f5d --- /dev/null +++ b/types.ts @@ -0,0 +1,10 @@ +import { Response } from "./response.ts"; +import { Request } from "./request.ts"; + +export type Matcher = (req: Request, matcherIsForASubRouter?: boolean) => boolean; +export type HandleFunc = (req: Request) => Promise; +export type CatchFunc = (req: Request, err: Error) => Promise; +export interface ROMap { + get(key: Key): Val | undefined; + has(key: Key): boolean; +} diff --git a/utils/path.ts b/utils/path.ts new file mode 100644 index 0000000..94e5404 --- /dev/null +++ b/utils/path.ts @@ -0,0 +1,69 @@ +/** + * match returns true if a Roarter path, of the form /user/:id, matches a given real-world path, + * such as /user/83 + * @param matchPath + * @param realPath + * @param prefix + */ +export function match(matchPath: string, realPath: string, prefix: boolean = false): boolean { + const matchPathSplit = matchPath.split("/").filter(s => s !== ''); + const realPathSplit = realPath.split("/").filter(s => s !== ''); + + if (!prefix && matchPathSplit.length !== realPathSplit.length) { + return false; + } + + for (let i = 0; i < matchPathSplit.length; i++) { + const matchStr = matchPathSplit[i]; + const realStr = realPathSplit[i]; + + // a ':' matches everything, so continue + if (matchStr[0] === ":") continue; + + if (matchStr !== realStr) return false; + } + + return true; +} + +/** + * extract returns a map of the matchPath keys mapped to the realPath values + * @param matchPath + * @param realPath + * @param prefix + */ +export function extract( + matchPath: string, + realPath: string, + prefix: boolean = false +): Map { + const matchPathSplit = matchPath.split("/").filter(s => s !== ''); + const realPathSplit = realPath.split("/").filter(s => s !== ''); + + if (!prefix && matchPathSplit.length !== realPathSplit.length) { + throw new Error( + "matchPath and realPath do not match. Call match before calling this function.", + ); + } + + const m = new Map(); + + for (let i = 0; i < matchPathSplit.length; i++) { + const matchStr = matchPathSplit[i]; + const realStr = realPathSplit[i]; + + // a ':' matches everything, so continue + if (matchStr[0] === ":") { + m.set(matchStr.substr(1), realStr); + continue; + } + + if (matchStr !== realStr) { + throw new Error( + "matchPath and realPath do not match. Call match before calling this function.", + ); + } + } + + return m; +}