From 1ad0f47d584f6783539217d8ca689de1a6b0f4da Mon Sep 17 00:00:00 2001 From: EuAndreh Date: Fri, 8 Mar 2024 07:47:47 -0300 Subject: src/web.mjs: Move logging and interceptors to the beginning of the file --- tests/js/hero.mjs | 1776 ++++++++++++++++++++++++++--------------------------- 1 file changed, 888 insertions(+), 888 deletions(-) (limited to 'tests/js/hero.mjs') diff --git a/tests/js/hero.mjs b/tests/js/hero.mjs index a8883d5..9a5cf25 100644 --- a/tests/js/hero.mjs +++ b/tests/js/hero.mjs @@ -7,6 +7,16 @@ import procees from "node:process"; import * as runner from "../runner.mjs"; import * as u from "../../src/utils.mjs"; import { + loggerDefaults, + loggerGlobals, + configLogger, + logit, + makeLogger, + interceptorsFn, + interceptors, + defaultInterceptors, + chainInterceptors, + wrapHandler, normalizeSegments, pathToSegments, hasPathParams, @@ -20,16 +30,6 @@ import { make404Handler, handleRequest, makeRequestListener, - loggerDefaults, - loggerGlobals, - configLogger, - logit, - makeLogger, - interceptorsFn, - interceptors, - defaultInterceptors, - chainInterceptors, - wrapHandler, actionsFn, lineHandlerFn, rmIf, @@ -44,1095 +44,1095 @@ import { } from "../../src/hero.mjs"; -const test_normalizeSegments = async t => { - t.start("normalizeSegments()"); +const test_configLogger = async t => { + t.start("configLogger()"); - await t.test("unchanged when already normalized", () => { - assert.deepEqual(normalizeSegments([""]), [""]); + await t.test("globals starts empty", () => { + assert.deepEqual(loggerGlobals, {}); }); - await t.test("empty terminator added when missing", () => { - assert.deepEqual(normalizeSegments([]), [""]); - assert.deepEqual(normalizeSegments([" "]), [" ", ""]); - assert.deepEqual(normalizeSegments(["_"]), ["_", ""]); - assert.deepEqual(normalizeSegments(["a"]), ["a", ""]); - assert.deepEqual(normalizeSegments([":a"]), [":a", ""]); - assert.deepEqual(normalizeSegments(["path"]), ["path", ""]); + await t.test("is gets becomes we assign it", () => { + const globals = { + app: "my-app", + color: "green", + version: "deadbeef", + }; + configLogger(globals); + assert.deepEqual(loggerGlobals, globals); + }); + + await t.test("we can reset it", () => { + configLogger({}); + assert.deepEqual(loggerGlobals, {}); }); }; -const test_pathToSegments = async t => { - t.start("pathToSegments()"); +const test_logit = async t => { + t.start("logit()"); - await t.test("simple paths", () => { - assert.deepEqual(pathToSegments("/"), [ "" ]); - assert.deepEqual(pathToSegments("/simple"), ["simple", ""]); - assert.deepEqual(pathToSegments("/simple/route"), ["simple", "route", ""]); + await t.test("we can log data", () => { + configLogger({ app: "hero-based app" }); + const contents = []; + const writerFn = x => contents.push(x); - assert.deepEqual(pathToSegments("/api/user/:id"), ["api", "user", ":id", ""]); - assert.deepEqual(pathToSegments("/api/user/:id/info"), ["api", "user", ":id", "info", ""]); - }); + logit(writerFn, "my level", { a: 1, type: "log-test" }); + assert.deepEqual(contents.map(JSON.parse), [{ + ...loggerDefaults, + app: "hero-based app", + level: "my level", + a: 1, + type: "log-test", + }]); - await t.test("extra separators", () => { - assert.deepEqual(pathToSegments("/api/health/"), ["api", "health", ""]); - assert.deepEqual(pathToSegments("/api///health"), ["api", "health", ""]); - assert.deepEqual(pathToSegments("//api///health//"), ["api", "health", ""]); + /// reset the logger out of the values specific to this test + configLogger({}); }); -}; -const test_hasPathParams = async t => { - t.start("hasPathParam()"); + await t.test("the object can change previous fallback values", () => { + const contents = []; + const writerFn = x => contents.push(x); + + configLogger({ + level: "unseen", + a: "unseen", + }); + logit(writerFn, "overwritten by level", { a: "overwritten by o" }); + + configLogger({ + pid: "overwritten by loggerGlobals", + }); + logit(writerFn, "unseen", { level: "overwritten by o" }); + + /// reset the logger out of the values specific to this test + configLogger({}); + + assert.deepEqual(contents.map(JSON.parse), [ + { + ...loggerDefaults, + pid: process.pid, + level: "overwritten by level", + a: "overwritten by o", + }, + { + ...loggerDefaults, + pid: "overwritten by loggerGlobals", + level: "overwritten by o", + }, + ]); - await t.test("has it", () => { - assert(hasPathParams(["some", ":path", ""])); - assert(hasPathParams(["path", ":params", ""])); - assert(hasPathParams(["api", "user", ":id", "info", ""])); }); - await t.test("doesn't have it", () => { - assert(!hasPathParams([])); - assert(!hasPathParams([ "" ])); - assert(!hasPathParams(["some", "path", ""])); - assert(!hasPathParams(["s:o:m:e:", "p::ath:::"])); - assert(!hasPathParams([" :"])); + await t.test("we can't log unserializable things", () => { + const obj = { self: null }; + obj.self = obj; + assert.throws(() => logit(obj), TypeError); }); }; -const test_addRoute = async t => { - t.start("addRoute()"); - - const fn1 = () => {}; +const test_makeLogger = async t => { + t.start("makeLogger()"); - await t.test("daily usage examples", () => { - assert.deepEqual( - addRoute({}, "GET", "/home", fn1), - { static: { GET: { home: { "": fn1 }}}}, - ); + await t.test("various log levels", () => { + const contents = []; + const writerFn = x => contents.push(x); + const log = makeLogger(writerFn); - assert.deepEqual( - addRoute({}, ["PUT", "POST", "PATCH"], "/api/user", fn1), + log.info ({ type: "expected" }); + log.warn ({ type: "worrysome" }); + log.error({ type: "bad" }); + assert.deepEqual(contents.map(JSON.parse), [ { - static: { - PUT: { api: { user: { "": fn1 }}}, - POST: { api: { user: { "": fn1 }}}, - PATCH: { api: { user: { "": fn1 }}}, - }, + ...loggerDefaults, + level: "INFO", + type: "expected", }, - ); - - assert.deepEqual( - addRoute({}, "*", "/settings", fn1), { - static: { - GET: { settings: { "": fn1 }}, - HEAD: { settings: { "": fn1 }}, - POST: { settings: { "": fn1 }}, - PUT: { settings: { "": fn1 }}, - PATCH: { settings: { "": fn1 }}, - DELETE: { settings: { "": fn1 }}, - OPTIONS: { settings: { "": fn1 }}, - }, + ...loggerDefaults, + level: "WARN", + type: "worrysome", }, - ); - - assert.deepEqual( - addRoute({}, "GET", "/", fn1), - { static: { GET: { "": fn1 }}}, - ); - - assert.deepEqual( - addRoute({}, "GET", "/api/user/:id", fn1), - { dynamic: { GET: { api: { user: { ":id": { "": fn1 }}}}}}, - ); - - assert.deepEqual( - addRoute({}, ["PUT", "PATCH"], "/api/org/:orgid/member/:memberid", fn1), { - dynamic: { - PUT: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, - PATCH: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, - }, + ...loggerDefaults, + level: "ERROR", + type: "bad", }, - ); + ]); }); - await t.test("bad method", () => { - assert.throws( - () => addRoute({}, "VERB", "/path", fn1), - assert.AssertionError, - ); - }); + await t.test("debug only works when $DEBUG is set", () => { + const contents = []; + const writerFn = x => contents.push(x); + const log = makeLogger(writerFn); - await t.test("empty methods array", () => { - assert.deepEqual(addRoute({}, [], "", fn1), {}); - }); + const previous = process.env.DEBUG; + delete process.env.DEBUG; - await t.test("subpaths", () => { - const fn1 = () => {}; - const fn2 = () => {}; + log.debug({ x: "ignored" }); + process.env.DEBUG = "1"; + log.debug({ x: "seen" }); + delete process.env.DEBUG; + /// call the function that toggles + log.debug({ x: "ignored" }); - const t1 = addRoute({}, "GET", "/home", fn1); - const t2 = addRoute(t1, "GET", "/home/details", fn2); - assert.deepEqual( - t2, - { - static: { - GET: { - home: { - "": fn1, - details: { - "": fn2, - }, - }, - }, - }, - }, - ); + assert.deepEqual(contents.map(JSON.parse), [{ + ...loggerDefaults, + level: "DEBUG", + x: "seen", + }]); + + process.env.DEBUG = previous; }); }; -const test_findStaticHandler = async t => { - t.start("findStaticHandler()"); +const test_interceptorsFn = async t => { + const next = x => x; - await t.test("multiple accesses to the same table", () => { - const fn1 = () => {}; - const fn2 = () => {}; - const fn3 = () => {}; + { + t.start("interceptorsFn().requestId()"); - const table = { - static: { - GET: { - api: { - home: { "": fn1 }, - settings: { "": fn2 }, - }, + let i = 0; + const uuidFn = () => `${i++}`; + + await t.test("we add an id to whatever we receive", () => { + assert.deepEqual( + interceptorsFn({uuidFn}).requestId({}, next), + { + id: "0", }, - POST: { - api: { - settings: { "": fn3 }, - }, + ); + assert.deepEqual( + interceptorsFn({uuidFn}).requestId({ a: "existing data" }, next), + { + a: "existing data", + id: "1", }, - }, - }; - - assert.deepEqual( - findStaticHandler(table, "GET", [ "api", "home", "" ]), - { handlerFn: fn1, params: {} }, - ); - assert.deepEqual( - findStaticHandler(table, "PUT", [ "api", "home", "" ]), - null, - ); - - assert.deepEqual( - findStaticHandler(table, "GET", [ "api", "settings", "" ]), - { handlerFn: fn2, params: {} }, - ); - assert.deepEqual( - findStaticHandler(table, "POST", [ "api", "settings", "" ]), - { handlerFn: fn3, params: {} }, - ); - assert.deepEqual( - findStaticHandler(table, "PUT", [ "api", "settings", "" ]), - null, - ); + ); + }); - assert.deepEqual( - findStaticHandler(table, "GET", [ "api", "profile", "" ]), - null, - ); + await t.test(`we overwrite the "id" if it already exists`, async () => { + assert.deepEqual( + interceptorsFn({uuidFn}).requestId({ id: "before" }, next), + { + id: "2", + }, + ); + }); + }; - assert.deepEqual( - findStaticHandler({}, "GET", [ "api", "profile", "" ]), - null, - ); - }); -}; + { + t.start("interceptorsFn().logged()"); -const test_firstParamMatch = async t => { - t.start("firstParamMatch()"); + await t.test("we log before and after next()", async () => { + const contents = []; + const logger = { info: x => contents.push(x) }; + const status = 201; + const req = { + id: "an ID", + url: "a URL", + method: "a method", + }; - const params = {}; - const fn1 = () => {}; - const fn2 = () => {}; + assert.deepEqual( + await interceptorsFn({logger}).logged(req, _ => ({ status })), + { status }, + ); + assert.deepEqual( + contents, + [ + { ...req, type: "in-request" }, + { id: req.id, status, type: "in-response" }, + ], + ); + }); + }; - await t.test("we BACKTRACK when searching down routes", () => { - const segments = [ "path", "split", "match", "" ]; + { + t.start("interceptorsFn().contentType()"); - const tree = { - path: { - split: { - MISMATCH: { - "": fn1, + await t.test("empty values", async () => { + assert.deepEqual( + await interceptorsFn().contentType({}, next), + { + body: "", + headers: { + "Content-Type": "application/json", + "Content-Length": 0, }, }, - ":param": { - match: { - "": fn2, + ); + + assert.deepEqual( + await interceptorsFn().contentType({ body: "" }, next), + { + body: "", + headers: { + "Content-Type": "text/html", + "Content-Length": 0, }, }, - }, - }; - - assert.deepEqual( - firstParamMatch(tree, segments, params), - { handlerFn: fn2, params: { param: "split" }}, - ); - }); - - await t.test("ambiguous route prefers params at the end", () => { - const segments = [ "path", "param1", "param2", "" ]; + ); + }); - const tree1 = { - path: { - ":shallower": { - param2: { - "": fn2, + await t.test("body values", async () => { + assert.deepEqual( + await interceptorsFn().contentType({ body: { a: 1 }}, next), + { + body: `{"a":1}`, + headers: { + "Content-Type": "application/json", + "Content-Length": 7, }, }, - }, - }; - - const tree2 = u.assocIn(tree1, [ "path", "param1", ":deeper", "" ], fn1); - - assert.deepEqual( - firstParamMatch(tree1, segments, params), - { handlerFn: fn2, params: { shallower: "param1" }}, - ); - - assert.deepEqual( - firstParamMatch(tree2, segments, params), - { handlerFn: fn1, params: { deeper: "param2" }}, - ); - }); - - await t.test("when 2 params are possible, we pick the first alphabetically", () => { - const segments = [ "user", "someId", "" ]; + ); - const tree1 = { - user: { - ":bbb": { - "": fn2, + assert.deepEqual( + await interceptorsFn().contentType({ body: "
" }, next), + { + body: "
", + headers: { + "Content-Type": "text/html", + "Content-Length": 6, + }, }, - }, - }; - - const tree2 = u.assocIn(tree1, ["user", ":aaa", ""], fn1); - - assert.deepEqual( - firstParamMatch(tree1, segments, params), - { handlerFn: fn2, params: { bbb: "someId" }}, - ); + ); + }); - assert.deepEqual( - firstParamMatch(tree2, segments, params), - { handlerFn: fn1, params: { aaa: "someId" }}, - ); - }); + await t.test("header values preference", async () => { + assert.deepEqual( + await interceptorsFn().contentType({ + body: "", + headers: { + "Content-Type": "we have preference", + "Content-Length": "and so do we", + }, + }, next), + { + body: "", + headers: { + "Content-Type": "we have preference", + "Content-Length": "and so do we", + }, + }, + ); + }); - await t.test(`we deal with segments that start with ":"`, () => { - const segments = [ "path", ":param", "" ]; - const tree = { - path: { - ":arg": { - "": fn1, + await t.test("headers get propagated", async () => { + assert.deepEqual( + await interceptorsFn().contentType({ + body: "", + headers: { + "My": "Header", + }, + }, next), + { + body: "", + headers: { + "My": "Header", + "Content-Type": "text/html", + "Content-Length": 0, + }, }, - }, - }; + ); + }); + }; - assert.deepEqual( - firstParamMatch(tree, segments, params), - { handlerFn: fn1, params: { arg: ":param" }}, - ); - }); -}; + { + t.start("interceptorsFn().serverError()"); -const test_findDynamicHandler = async t => { - t.start("findDynamicHandler()"); + await t.test("no-op when no error occurs", async () => { + assert.deepEqual( + await interceptorsFn().serverError({ status: 1 }, next), + { status: 1 }, + ); + }); - await t.test("daily usage cases", () => { - const fn1 = () => {}; - const fn2 = () => {}; - - const table = { - dynamic: { - GET: { - users: { - "by-id": { - ":id": { "": fn1 }, - }, - }, - }, - PUT: { - user: { - ":user-id": { - info: { "": fn2 }, - }, - }, + await t.test(`an error is thrown if "status" is missing`, async () => { + const contents = []; + const logger = { error: x => contents.push(x) }; + assert.deepEqual( + await interceptorsFn({ logger }).serverError({ id: 123 }, next), + { + status: 500, + body: "Internal Server Error\n", }, - }, - }; - - assert.deepEqual( - findDynamicHandler(table, "GET", [ "users", "by-id", "123", "" ]), - { handlerFn: fn1, params: { "id": "123" }}, - ); + ); + assert.deepEqual( + contents, + [{ + id: 123, + type: "server-error-interceptor", + message: `Missing "status"`, + }], + ); + }); - assert.deepEqual( - findDynamicHandler({}, "GET", [ "users", "by-id", "123", "" ]), - null, - ); + await t.test("we turn a handler error into a 500 response", async () => { + const contents = []; + const logger = { error: x => contents.push(x) }; + assert.deepEqual( + await interceptorsFn({ logger }).serverError( + { id: "some ID" }, + _ => { throw new Error("My test error message"); }, + ), + { + status: 500, + body: "Internal Server Error\n", + }, + ); + assert.deepEqual( + contents, + [{ + id: "some ID", + type: "server-error-interceptor", + message: "My test error message", + }], + ); + }); + }; +}; - assert.deepEqual( - findDynamicHandler({ dynamic: { GET: { "": fn1 }}}, "GET", [ "users", "by-id", "123", "" ]), - null, - ); +const test_chainInterceptors = async t => { + t.start("chainInterceptors()"); - assert.deepEqual( - findDynamicHandler(table, "PUT", [ "user", "deadbeef", "info", "" ]), - { handlerFn: fn2, params: { "user-id": "deadbeef" }}, - ); + await t.test("empty values", () => { + assert.equal(chainInterceptors([])("req"), "req"); }); -}; - -const test_findHandler = async t => { - t.start("findHandler()"); - await t.test("mix of static and dynamic routes", () => { - const static1 = () => {}; - const static2 = () => {}; - const static3 = () => {}; - const dynamic1 = () => {}; - const dynamic2 = () => {}; - const dynamic3 = () => {}; - const dynamic4 = () => {}; + await t.test("the order of interceptors matter", () => { + const a = []; - const table = { - static: { - GET: { - user: { - "": static1, - }, - pages: { - "": static2, - home: { - "": static3, - }, - }, - }, - }, - dynamic: { - GET: { - user: { - ":id": { - "": dynamic1, - }, - }, - }, - PUT: { - user: { - ":id": { - "": dynamic2, - "info": { - "": dynamic3, - }, - "preferences": { - "": dynamic4, - }, - }, - }, - }, - }, + const i1 = (req, next) => { + a.push("i1"); + return next(req); + }; + const i2 = (req, next) => { + a.push("i2"); + return next(req); }; - assert.deepEqual( - findHandler(table, "GET", "/"), - null, - ); + assert.equal(chainInterceptors([i1, i2])("req"), "req"); + assert.deepEqual(a, ["i1", "i2"]); + assert.equal(chainInterceptors([i2, i1])("req"), "req"); + assert.deepEqual(a, ["i1", "i2", "i2", "i1"]); + }); - assert.deepEqual( - findHandler(table, "GET", "/user/"), - { handlerFn: static1, params: {} }, - ); + await t.test("with ordering, interceptors implicitly depend on each other", () => { + const i1 = (req, next) => next({ ...req, id: 1 }); + const i2 = (req, next) => next({ ...req, id: req.id + 1 }); assert.deepEqual( - findHandler(table, "GET", "/pages"), - { handlerFn: static2, params: {} }, - ); - assert.deepEqual( - findHandler(table, "GET", "/pages/home/"), - { handlerFn: static3, params: {} }, + chainInterceptors([i1, i2])({}), + { id: 2 }, ); assert.deepEqual( - findHandler(table, "GET", "/user/some-id"), - { handlerFn: dynamic1, params: { id: "some-id" }}, - ); - assert.deepEqual( - findHandler(table, "GET", "/user/other-id/info"), - null, + chainInterceptors([i2])({}), + { id: NaN }, ); assert.deepEqual( - findHandler(table, "PUT", "/user/other-id/info"), - { handlerFn: dynamic3, params: { id: "other-id" }}, - ); - assert.deepEqual( - findHandler(table, "PUT", "/user/another-id/preferences"), - { handlerFn: dynamic4, params: { id: "another-id" }}, - ); - assert.deepEqual( - findHandler(table, "POST", "/user/another-id/preferences"), - null, + chainInterceptors([i2, i1])({}), + { id: 1 }, ); }); -}; - -const test_extractQueryParams = async t => { - t.start("extractQueryParams()"); - await t.test("empty values", () => { - assert.deepEqual(extractQueryParams(), {}); - assert.deepEqual(extractQueryParams(null), {}); - assert.deepEqual(extractQueryParams(undefined), {}); - }); + await t.test("we can chain async interceptors", async () => { + const i1 = async (req, next) => await next({ ...req, i1: true }); + const i2 = async (req, next) => await next({ ...req, i2: true }); - await t.test("we get a flat key-value strings", () => { assert.deepEqual( - extractQueryParams("a[]=1&b=text&c=___"), + await chainInterceptors([i1, i2])({}), { - "a[]": "1", - b: "text", - c: "___", + i1: true, + i2: true, }, ); }); - - await t.test("duplicate values are suppressed, deterministically", () => { - assert.deepEqual(extractQueryParams("a=1&a=2&a=3"), { a: "3" }); - assert.deepEqual(extractQueryParams("a=1&b=2&a=3"), { a: "3", b: "2" }); - }); }; -const test_make404Handler = async t => { - t.start("make404Handler"); +const test_wrapHandler = async t => { + t.start("wrapHandler()"); - await t.test("empty interceptors", () => { - assert.deepEqual(make404Handler([]), { - params: {}, - handlerFn: handle404, - }); + await t.test("noop when arr is empty", () => { + const fn = () => {}; + const wrappedFn1 = wrapHandler(fn, []); + assert.deepEqual(fn, wrappedFn1); + + const wrappedFn2 = wrapHandler(fn, [ interceptors.requestId ]); + assert.notDeepEqual(fn, wrappedFn2); }); -}; -const test_handleRequest = async t => { - t.start("handleRequest()"); + await t.test("a handler with chained interceptors change its behaviour", async () => { + let i = 0; + const uuidFn = () => `${i++}`; - const fn = req => req; + const contents = []; + const logger = { info: x => contents.push(x) }; - await t.test("request without params", async () => { - const table = { - static: { - GET: { - "": fn, - }, - }, + const interceptors = interceptorsFn({uuidFn, logger}); + + const fn = async _ => await { status: 1, body: { a: 1 }}; + const wrappedFn = wrapHandler(fn, [ + interceptors.requestId, + interceptors.logged, + interceptors.contentType, + ]); + + const req = { + url: "URL", + method: "METHOD", }; assert.deepEqual( - await handleRequest(table, "GET", "/?q=1"), - { - params: { - path: {}, - query: { - q: "1", - }, - }, - method: "GET", - path: "/", - handler: fn, - }, + await fn(req), + { status: 1, body: { a: 1 }}, ); - }); - - await t.test("request with params", async () => { - const table = { - dynamic: { - PUT: { - api: { - user: { - ":userId": { - "": fn, - }, - }, - }, - }, - }, - interceptors: [], - }; + assert.deepEqual(contents, []); assert.deepEqual( - await handleRequest(table, "PUT", "/api/user/2222"), + await wrappedFn(req), { - params: { - path: { - userId: "2222", - }, - query: {}, + status: 1, + body: `{"a":1}`, + headers: { + "Content-Type": "application/json", + "Content-Length": 7, }, - method: "PUT", - path: "/api/user/2222", - handler: fn, }, ); - }); - - await t.test("missing route", async () => { assert.deepEqual( - await handleRequest({ interceptors: [] }, "GET", "/"), - { - status: 404, - body: "Not Found\n", - }, + contents, + [ + { + id: "0", + url: "URL", + method: "METHOD", + type: "in-request", + }, + { + id: "0", + status: 1, + type: "in-response", + }, + ], ); }); }; -const test_makeRequestListener = async t => { - t.start("makeRequestListener()"); - - await t.test("straightforward body execution", async () => { - const fn = _ => ({ - status: "test status", - body: "test body", - headers: "test headers", - }); - const routes = [[ "GET", "/route1", fn ]]; - const requestListener = makeRequestListener(buildRoutes(routes)); - const req = { - method: "GET", - url: "/route1", - }; - - const heads = []; - const bodies = []; - const res = { - writeHead: (status, headers) => heads.push({ status, headers }), - end: body => bodies.push(body), - }; - await requestListener(req, res); +const test_normalizeSegments = async t => { + t.start("normalizeSegments()"); - assert.deepEqual( - heads, - [{ - status: "test status", - headers: "test headers", - }], - ); - assert.deepEqual( - bodies, - [ "test body" ], - ); + await t.test("unchanged when already normalized", () => { + assert.deepEqual(normalizeSegments([""]), [""]); }); - await t.test("we break if handleRequest() throws an error", async () => { - const fn = _ => { throw new Error("handler error"); }; - const routes = [[ "GET", "/route2", fn ]]; - const requestListener = makeRequestListener(buildRoutes(routes)); - const req = { - method: "GET", - url: "/route2", - }; - - const heads = []; - const bodies = []; - const res = { - writeHead: (status, headers) => heads.push({ status, headers }), - end: body => bodies.push(body), - }; - - await assert.rejects( - async () => await requestListener(req, res), - { message: "handler error" }, - ); - - assert.deepEqual(heads, []); - assert.deepEqual(bodies, []); + await t.test("empty terminator added when missing", () => { + assert.deepEqual(normalizeSegments([]), [""]); + assert.deepEqual(normalizeSegments([" "]), [" ", ""]); + assert.deepEqual(normalizeSegments(["_"]), ["_", ""]); + assert.deepEqual(normalizeSegments(["a"]), ["a", ""]); + assert.deepEqual(normalizeSegments([":a"]), [":a", ""]); + assert.deepEqual(normalizeSegments(["path"]), ["path", ""]); }); }; -const test_configLogger = async t => { - t.start("configLogger()"); +const test_pathToSegments = async t => { + t.start("pathToSegments()"); - await t.test("globals starts empty", () => { - assert.deepEqual(loggerGlobals, {}); - }); + await t.test("simple paths", () => { + assert.deepEqual(pathToSegments("/"), [ "" ]); + assert.deepEqual(pathToSegments("/simple"), ["simple", ""]); + assert.deepEqual(pathToSegments("/simple/route"), ["simple", "route", ""]); - await t.test("is gets becomes we assign it", () => { - const globals = { - app: "my-app", - color: "green", - version: "deadbeef", - }; - configLogger(globals); - assert.deepEqual(loggerGlobals, globals); + assert.deepEqual(pathToSegments("/api/user/:id"), ["api", "user", ":id", ""]); + assert.deepEqual(pathToSegments("/api/user/:id/info"), ["api", "user", ":id", "info", ""]); }); - await t.test("we can reset it", () => { - configLogger({}); - assert.deepEqual(loggerGlobals, {}); + await t.test("extra separators", () => { + assert.deepEqual(pathToSegments("/api/health/"), ["api", "health", ""]); + assert.deepEqual(pathToSegments("/api///health"), ["api", "health", ""]); + assert.deepEqual(pathToSegments("//api///health//"), ["api", "health", ""]); }); }; -const test_logit = async t => { - t.start("logit()"); - - await t.test("we can log data", () => { - configLogger({ app: "hero-based app" }); - const contents = []; - const writerFn = x => contents.push(x); - - logit(writerFn, "my level", { a: 1, type: "log-test" }); - assert.deepEqual(contents.map(JSON.parse), [{ - ...loggerDefaults, - app: "hero-based app", - level: "my level", - a: 1, - type: "log-test", - }]); +const test_hasPathParams = async t => { + t.start("hasPathParam()"); - /// reset the logger out of the values specific to this test - configLogger({}); + await t.test("has it", () => { + assert(hasPathParams(["some", ":path", ""])); + assert(hasPathParams(["path", ":params", ""])); + assert(hasPathParams(["api", "user", ":id", "info", ""])); }); - await t.test("the object can change previous fallback values", () => { - const contents = []; - const writerFn = x => contents.push(x); + await t.test("doesn't have it", () => { + assert(!hasPathParams([])); + assert(!hasPathParams([ "" ])); + assert(!hasPathParams(["some", "path", ""])); + assert(!hasPathParams(["s:o:m:e:", "p::ath:::"])); + assert(!hasPathParams([" :"])); + }); +}; - configLogger({ - level: "unseen", - a: "unseen", - }); - logit(writerFn, "overwritten by level", { a: "overwritten by o" }); +const test_addRoute = async t => { + t.start("addRoute()"); - configLogger({ - pid: "overwritten by loggerGlobals", - }); - logit(writerFn, "unseen", { level: "overwritten by o" }); + const fn1 = () => {}; - /// reset the logger out of the values specific to this test - configLogger({}); + await t.test("daily usage examples", () => { + assert.deepEqual( + addRoute({}, "GET", "/home", fn1), + { static: { GET: { home: { "": fn1 }}}}, + ); - assert.deepEqual(contents.map(JSON.parse), [ + assert.deepEqual( + addRoute({}, ["PUT", "POST", "PATCH"], "/api/user", fn1), { - ...loggerDefaults, - pid: process.pid, - level: "overwritten by level", - a: "overwritten by o", + static: { + PUT: { api: { user: { "": fn1 }}}, + POST: { api: { user: { "": fn1 }}}, + PATCH: { api: { user: { "": fn1 }}}, + }, }, + ); + + assert.deepEqual( + addRoute({}, "*", "/settings", fn1), { - ...loggerDefaults, - pid: "overwritten by loggerGlobals", - level: "overwritten by o", + static: { + GET: { settings: { "": fn1 }}, + HEAD: { settings: { "": fn1 }}, + POST: { settings: { "": fn1 }}, + PUT: { settings: { "": fn1 }}, + PATCH: { settings: { "": fn1 }}, + DELETE: { settings: { "": fn1 }}, + OPTIONS: { settings: { "": fn1 }}, + }, }, - ]); + ); - }); + assert.deepEqual( + addRoute({}, "GET", "/", fn1), + { static: { GET: { "": fn1 }}}, + ); - await t.test("we can't log unserializable things", () => { - const obj = { self: null }; - obj.self = obj; - assert.throws(() => logit(obj), TypeError); - }); -}; - -const test_makeLogger = async t => { - t.start("makeLogger()"); - - await t.test("various log levels", () => { - const contents = []; - const writerFn = x => contents.push(x); - const log = makeLogger(writerFn); + assert.deepEqual( + addRoute({}, "GET", "/api/user/:id", fn1), + { dynamic: { GET: { api: { user: { ":id": { "": fn1 }}}}}}, + ); - log.info ({ type: "expected" }); - log.warn ({ type: "worrysome" }); - log.error({ type: "bad" }); - assert.deepEqual(contents.map(JSON.parse), [ - { - ...loggerDefaults, - level: "INFO", - type: "expected", - }, - { - ...loggerDefaults, - level: "WARN", - type: "worrysome", - }, + assert.deepEqual( + addRoute({}, ["PUT", "PATCH"], "/api/org/:orgid/member/:memberid", fn1), { - ...loggerDefaults, - level: "ERROR", - type: "bad", + dynamic: { + PUT: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, + PATCH: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, + }, }, - ]); + ); }); - await t.test("debug only works when $DEBUG is set", () => { - const contents = []; - const writerFn = x => contents.push(x); - const log = makeLogger(writerFn); - - const previous = process.env.DEBUG; - delete process.env.DEBUG; + await t.test("bad method", () => { + assert.throws( + () => addRoute({}, "VERB", "/path", fn1), + assert.AssertionError, + ); + }); - log.debug({ x: "ignored" }); - process.env.DEBUG = "1"; - log.debug({ x: "seen" }); - delete process.env.DEBUG; - /// call the function that toggles - log.debug({ x: "ignored" }); + await t.test("empty methods array", () => { + assert.deepEqual(addRoute({}, [], "", fn1), {}); + }); - assert.deepEqual(contents.map(JSON.parse), [{ - ...loggerDefaults, - level: "DEBUG", - x: "seen", - }]); + await t.test("subpaths", () => { + const fn1 = () => {}; + const fn2 = () => {}; - process.env.DEBUG = previous; + const t1 = addRoute({}, "GET", "/home", fn1); + const t2 = addRoute(t1, "GET", "/home/details", fn2); + assert.deepEqual( + t2, + { + static: { + GET: { + home: { + "": fn1, + details: { + "": fn2, + }, + }, + }, + }, + }, + ); }); }; -const test_interceptorsFn = async t => { - const next = x => x; - - { - t.start("interceptorsFn().requestId()"); +const test_findStaticHandler = async t => { + t.start("findStaticHandler()"); - let i = 0; - const uuidFn = () => `${i++}`; + await t.test("multiple accesses to the same table", () => { + const fn1 = () => {}; + const fn2 = () => {}; + const fn3 = () => {}; - await t.test("we add an id to whatever we receive", () => { - assert.deepEqual( - interceptorsFn({uuidFn}).requestId({}, next), - { - id: "0", + const table = { + static: { + GET: { + api: { + home: { "": fn1 }, + settings: { "": fn2 }, + }, }, - ); - assert.deepEqual( - interceptorsFn({uuidFn}).requestId({ a: "existing data" }, next), - { - a: "existing data", - id: "1", + POST: { + api: { + settings: { "": fn3 }, + }, }, - ); - }); + }, + }; - await t.test(`we overwrite the "id" if it already exists`, async () => { - assert.deepEqual( - interceptorsFn({uuidFn}).requestId({ id: "before" }, next), - { - id: "2", - }, - ); - }); - }; + assert.deepEqual( + findStaticHandler(table, "GET", [ "api", "home", "" ]), + { handlerFn: fn1, params: {} }, + ); + assert.deepEqual( + findStaticHandler(table, "PUT", [ "api", "home", "" ]), + null, + ); - { - t.start("interceptorsFn().logged()"); + assert.deepEqual( + findStaticHandler(table, "GET", [ "api", "settings", "" ]), + { handlerFn: fn2, params: {} }, + ); + assert.deepEqual( + findStaticHandler(table, "POST", [ "api", "settings", "" ]), + { handlerFn: fn3, params: {} }, + ); + assert.deepEqual( + findStaticHandler(table, "PUT", [ "api", "settings", "" ]), + null, + ); - await t.test("we log before and after next()", async () => { - const contents = []; - const logger = { info: x => contents.push(x) }; - const status = 201; - const req = { - id: "an ID", - url: "a URL", - method: "a method", - }; + assert.deepEqual( + findStaticHandler(table, "GET", [ "api", "profile", "" ]), + null, + ); - assert.deepEqual( - await interceptorsFn({logger}).logged(req, _ => ({ status })), - { status }, - ); - assert.deepEqual( - contents, - [ - { ...req, type: "in-request" }, - { id: req.id, status, type: "in-response" }, - ], - ); - }); - }; + assert.deepEqual( + findStaticHandler({}, "GET", [ "api", "profile", "" ]), + null, + ); + }); +}; - { - t.start("interceptorsFn().contentType()"); +const test_firstParamMatch = async t => { + t.start("firstParamMatch()"); - await t.test("empty values", async () => { - assert.deepEqual( - await interceptorsFn().contentType({}, next), - { - body: "", - headers: { - "Content-Type": "application/json", - "Content-Length": 0, + const params = {}; + const fn1 = () => {}; + const fn2 = () => {}; + + await t.test("we BACKTRACK when searching down routes", () => { + const segments = [ "path", "split", "match", "" ]; + + const tree = { + path: { + split: { + MISMATCH: { + "": fn1, }, }, - ); - - assert.deepEqual( - await interceptorsFn().contentType({ body: "" }, next), - { - body: "", - headers: { - "Content-Type": "text/html", - "Content-Length": 0, + ":param": { + match: { + "": fn2, }, }, - ); - }); + }, + }; - await t.test("body values", async () => { - assert.deepEqual( - await interceptorsFn().contentType({ body: { a: 1 }}, next), - { - body: `{"a":1}`, - headers: { - "Content-Type": "application/json", - "Content-Length": 7, + assert.deepEqual( + firstParamMatch(tree, segments, params), + { handlerFn: fn2, params: { param: "split" }}, + ); + }); + + await t.test("ambiguous route prefers params at the end", () => { + const segments = [ "path", "param1", "param2", "" ]; + + const tree1 = { + path: { + ":shallower": { + param2: { + "": fn2, }, }, - ); + }, + }; - assert.deepEqual( - await interceptorsFn().contentType({ body: "
" }, next), - { - body: "
", - headers: { - "Content-Type": "text/html", - "Content-Length": 6, - }, + const tree2 = u.assocIn(tree1, [ "path", "param1", ":deeper", "" ], fn1); + + assert.deepEqual( + firstParamMatch(tree1, segments, params), + { handlerFn: fn2, params: { shallower: "param1" }}, + ); + + assert.deepEqual( + firstParamMatch(tree2, segments, params), + { handlerFn: fn1, params: { deeper: "param2" }}, + ); + }); + + await t.test("when 2 params are possible, we pick the first alphabetically", () => { + const segments = [ "user", "someId", "" ]; + + const tree1 = { + user: { + ":bbb": { + "": fn2, }, - ); - }); + }, + }; - await t.test("header values preference", async () => { - assert.deepEqual( - await interceptorsFn().contentType({ - body: "", - headers: { - "Content-Type": "we have preference", - "Content-Length": "and so do we", - }, - }, next), - { - body: "", - headers: { - "Content-Type": "we have preference", - "Content-Length": "and so do we", - }, + const tree2 = u.assocIn(tree1, ["user", ":aaa", ""], fn1); + + assert.deepEqual( + firstParamMatch(tree1, segments, params), + { handlerFn: fn2, params: { bbb: "someId" }}, + ); + + assert.deepEqual( + firstParamMatch(tree2, segments, params), + { handlerFn: fn1, params: { aaa: "someId" }}, + ); + }); + + await t.test(`we deal with segments that start with ":"`, () => { + const segments = [ "path", ":param", "" ]; + const tree = { + path: { + ":arg": { + "": fn1, }, - ); - }); + }, + }; - await t.test("headers get propagated", async () => { - assert.deepEqual( - await interceptorsFn().contentType({ - body: "", - headers: { - "My": "Header", + assert.deepEqual( + firstParamMatch(tree, segments, params), + { handlerFn: fn1, params: { arg: ":param" }}, + ); + }); +}; + +const test_findDynamicHandler = async t => { + t.start("findDynamicHandler()"); + + await t.test("daily usage cases", () => { + const fn1 = () => {}; + const fn2 = () => {}; + + const table = { + dynamic: { + GET: { + users: { + "by-id": { + ":id": { "": fn1 }, + }, }, - }, next), - { - body: "", - headers: { - "My": "Header", - "Content-Type": "text/html", - "Content-Length": 0, + }, + PUT: { + user: { + ":user-id": { + info: { "": fn2 }, + }, }, }, - ); - }); - }; + }, + }; - { - t.start("interceptorsFn().serverError()"); + assert.deepEqual( + findDynamicHandler(table, "GET", [ "users", "by-id", "123", "" ]), + { handlerFn: fn1, params: { "id": "123" }}, + ); - await t.test("no-op when no error occurs", async () => { - assert.deepEqual( - await interceptorsFn().serverError({ status: 1 }, next), - { status: 1 }, - ); - }); + assert.deepEqual( + findDynamicHandler({}, "GET", [ "users", "by-id", "123", "" ]), + null, + ); - await t.test(`an error is thrown if "status" is missing`, async () => { - const contents = []; - const logger = { error: x => contents.push(x) }; - assert.deepEqual( - await interceptorsFn({ logger }).serverError({ id: 123 }, next), - { - status: 500, - body: "Internal Server Error\n", - }, - ); - assert.deepEqual( - contents, - [{ - id: 123, - type: "server-error-interceptor", - message: `Missing "status"`, - }], - ); - }); + assert.deepEqual( + findDynamicHandler({ dynamic: { GET: { "": fn1 }}}, "GET", [ "users", "by-id", "123", "" ]), + null, + ); - await t.test("we turn a handler error into a 500 response", async () => { - const contents = []; - const logger = { error: x => contents.push(x) }; - assert.deepEqual( - await interceptorsFn({ logger }).serverError( - { id: "some ID" }, - _ => { throw new Error("My test error message"); }, - ), - { - status: 500, - body: "Internal Server Error\n", - }, - ); - assert.deepEqual( - contents, - [{ - id: "some ID", - type: "server-error-interceptor", - message: "My test error message", - }], - ); - }); - }; + assert.deepEqual( + findDynamicHandler(table, "PUT", [ "user", "deadbeef", "info", "" ]), + { handlerFn: fn2, params: { "user-id": "deadbeef" }}, + ); + }); }; -const test_chainInterceptors = async t => { - t.start("chainInterceptors()"); - - await t.test("empty values", () => { - assert.equal(chainInterceptors([])("req"), "req"); - }); +const test_findHandler = async t => { + t.start("findHandler()"); - await t.test("the order of interceptors matter", () => { - const a = []; + await t.test("mix of static and dynamic routes", () => { + const static1 = () => {}; + const static2 = () => {}; + const static3 = () => {}; + const dynamic1 = () => {}; + const dynamic2 = () => {}; + const dynamic3 = () => {}; + const dynamic4 = () => {}; - const i1 = (req, next) => { - a.push("i1"); - return next(req); - }; - const i2 = (req, next) => { - a.push("i2"); - return next(req); + const table = { + static: { + GET: { + user: { + "": static1, + }, + pages: { + "": static2, + home: { + "": static3, + }, + }, + }, + }, + dynamic: { + GET: { + user: { + ":id": { + "": dynamic1, + }, + }, + }, + PUT: { + user: { + ":id": { + "": dynamic2, + "info": { + "": dynamic3, + }, + "preferences": { + "": dynamic4, + }, + }, + }, + }, + }, }; - assert.equal(chainInterceptors([i1, i2])("req"), "req"); - assert.deepEqual(a, ["i1", "i2"]); - assert.equal(chainInterceptors([i2, i1])("req"), "req"); - assert.deepEqual(a, ["i1", "i2", "i2", "i1"]); - }); + assert.deepEqual( + findHandler(table, "GET", "/"), + null, + ); - await t.test("with ordering, interceptors implicitly depend on each other", () => { - const i1 = (req, next) => next({ ...req, id: 1 }); - const i2 = (req, next) => next({ ...req, id: req.id + 1 }); + assert.deepEqual( + findHandler(table, "GET", "/user/"), + { handlerFn: static1, params: {} }, + ); assert.deepEqual( - chainInterceptors([i1, i2])({}), - { id: 2 }, + findHandler(table, "GET", "/pages"), + { handlerFn: static2, params: {} }, + ); + assert.deepEqual( + findHandler(table, "GET", "/pages/home/"), + { handlerFn: static3, params: {} }, ); assert.deepEqual( - chainInterceptors([i2])({}), - { id: NaN }, + findHandler(table, "GET", "/user/some-id"), + { handlerFn: dynamic1, params: { id: "some-id" }}, + ); + assert.deepEqual( + findHandler(table, "GET", "/user/other-id/info"), + null, ); assert.deepEqual( - chainInterceptors([i2, i1])({}), - { id: 1 }, + findHandler(table, "PUT", "/user/other-id/info"), + { handlerFn: dynamic3, params: { id: "other-id" }}, + ); + assert.deepEqual( + findHandler(table, "PUT", "/user/another-id/preferences"), + { handlerFn: dynamic4, params: { id: "another-id" }}, + ); + assert.deepEqual( + findHandler(table, "POST", "/user/another-id/preferences"), + null, ); }); +}; - await t.test("we can chain async interceptors", async () => { - const i1 = async (req, next) => await next({ ...req, i1: true }); - const i2 = async (req, next) => await next({ ...req, i2: true }); +const test_extractQueryParams = async t => { + t.start("extractQueryParams()"); + + await t.test("empty values", () => { + assert.deepEqual(extractQueryParams(), {}); + assert.deepEqual(extractQueryParams(null), {}); + assert.deepEqual(extractQueryParams(undefined), {}); + }); + await t.test("we get a flat key-value strings", () => { assert.deepEqual( - await chainInterceptors([i1, i2])({}), + extractQueryParams("a[]=1&b=text&c=___"), { - i1: true, - i2: true, + "a[]": "1", + b: "text", + c: "___", }, ); }); -}; -const test_wrapHandler = async t => { - t.start("wrapHandler()"); + await t.test("duplicate values are suppressed, deterministically", () => { + assert.deepEqual(extractQueryParams("a=1&a=2&a=3"), { a: "3" }); + assert.deepEqual(extractQueryParams("a=1&b=2&a=3"), { a: "3", b: "2" }); + }); +}; - await t.test("noop when arr is empty", () => { - const fn = () => {}; - const wrappedFn1 = wrapHandler(fn, []); - assert.deepEqual(fn, wrappedFn1); +const test_make404Handler = async t => { + t.start("make404Handler"); - const wrappedFn2 = wrapHandler(fn, [ interceptors.requestId ]); - assert.notDeepEqual(fn, wrappedFn2); + await t.test("empty interceptors", () => { + assert.deepEqual(make404Handler([]), { + params: {}, + handlerFn: handle404, + }); }); +}; - await t.test("a handler with chained interceptors change its behaviour", async () => { - let i = 0; - const uuidFn = () => `${i++}`; +const test_handleRequest = async t => { + t.start("handleRequest()"); - const contents = []; - const logger = { info: x => contents.push(x) }; + const fn = req => req; - const interceptors = interceptorsFn({uuidFn, logger}); + await t.test("request without params", async () => { + const table = { + static: { + GET: { + "": fn, + }, + }, + }; - const fn = async _ => await { status: 1, body: { a: 1 }}; - const wrappedFn = wrapHandler(fn, [ - interceptors.requestId, - interceptors.logged, - interceptors.contentType, - ]); + assert.deepEqual( + await handleRequest(table, "GET", "/?q=1"), + { + params: { + path: {}, + query: { + q: "1", + }, + }, + method: "GET", + path: "/", + handler: fn, + }, + ); + }); - const req = { - url: "URL", - method: "METHOD", + await t.test("request with params", async () => { + const table = { + dynamic: { + PUT: { + api: { + user: { + ":userId": { + "": fn, + }, + }, + }, + }, + }, + interceptors: [], }; assert.deepEqual( - await fn(req), - { status: 1, body: { a: 1 }}, + await handleRequest(table, "PUT", "/api/user/2222"), + { + params: { + path: { + userId: "2222", + }, + query: {}, + }, + method: "PUT", + path: "/api/user/2222", + handler: fn, + }, ); - assert.deepEqual(contents, []); + }); + await t.test("missing route", async () => { assert.deepEqual( - await wrappedFn(req), + await handleRequest({ interceptors: [] }, "GET", "/"), { - status: 1, - body: `{"a":1}`, - headers: { - "Content-Type": "application/json", - "Content-Length": 7, - }, + status: 404, + body: "Not Found\n", }, ); + }); +}; + +const test_makeRequestListener = async t => { + t.start("makeRequestListener()"); + + await t.test("straightforward body execution", async () => { + const fn = _ => ({ + status: "test status", + body: "test body", + headers: "test headers", + }); + const routes = [[ "GET", "/route1", fn ]]; + const requestListener = makeRequestListener(buildRoutes(routes)); + const req = { + method: "GET", + url: "/route1", + }; + + const heads = []; + const bodies = []; + const res = { + writeHead: (status, headers) => heads.push({ status, headers }), + end: body => bodies.push(body), + }; + await requestListener(req, res); + + assert.deepEqual( + heads, + [{ + status: "test status", + headers: "test headers", + }], + ); assert.deepEqual( - contents, - [ - { - id: "0", - url: "URL", - method: "METHOD", - type: "in-request", - }, - { - id: "0", - status: 1, - type: "in-response", - }, - ], + bodies, + [ "test body" ], + ); + }); + + await t.test("we break if handleRequest() throws an error", async () => { + const fn = _ => { throw new Error("handler error"); }; + const routes = [[ "GET", "/route2", fn ]]; + const requestListener = makeRequestListener(buildRoutes(routes)); + const req = { + method: "GET", + url: "/route2", + }; + + const heads = []; + const bodies = []; + const res = { + writeHead: (status, headers) => heads.push({ status, headers }), + end: body => bodies.push(body), + }; + + await assert.rejects( + async () => await requestListener(req, res), + { message: "handler error" }, ); + + assert.deepEqual(heads, []); + assert.deepEqual(bodies, []); }); }; @@ -1734,6 +1734,12 @@ const test_buildServer = async t => { await runner.runTests([ + test_configLogger, + test_logit, + test_makeLogger, + test_interceptorsFn, + test_chainInterceptors, + test_wrapHandler, test_normalizeSegments, test_pathToSegments, test_hasPathParams, @@ -1746,12 +1752,6 @@ await runner.runTests([ test_make404Handler, test_handleRequest, test_makeRequestListener, - test_configLogger, - test_logit, - test_makeLogger, - test_interceptorsFn, - test_chainInterceptors, - test_wrapHandler, test_actionsFn, test_lineHandlerFn, test_buildRoutes, -- cgit v1.2.3