diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab96bb..cc9c466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## [0.14.0] - 2025-02-04 + +## Changed + +- multiple paths can now be registered on the same HTTP verb, and if the paths + are exactly the same, or if the "static" and the "parameter" portions of the + paths somehow overlap, then the handler is invoked **once for each registered + path**; for example if we register `@Get("/foo/:bar")` and `@Get("/foo/bar")` + (in that order) on the same handler function, then for every request to + `/foo/bar`, that handler is invoked **twice**, the first time having no + "param" at all, and the 2nd time having the param `bar` with the value + `"bar"`. This behavior follows TC39 decorator specs where all decorators to a + function are applied "from inside out" aka: the last declared decorator gets + applied first, and so on + +## Added + +- `ctx.state._oakRoutingCtrl_regPath` is available as a pointer to the + registered path that matches the URL request currently being handled; this is + helpful in rare situations where multiple overlapping paths are registered on + the same handler function, causing it to be invoked multiple times, and so we + may benefit from a mechanism to control when to write to the response body (as + this operation can only be done once) + ## [0.13.0] - 2025-02-01 ### Added diff --git a/deno.jsonc b/deno.jsonc index 644b9e9..7acd2c7 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,6 +1,6 @@ { "name": "@dklab/oak-routing-ctrl", - "version": "0.13.0", + "version": "0.14.0", "exports": { ".": "./mod.ts", "./mod": "./mod.ts" @@ -15,8 +15,8 @@ }, "tasks": { "pretty": "deno lint --ignore=docs && deno check . && deno fmt", - "test": "deno test -RE", - "check-doc": "deno check --doc .", + "test": "deno test -RE -I=jspm.dev,jsr.io,deno.land -N=0.0.0.0,127.0.0.1", + "check-doc": "deno check -I=jspm.dev,jsr.io,deno.land --doc .", "doc": "deno doc --html mod.ts" }, "imports": { @@ -26,7 +26,8 @@ "@std/io": "jsr:@std/io@^0.225.2", "@std/path": "jsr:@std/path@^1.0.8", "@std/testing": "jsr:@std/testing@^1.0.9", - "zod": "npm:zod@^3.24.1" + "zod": "npm:zod@^3.24.1", + "superoak": "https://deno.land/x/superoak@4.8.1/mod.ts" }, "fmt": { "useTabs": false, diff --git a/deno.lock b/deno.lock index 7e46502..e08fb65 100644 --- a/deno.lock +++ b/deno.lock @@ -133,6 +133,102 @@ "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==" } }, + "remote": { + "https://deno.land/std@0.213.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.213.0/assert/_diff.ts": "dcc63d94ca289aec80644030cf88ccbf7acaa6fbd7b0f22add93616b36593840", + "https://deno.land/std@0.213.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4", + "https://deno.land/std@0.213.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", + "https://deno.land/std@0.213.0/assert/assert_almost_equals.ts": "8b96b7385cc117668b0720115eb6ee73d04c9bcb2f5d2344d674918c9113688f", + "https://deno.land/std@0.213.0/assert/assert_array_includes.ts": "1688d76317fd45b7e93ef9e2765f112fdf2b7c9821016cdfb380b9445374aed1", + "https://deno.land/std@0.213.0/assert/assert_equals.ts": "4497c56fe7d2993b0d447926702802fc0becb44e319079e8eca39b482ee01b4e", + "https://deno.land/std@0.213.0/assert/assert_exists.ts": "24a7bf965e634f909242cd09fbaf38bde6b791128ece08e33ab08586a7cc55c9", + "https://deno.land/std@0.213.0/assert/assert_false.ts": "6f382568e5128c0f855e5f7dbda8624c1ed9af4fcc33ef4a9afeeedcdce99769", + "https://deno.land/std@0.213.0/assert/assert_greater.ts": "4945cf5729f1a38874d7e589e0fe5cc5cd5abe5573ca2ddca9d3791aa891856c", + "https://deno.land/std@0.213.0/assert/assert_greater_or_equal.ts": "573ed8823283b8d94b7443eb69a849a3c369a8eb9666b2d1db50c33763a5d219", + "https://deno.land/std@0.213.0/assert/assert_instance_of.ts": "72dc1faff1e248692d873c89382fa1579dd7b53b56d52f37f9874a75b11ba444", + "https://deno.land/std@0.213.0/assert/assert_is_error.ts": "6596f2b5ba89ba2fe9b074f75e9318cda97a2381e59d476812e30077fbdb6ed2", + "https://deno.land/std@0.213.0/assert/assert_less.ts": "2b4b3fe7910f65f7be52212f19c3977ecb8ba5b2d6d0a296c83cde42920bb005", + "https://deno.land/std@0.213.0/assert/assert_less_or_equal.ts": "b93d212fe669fbde959e35b3437ac9a4468f2e6b77377e7b6ea2cfdd825d38a0", + "https://deno.land/std@0.213.0/assert/assert_match.ts": "ec2d9680ed3e7b9746ec57ec923a17eef6d476202f339ad91d22277d7f1d16e1", + "https://deno.land/std@0.213.0/assert/assert_not_equals.ts": "f3edda73043bc2c9fae6cbfaa957d5c69bbe76f5291a5b0466ed132c8789df4c", + "https://deno.land/std@0.213.0/assert/assert_not_instance_of.ts": "8f720d92d83775c40b2542a8d76c60c2d4aeddaf8713c8d11df8984af2604931", + "https://deno.land/std@0.213.0/assert/assert_not_match.ts": "b4b7c77f146963e2b673c1ce4846473703409eb93f5ab0eb60f6e6f8aeffe39f", + "https://deno.land/std@0.213.0/assert/assert_not_strict_equals.ts": "da0b8ab60a45d5a9371088378e5313f624799470c3b54c76e8b8abeec40a77be", + "https://deno.land/std@0.213.0/assert/assert_object_match.ts": "e85e5eef62a56ce364c3afdd27978ccab979288a3e772e6855c270a7b118fa49", + "https://deno.land/std@0.213.0/assert/assert_rejects.ts": "e9e0c8d9c3e164c7ac962c37b3be50577c5a2010db107ed272c4c1afb1269f54", + "https://deno.land/std@0.213.0/assert/assert_strict_equals.ts": "0425a98f70badccb151644c902384c12771a93e65f8ff610244b8147b03a2366", + "https://deno.land/std@0.213.0/assert/assert_string_includes.ts": "dfb072a890167146f8e5bdd6fde887ce4657098e9f71f12716ef37f35fb6f4a7", + "https://deno.land/std@0.213.0/assert/assert_throws.ts": "edddd86b39606c342164b49ad88dd39a26e72a26655e07545d172f164b617fa7", + "https://deno.land/std@0.213.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", + "https://deno.land/std@0.213.0/assert/equal.ts": "fae5e8a52a11d3ac694bbe1a53e13a7969e3f60791262312e91a3e741ae519e2", + "https://deno.land/std@0.213.0/assert/fail.ts": "f310e51992bac8e54f5fd8e44d098638434b2edb802383690e0d7a9be1979f1c", + "https://deno.land/std@0.213.0/assert/mod.ts": "325df8c0683ad83a873b9691aa66b812d6275fc9fec0b2d180ac68a2c5efed3b", + "https://deno.land/std@0.213.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd", + "https://deno.land/std@0.213.0/assert/unreachable.ts": "38cfecb95d8b06906022d2f9474794fca4161a994f83354fd079cac9032b5145", + "https://deno.land/std@0.213.0/async/delay.ts": "8e1d18fe8b28ff95885e2bc54eccec1713f57f756053576d8228e6ca110793ad", + "https://deno.land/std@0.213.0/fmt/colors.ts": "aeaee795471b56fc62a3cb2e174ed33e91551b535f44677f6320336aabb54fbb", + "https://deno.land/std@0.213.0/http/server.ts": "6dce295abc169d0956ae00432441331b3425afad4d79e8b3475739be2f04d614", + "https://deno.land/std@0.213.0/http/status.ts": "ed61b4882af2514a81aefd3245e8df4c47b9a8e54929a903577643d2d1ebf514", + "https://deno.land/x/free_port@v1.2.0/mod.ts": "512646732aaea41fbfd1f210f3ae82660f38251777d189d290da331d0235a58e", + "https://deno.land/x/opine@2.3.4/src/methods.ts": "0481daecc6068d24e9e5391818baddf555ab803d39a465dcd259161f8bd8ee49", + "https://deno.land/x/opine@2.3.4/src/utils/mergeDescriptors.ts": "1fe498d4a1a8dcfd3570f9ca5e0647590d86d029b3c340bfcfdb57002851e41b", + "https://deno.land/x/superdeno@4.9.0/deps.ts": "acb88a5969aae0bcc82e053cb433cd183a10cc656495caa634b6e22a79156c4e", + "https://deno.land/x/superdeno@4.9.0/mod.ts": "fa91c501867a4302a4bc92d63cbf934fe5475ebb7bf58335338e001147263c87", + "https://deno.land/x/superdeno@4.9.0/src/close.ts": "8bd4ab602ebbb048d06697d0c48c30be5f78ab9ad673850965e8014d78cca7a8", + "https://deno.land/x/superdeno@4.9.0/src/superagent.ts": "8f60187f9278b154ef6bccf09a5ff7d45f81103ad0ce02d45518a6bbe63ce764", + "https://deno.land/x/superdeno@4.9.0/src/superdeno.ts": "2e2cd4898961ac7688f0c2a4b210bf560a338f6601bd231d74bf8a0956880311", + "https://deno.land/x/superdeno@4.9.0/src/test.ts": "1ab3c8c98160af8c3b30e097809d5c57bdd38d7b42c703f3f170f8452ad06c0f", + "https://deno.land/x/superdeno@4.9.0/src/types.ts": "9a48cdfafad3cea2212e1be29cdd2055e7d3d467437c9048012797323335abbb", + "https://deno.land/x/superdeno@4.9.0/src/utils.ts": "09a2e65cc5cc2a261b885f0e66ee84e96e978181975a0728636d20e48b67bd89", + "https://deno.land/x/superdeno@4.9.0/src/xhrSham.js": "6a35aed77bbe98324fe3b4d7430463b7cd6d3b43445ffdccd1fc327dc59dd3c6", + "https://deno.land/x/superdeno@4.9.0/version.ts": "4f8ba8f2a6b201e8e96818d3ab5c43aef1db751523c4b79160500664b72f87de", + "https://deno.land/x/superoak@4.8.1/deps.ts": "d716c0b36fdac6458f6984ce80f69d0b645c7e0ac8461024a40ead5ed3fcd08d", + "https://deno.land/x/superoak@4.8.1/mod.ts": "6d4ea8a5a48c9007f2e947934889c06259d3ebb5569515bcb0432036a22449cd", + "https://deno.land/x/superoak@4.8.1/src/superoak.ts": "9c08a3211c4d1f7bb89e88fc3f242536fce654c157aa6db52d3c24f033bb3d28", + "https://deno.land/x/superoak@4.8.1/version.ts": "b9b71ac3596ff0a6aaad2bf9df8a54fb2925abd526800879e261de9c693812bc", + "https://jspm.dev/npm:call-bind@1.0.5!cjs": "09f8399c727fc1e9d58fdafc0a729b45bf37b7ee0c11d9d0b39abe37ac42ccf5", + "https://jspm.dev/npm:call-bind@1.0.5/callBound!cjs": "55fa05e2b115eeaef9ff684e3df12de253e6644a40ad09b5722f3a9a8df8f645", + "https://jspm.dev/npm:call-bind@1/callBound!cjs": "9cf2ef160025d392767618c2f0cb72d32cf14caa3fbeb493c6df9bde9d7fca8d", + "https://jspm.dev/npm:component-emitter@1!cjs": "26c2994a5fcac1cd9156b00be96c5e2f006dd76338095a96006ac3a47c6c327d", + "https://jspm.dev/npm:component-emitter@1.3.0!cjs": "757cafefb0bf5639f3f90b2267a7d168e03631e731c2a79fca847b735695e196", + "https://jspm.dev/npm:define-data-property@1!cjs": "37b65cb06c826730306a5f766de69da37b96076c96ea11a47667e9429623f937", + "https://jspm.dev/npm:define-data-property@1.1.1!cjs": "4ac6fa4b9d7ba7ccc83ffa350c58112ee878a450a97375217f66508d5673c822", + "https://jspm.dev/npm:fast-safe-stringify@2!cjs": "d8dd0803af23f037ffb44c13e18333131af27ebe582f55fd498b6e3c8f6d5a9d", + "https://jspm.dev/npm:fast-safe-stringify@2.1.1!cjs": "8a14a2de8a07a719c74aa63ffa5ff635fc55e9ee5d5a79fbc2e087dc4aa1940e", + "https://jspm.dev/npm:function-bind@1!cjs": "73fbc50bf85e8a6ca150609e98c396301c1ae5a1603e50ce8c64e95f646e0ce0", + "https://jspm.dev/npm:function-bind@1.1.2!cjs": "bbb663bc4e50f400a8ca0de9e0bfdaaa7022695f86b2806a48dc1afc5b4195a8", + "https://jspm.dev/npm:function-bind@1.1.2/implementation!cjs": "ebdc0ec85854db19d7e21081b368891394f86e21c6d786273c327762cb46ea6a", + "https://jspm.dev/npm:get-intrinsic@1!cjs": "f6d9266edc586632e8f6d8d6c5ca28fb2c0d5ee9c9d9252df9aafd57eda9fcea", + "https://jspm.dev/npm:get-intrinsic@1.2.2!cjs": "723fcebc493a45d5af8ecb366020a6cc2ce9bd4759bad699c1172015cb193f65", + "https://jspm.dev/npm:gopd@1!cjs": "c220469947b77de2c5e4b115beda16397bf6133c5b873b8e24e85b902ee6dc82", + "https://jspm.dev/npm:gopd@1.0.1!cjs": "b38da4f4b49cfef31e3aa8d62fdd136cf0fe99a5df6c603a426f97248f3cf4ab", + "https://jspm.dev/npm:has-property-descriptors@1!cjs": "b1a828f75a22a5614b136dd3da1be98cc744a2cd6bfed9bd8c338a8d51a570d1", + "https://jspm.dev/npm:has-property-descriptors@1.0.1!cjs": "f8da64823507f597f3cb44a2f3576e350df72e1033ef5e7a5b30d771e81c0819", + "https://jspm.dev/npm:has-proto@1!cjs": "78a2914e5525d531426c5d69fd5aa23671ec359c6c527b9791327f60ad1b6682", + "https://jspm.dev/npm:has-proto@1.0.1!cjs": "0a9d605f1d310f859265780011d6343a7869cadf3a9e02fd6cc949c2924b528c", + "https://jspm.dev/npm:has-symbols@1!cjs": "48faf647d225b64fa235ccc3e5a848e72221b0230935e421066a5de39aa89c3a", + "https://jspm.dev/npm:has-symbols@1.0.3!cjs": "36965f84e4e0ea1abeddb6928d0719a2648e61ceb9825df185b40d05cddb64df", + "https://jspm.dev/npm:has-symbols@1.0.3/shams!cjs": "669673e1dc7691c0b397580760121d57f3a5c5101dd70be2e8dd7d2a044de2e9", + "https://jspm.dev/npm:hasown@2!cjs": "9a39af846b167cae93b7a40f1ba4c97255bb5b07a1481da853a29bb68d24e603", + "https://jspm.dev/npm:hasown@2.0.0!cjs": "f52fd2477e345530f759465a984023f23d8261c4a54970e619daf1da6a2e85f5", + "https://jspm.dev/npm:object-inspect@1!cjs": "dc197b471ed55ecf2eabeb8da9aaee277e97831e65192531432a4ec2346211d9", + "https://jspm.dev/npm:object-inspect@1.13.1!cjs": "cec116e5c2b7d6b75e178d2541d70475d716ad912e3d5599e5c2d97284a9cb3e", + "https://jspm.dev/npm:qs@6!cjs": "210de1e090ac836c2495c19dfea88fc74b49de1b308241f8c9490d27ab6e0195", + "https://jspm.dev/npm:qs@6.11.2!cjs": "5da52fff60f7b1a6b1c73cdea2d9fc5d5588fa6c551b2a0ea2a1ebbb2a5e559f", + "https://jspm.dev/npm:qs@6.11.2/_/e71c21de.js": "cfe49eb949fb7291803f1ed2f4c0a244b8fca3b6936f5082fc97581a0663e427", + "https://jspm.dev/npm:qs@6.11.2/lib/stringify!cjs": "35d39c5871af151efe9ccca8e4ebecbf0282f97287b5fad56ebac369f69c2581", + "https://jspm.dev/npm:set-function-length@1!cjs": "b4c766d874ba261ff0c11aa18a6bf4510ecf8da09a7219da83a62772e0bc1b41", + "https://jspm.dev/npm:set-function-length@1.1.1!cjs": "f52607660d1f50e19e645ab49e6a4adf27fa4ae909867ec9950e993c430e4ca1", + "https://jspm.dev/npm:side-channel@1!cjs": "a07dfe7165af0d7f916d089490c38839397abcd8b218e4566b270858c9a0ea04", + "https://jspm.dev/npm:side-channel@1.0.4!cjs": "db65b31b6f9e67d57f04e26d71eb5b376306f5a89ab46fae1278c3ffefb19663", + "https://jspm.dev/npm:superagent@6.1.0!cjs": "fcf1c0b17cb3ff899b59ae178fc4ab74ad3b592d7fa8b44b16394001758e3176", + "https://jspm.dev/npm:superagent@6.1.0/lib/agent-base!cjs": "cfe465965a55d80114d835143717413945d0bbc46355d0f7f8200a89902ed006", + "https://jspm.dev/npm:superagent@6.1.0/lib/is-object!cjs": "95f67ff49b42fd5e82114b9d54a4b3fe1ac98813aed7ceaf53d314983f59820a", + "https://jspm.dev/npm:superagent@6.1.0/lib/request-base!cjs": "e361c341aa75d7417c918bc8fb697d0ccf96101e039dd2f00e5e45c01c534caa", + "https://jspm.dev/npm:superagent@6.1.0/lib/response-base!cjs": "00ac549f34d73c2753caa798aa7eb781051179013e3418ff0868a1e1904a8913", + "https://jspm.dev/npm:superagent@6.1.0/lib/utils!cjs": "ea706523553983c96ef4ab2f191c61c53fb8b78ad8ff2472b48f1385e896c030", + "https://jspm.dev/superagent@6.1.0": "4b3082d71252c42abd3930d85d1f3c4b2e937e0fab2b5f1c9d19eac20dea89a9" + }, "workspace": { "dependencies": [ "jsr:@oak/oak@^17.1.4", diff --git a/mod.ts b/mod.ts index cd43c36..2a10158 100644 --- a/mod.ts +++ b/mod.ts @@ -18,3 +18,11 @@ export { z, type zInfer, } from "./src/utils/schema_utils.ts"; + +export type { + /** + * re-exporting from oak for convenient uses + * @ignore + */ + Context, +} from "@oak/oak"; diff --git a/src/Controller.ts b/src/Controller.ts index a9d0e87..3dcfc7f 100644 --- a/src/Controller.ts +++ b/src/Controller.ts @@ -26,8 +26,9 @@ type ClassDecorator = ( */ export const Controller = (pathPrefix: string = ""): ClassDecorator => (target, context): void => { + const ctrlClassName = target.name; debug( - `invoking ControllerDecorator for ${target.name} -`, + `invoking ControllerDecorator for ${ctrlClassName} -`, "runtime provides context:", context, ); @@ -35,10 +36,19 @@ export const Controller = for (const fnName of fnNames) { const pair = store.get(fnName); if (!pair) continue; - pair.forEach((path, verb, p) => { + const patchedPair = new Map(); + pair.forEach((verb, path) => { const fullPath = join(pathPrefix, path); - p.set(verb, fullPath); - patchOasPath(fnName, verb, fullPath); + patchedPair.set(fullPath, verb); + debug( + `[${ctrlClassName}] @Controller: patched [${verb}] ${path} to ${fullPath}`, + ); + // @TODO consider throwing if we discover 2 (or more) Controllers + // sharing the exact same set of path, fnName, and method + patchOasPath(ctrlClassName, fnName, verb, fullPath); }); + store.delete(fnName); + const fqFnName = `${ctrlClassName}.${fnName}`; + store.set(fqFnName, patchedPair); } }; diff --git a/src/Delete_test.ts b/src/Delete_test.ts index 20f4ab8..6e9452d 100644 --- a/src/Delete_test.ts +++ b/src/Delete_test.ts @@ -23,5 +23,5 @@ Deno.test("@Delete decorator", () => { assertSpyCall(Delete, 0, { args: ["/bar"] }); assertInstanceOf(Delete.calls[0].returned, Function); assertSpyCalls(Delete, 1); - assertEquals(store.get("doSomething")?.get("delete"), "/bar"); + assertEquals(store.get("doSomething")?.get("/bar"), "delete"); }); diff --git a/src/Get_test.ts b/src/Get_test.ts index 3d6f599..2afeb44 100644 --- a/src/Get_test.ts +++ b/src/Get_test.ts @@ -23,5 +23,5 @@ Deno.test("@Get decorator", () => { assertSpyCall(Get, 0, { args: ["/bar"] }); assertInstanceOf(Get.calls[0].returned, Function); assertSpyCalls(Get, 1); - assertEquals(store.get("doSomething")?.get("get"), "/bar"); + assertEquals(store.get("doSomething")?.get("/bar"), "get"); }); diff --git a/src/Head_test.ts b/src/Head_test.ts index 533af72..3bb3465 100644 --- a/src/Head_test.ts +++ b/src/Head_test.ts @@ -23,5 +23,5 @@ Deno.test("@Head decorator", () => { assertSpyCall(Head, 0, { args: ["/bar"] }); assertInstanceOf(Head.calls[0].returned, Function); assertSpyCalls(Head, 1); - assertEquals(store.get("doSomething")?.get("head"), "/bar"); + assertEquals(store.get("doSomething")?.get("/bar"), "head"); }); diff --git a/src/Options_test.ts b/src/Options_test.ts index 9a9aab1..387c094 100644 --- a/src/Options_test.ts +++ b/src/Options_test.ts @@ -23,5 +23,5 @@ Deno.test("@Options decorator", () => { assertSpyCall(Options, 0, { args: ["/bar"] }); assertInstanceOf(Options.calls[0].returned, Function); assertSpyCalls(Options, 1); - assertEquals(store.get("doSomething")?.get("options"), "/bar"); + assertEquals(store.get("doSomething")?.get("/bar"), "options"); }); diff --git a/src/Patch_test.ts b/src/Patch_test.ts index 7f7b086..e5a4ba9 100644 --- a/src/Patch_test.ts +++ b/src/Patch_test.ts @@ -23,5 +23,5 @@ Deno.test("@Patch decorator", () => { assertSpyCall(Patch, 0, { args: ["/bar"] }); assertInstanceOf(Patch.calls[0].returned, Function); assertSpyCalls(Patch, 1); - assertEquals(store.get("doSomething")?.get("patch"), "/bar"); + assertEquals(store.get("doSomething")?.get("/bar"), "patch"); }); diff --git a/src/Post_test.ts b/src/Post_test.ts index d3314c4..9338447 100644 --- a/src/Post_test.ts +++ b/src/Post_test.ts @@ -23,5 +23,5 @@ Deno.test("@Post decorator", () => { assertSpyCall(Post, 0, { args: ["/bar"] }); assertInstanceOf(Post.calls[0].returned, Function); assertSpyCalls(Post, 1); - assertEquals(store.get("doSomething")?.get("post"), "/bar"); + assertEquals(store.get("doSomething")?.get("/bar"), "post"); }); diff --git a/src/Put_test.ts b/src/Put_test.ts index 5a82f5b..e274ea1 100644 --- a/src/Put_test.ts +++ b/src/Put_test.ts @@ -27,7 +27,7 @@ Deno.test("@Put decorator", () => { assertSpyCall(Put, 0, { args: ["/bar"] }); assertInstanceOf(Put.calls[0].returned, Function); assertSpyCalls(Put, 1); - assertEquals(store.get("doSomething")?.get("put"), "/bar"); + assertEquals(store.get("doSomething")?.get("/bar"), "put"); // assertSpyCalls(spyLoggerDebug, 1); // assertSpyCalls(spyStoreRegister, 1); diff --git a/src/Store.ts b/src/Store.ts index 02b02ee..7c25b8c 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -7,7 +7,7 @@ export type SupportedVerb = | "head" | "options"; -export const store: Map> = new Map(); +export const store: Map> = new Map(); /** * internal library helper method, used to keep track of the declared @@ -21,8 +21,13 @@ export const register = ( const normalizedVerb: SupportedVerb = verb.toLowerCase() as SupportedVerb; const existingPair = store.get(fnName); if (existingPair) { - existingPair.set(normalizedVerb, path); + // @NOTE that we intentionally allow multiple paths registered on the + // same verb e.g. + // - @Get('/foo') AND @Get('/foo/bar') + // - @Get('/foo') AND @Get('/bar') + // - @Get('/foo/:bar') AND @Get('/foo/bar') + existingPair.set(path, normalizedVerb); } else { - store.set(fnName, new Map([[normalizedVerb, path]])); + store.set(fnName, new Map([[path, normalizedVerb]])); } }; diff --git a/src/Store_test.ts b/src/Store_test.ts index 4a32646..8930cfa 100644 --- a/src/Store_test.ts +++ b/src/Store_test.ts @@ -23,11 +23,11 @@ Deno.test("Store", () => { assertSpyCall(spyStoreGet, 3, { args: ["handlerFnName"] }); assertSpyCall(spyStoreGet, 4, { args: ["handlerFnName"] }); const finalMap = new Map([ - ["get", "/foo"], - ["post", "/bar"], - ["put", "/baz"], - ["delete", "/maz"], - ["patch", "/laz"], + ["/foo", "get"], + ["/bar", "post"], + ["/baz", "put"], + ["/maz", "delete"], + ["/laz", "patch"], ]); assertEquals(store.get("handlerFnName"), finalMap); diff --git a/src/oasStore.ts b/src/oasStore.ts index d802a66..a16b9b6 100644 --- a/src/oasStore.ts +++ b/src/oasStore.ts @@ -7,14 +7,17 @@ type TheRouteConfig = RouteConfig & { tags?: string[]; }; -// fnName|method|path => OasRouteConfig +const TMP_CTRL_NAME = "FILLED_LATER"; + +// ctrlName|fnName|method|path => OasRouteConfig export const oasStore: Map = new Map(); const getRouteId = ( + ctrlName: string, fnName: string, method: SupportedVerb, path: string, -) => `${fnName}|${method}|${path}`; +) => `${ctrlName}|${fnName}|${method}|${path}`; /** * input: `/some/:foo/and/:bar` @@ -43,7 +46,7 @@ export const updateOas = ( // because we don't want "documentation without consent" if (!specs) return; - const oasRouteIdentifier = getRouteId(fnName, method, path); + const oasRouteIdentifier = getRouteId(TMP_CTRL_NAME, fnName, method, path); const oasPath = getOasCompatPath(path); @@ -70,30 +73,43 @@ export const updateOas = ( tags: specs?.tags, }; - debug(`OpenApiSpec: recording for [${method}] ${path}`); + debug(`[${TMP_CTRL_NAME}] OpenApiSpec: recording for [${method}] ${path}`); oasStore.set(oasRouteIdentifier, updated); }; +/** + * patch the Open API Spec config, designed to be invoked in the context of the `@Controller` decorator + * @param ctrlName the name of the class being decorated with `@Controller` + * @param fnName the name of the function being decorated with e.g. `@Get`, `@Post`, and so on + */ export const patchOasPath = ( + ctrlName: string, fnName: string, method: SupportedVerb, path: string, ) => { - oasStore.forEach((storedSpecs, routeId) => { - const [storedFnName, storedMethod, storedPath] = routeId.split("|"); + for (const [routeId, storedSpecs] of oasStore) { + const [storedCtrlName, storedFnName, storedMethod, storedPath] = routeId + .split("|"); + if ( + storedCtrlName === TMP_CTRL_NAME && fnName === storedFnName && method === storedMethod && - path.length > storedPath.length && + path.length >= storedPath.length && path.endsWith(storedPath) ) { - debug(`OpenApiSpec: patching ${storedSpecs.path} to ${path}`); storedSpecs.path = getOasCompatPath(path); - // @TODO consider throwing if we discover 2 (or more) Controllers - // sharing the exact same set of path, fnName, and method + const newRouteId = getRouteId(ctrlName, fnName, method, path); + oasStore.delete(routeId); + oasStore.set(newRouteId, storedSpecs); + debug( + `[${ctrlName}] OpenApiSpec: patched [${method}] ${storedPath} to ${path}`, + ); + break; } - }); + } }; export const _internal = { diff --git a/src/oasStore_test.ts b/src/oasStore_test.ts index c612e1c..99dcb5a 100644 --- a/src/oasStore_test.ts +++ b/src/oasStore_test.ts @@ -12,6 +12,7 @@ Deno.test("no-op", () => { }); Deno.test("store entry creation & update", () => { + const ctrlName = "TestCtrl"; const fnName = "doSomething"; const method = "post"; const path = "/hello/:name"; @@ -40,7 +41,7 @@ Deno.test("store entry creation & update", () => { }, }); - const record = oasStore.get(getRouteId(fnName, method, path)); + const record = oasStore.get(getRouteId("FILLED_LATER", fnName, method, path)); assertEquals(record?.method, method); assertEquals(record?.path, getOasCompatPath(path)); assertInstanceOf( @@ -48,8 +49,10 @@ Deno.test("store entry creation & update", () => { ZodObject, ); - patchOasPath(fnName, method, patchedPath); - const patchedRecord = oasStore.get(getRouteId(fnName, method, path)); + patchOasPath(ctrlName, fnName, method, patchedPath); + const patchedRecord = oasStore.get( + getRouteId(ctrlName, fnName, method, patchedPath), + ); assertEquals(patchedRecord?.method, method); assertEquals(patchedRecord?.path, getOasCompatPath(patchedPath)); assertInstanceOf( diff --git a/src/useOakServer.ts b/src/useOakServer.ts index d267949..4606abb 100644 --- a/src/useOakServer.ts +++ b/src/useOakServer.ts @@ -19,12 +19,22 @@ export const useOakServer = ( const ctrlProps: string[] = Object.getOwnPropertyNames(Ctrl.prototype); for (const propName of ctrlProps) { if (propName === "constructor") continue; - const pair = store.get(propName); + const fqFnName = `${Ctrl.name}.${propName}`; + const pair = store.get(fqFnName); if (!pair) continue; - for (const [verb, path] of pair) { + for (const [path, verb] of pair) { oakRouter[verb]( path, async (ctx, next): Promise => { + // since 0.14.0, multiple paths can be registered on the + // same handler function, so it's useful to have a pointer + // to the currently registered path every time the handler is + // invoked per match + ctx.state._oakRoutingCtrl_regPath = path; + debug( + `handling literally-registered path ${path} with ${fqFnName}`, + ); + const handler = Object.getOwnPropertyDescriptor( Ctrl.prototype, propName, @@ -45,7 +55,7 @@ export const useOakServer = ( await next(); }, ); - debug(`mapping route [${verb}] ${path} -> ${propName}`); + debug(`mapping route [${verb}] ${path} -> ${fqFnName}`); } } } diff --git a/src/useOakServer_multipaths_test.ts b/src/useOakServer_multipaths_test.ts new file mode 100644 index 0000000..86af916 --- /dev/null +++ b/src/useOakServer_multipaths_test.ts @@ -0,0 +1,373 @@ +// superdeno currently doesn't work out-of-the-box with Deno 2 +// so we hack around it a little +(globalThis.window = globalThis as + & typeof globalThis + & Window + & Record)[Symbol("SHAM_SYMBOL")] = {}; + +import { superoak } from "superoak"; +import { Application } from "@oak/oak"; +import { + type Context, + Controller, + ControllerMethodArgs, + Get, + Patch, + Post, + Put, + useOakServer, +} from "../mod.ts"; +import { assertSpyCallArgs, assertSpyCalls, spy } from "@std/testing/mock"; +import { assertEquals } from "@std/assert"; + +const spyTemplate = { + handler1(..._: unknown[]) {}, + handler2(..._: unknown[]) {}, + handler3(..._: unknown[]) {}, + handler4(..._: unknown[]) {}, + catchAll(..._: unknown[]) {}, +}; + +@Controller("/test") +class TestController1 { + @Get("/handler1/:bar") + @Get("/handler1/bar") + @Post("/handler1/par") + @ControllerMethodArgs("param") + handler1(param: Record, ctx: Context) { + const method = ctx.request.method; + const reqPath = ctx.request.url.pathname; + const regPath = ctx.state._oakRoutingCtrl_regPath; + spyTemplate.handler1(method, reqPath, regPath, { ...param }); + return { method, reqPath, regPath, param }; + } + + @Get("/handler2/bar") + @Post("/handler2/par") + @Get("/handler2/:bar") + @ControllerMethodArgs("body", "param") + handler2( + body: Record, + param: Record, + ctx: Context, + ) { + const method = ctx.request.method; + const reqPath = ctx.request.url.pathname; + const regPath = ctx.state._oakRoutingCtrl_regPath; + spyTemplate.handler2(method, reqPath, regPath, { ...param }); + return { method, reqPath, regPath, param, body }; + } + + @Put("/handler3") + @Put("/handler3/:bar") + @ControllerMethodArgs("body", "param") + handler3( + body: Record, + param: Record, + ctx: Context, + ) { + const method = ctx.request.method; + const reqPath = ctx.request.url.pathname; + const regPath = ctx.state._oakRoutingCtrl_regPath; + spyTemplate.handler3(method, reqPath, regPath, { ...param }); + return { method, reqPath, regPath, param, body }; + } + + @Patch("/handler4") + @ControllerMethodArgs("body", "param") + handler4( + body: Record, + param: Record, + ctx: Context, + ) { + const method = ctx.request.method; + const reqPath = ctx.request.url.pathname; + const regPath = ctx.state._oakRoutingCtrl_regPath; + spyTemplate.handler4(method, reqPath, regPath, { ...param }); + return { method, reqPath, regPath, param, body }; + } +} + +@Controller("/test") +class TestController2 { + @Patch("/handler4/:bar") + @ControllerMethodArgs("body", "param") + handler4( + body: Record, + param: Record, + ctx: Context, + ) { + const method = ctx.request.method; + const reqPath = ctx.request.url.pathname; + const regPath = ctx.state._oakRoutingCtrl_regPath; + spyTemplate.handler4(method, reqPath, regPath, { ...param }); + return { method, reqPath, regPath, param, body }; + } +} + +const app = new Application(); +useOakServer(app, [TestController1, TestController2]); +app.use((ctx) => { + spyTemplate.catchAll( + "catch-all middleware invoked for", + ctx.request.url.pathname, + ); +}); + +/** + * Two similar paths registered on the same handler, the only difference + * being how the path parameter is declared & recognized; Scenario 1: + * - @Get("/foo/:bar") + * - @Get("/foo/bar") + */ +Deno.test("Overlapping paths, scenario 1", async () => { + const handler1Spy = spy(spyTemplate, "handler1"); + const catchAllSpy = spy(spyTemplate, "catchAll"); + + const req = await superoak(app); + const res = await req.get("/test/handler1/bar"); + + assertSpyCalls(handler1Spy, 2); + assertSpyCallArgs(handler1Spy, 0, [ + "GET", + "/test/handler1/bar", + "/test/handler1/bar", + {}, + ]); + assertSpyCallArgs(handler1Spy, 1, [ + "GET", + "/test/handler1/bar", + "/test/handler1/:bar", + { + bar: "bar", + }, + ]); + assertSpyCalls(catchAllSpy, 1); + + handler1Spy.restore(); + catchAllSpy.restore(); + + assertEquals(res.body, { + method: "GET", + reqPath: "/test/handler1/bar", + regPath: "/test/handler1/bar", + param: { bar: "bar" }, + }); +}); + +Deno.test("One @Post and Two @Get paths registered on handler1", async () => { + const handler1Spy = spy(spyTemplate, "handler1"); + const catchAllSpy = spy(spyTemplate, "catchAll"); + + const req = await superoak(app); + const res = await req.post("/test/handler1/par"); + + assertSpyCalls(handler1Spy, 1); + assertSpyCallArgs(handler1Spy, 0, [ + "POST", + "/test/handler1/par", + "/test/handler1/par", + {}, + ]); + assertSpyCalls(catchAllSpy, 1); + + handler1Spy.restore(); + catchAllSpy.restore(); + + assertEquals(res.body, { + method: "POST", + reqPath: "/test/handler1/par", + regPath: "/test/handler1/par", + param: {}, + }); +}); + +/** + * Two similar paths registered on the same handler, the only difference + * being how the path parameter is declared & recognized; Scenario 2: + * - @Get("/foo/bar") + * - @Get("/foo/:bar") + */ +Deno.test("Overlapping paths, scenario 2", async () => { + const handler2Spy = spy(spyTemplate, "handler2"); + const catchAllSpy = spy(spyTemplate, "catchAll"); + + const req = await superoak(app); + const res = await req.get("/test/handler2/bar"); + + assertSpyCalls(handler2Spy, 2); + assertSpyCallArgs(handler2Spy, 0, [ + "GET", + "/test/handler2/bar", + "/test/handler2/:bar", + { + bar: "bar", + }, + ]); + assertSpyCallArgs(handler2Spy, 1, [ + "GET", + "/test/handler2/bar", + "/test/handler2/bar", + { + bar: "bar", + }, + ]); + assertSpyCalls(catchAllSpy, 1); + + handler2Spy.restore(); + catchAllSpy.restore(); + + assertEquals(res.body, { + method: "GET", + reqPath: "/test/handler2/bar", + regPath: "/test/handler2/:bar", + param: { bar: "bar" }, + body: {}, + }); +}); + +Deno.test("One @Post and Two @Get paths registered on handler2", async () => { + const handler2Spy = spy(spyTemplate, "handler2"); + const catchAllSpy = spy(spyTemplate, "catchAll"); + + const req = await superoak(app); + const res = await req.post("/test/handler2/par").send({ + universe: 42, + }); + + assertSpyCalls(handler2Spy, 1); + assertSpyCallArgs(handler2Spy, 0, [ + "POST", + "/test/handler2/par", + "/test/handler2/par", + {}, + ]); + assertSpyCalls(catchAllSpy, 1); + + handler2Spy.restore(); + catchAllSpy.restore(); + + assertEquals(res.body, { + method: "POST", + reqPath: "/test/handler2/par", + regPath: "/test/handler2/par", + param: {}, + body: { universe: 42 }, + }); +}); + +Deno.test("Similar paths that do not actually overlap - path 1", async () => { + const handler3Spy = spy(spyTemplate, "handler3"); + const catchAllSpy = spy(spyTemplate, "catchAll"); + + const req = await superoak(app); + const res = await req.put("/test/handler3/").send({ + charlie: false, + }); + + assertSpyCalls(handler3Spy, 1); + assertSpyCallArgs(handler3Spy, 0, [ + "PUT", + "/test/handler3/", + "/test/handler3", + {}, + ]); + assertSpyCalls(catchAllSpy, 1); + + handler3Spy.restore(); + catchAllSpy.restore(); + + assertEquals(res.body, { + method: "PUT", + reqPath: "/test/handler3/", + regPath: "/test/handler3", + param: {}, + body: { charlie: false }, + }); +}); + +Deno.test("Similar paths that do not actually overlap - path 2", async () => { + const handler3Spy = spy(spyTemplate, "handler3"); + const catchAllSpy = spy(spyTemplate, "catchAll"); + + const req = await superoak(app); + const res = await req.put("/test/handler3/charlie").send({ + charlie: true, + }); + + assertSpyCalls(handler3Spy, 1); + assertSpyCallArgs(handler3Spy, 0, [ + "PUT", + "/test/handler3/charlie", + "/test/handler3/:bar", + { bar: "charlie" }, + ]); + assertSpyCalls(catchAllSpy, 1); + + handler3Spy.restore(); + catchAllSpy.restore(); + + assertEquals(res.body, { + method: "PUT", + reqPath: "/test/handler3/charlie", + regPath: "/test/handler3/:bar", + param: { bar: "charlie" }, + body: { charlie: true }, + }); +}); + +Deno.test("[PATCH] /test/handler4", async () => { + const handler4Spy = spy(spyTemplate, "handler4"); + const catchAllSpy = spy(spyTemplate, "catchAll"); + + const req = await superoak(app); + const res = await req.patch("/test/handler4"); + + assertSpyCalls(handler4Spy, 1); + assertSpyCallArgs(handler4Spy, 0, [ + "PATCH", + "/test/handler4", + "/test/handler4", + {}, + ]); + assertSpyCalls(catchAllSpy, 1); + + handler4Spy.restore(); + catchAllSpy.restore(); + + assertEquals(res.body, { + method: "PATCH", + reqPath: "/test/handler4", + regPath: "/test/handler4", + param: {}, + body: {}, + }); +}); + +Deno.test("[PATCH] /test/handler4/:bar", async () => { + const handler4Spy = spy(spyTemplate, "handler4"); + const catchAllSpy = spy(spyTemplate, "catchAll"); + + const req = await superoak(app); + const res = await req.patch("/test/handler4/bob").send({ bob: true }); + + assertSpyCalls(handler4Spy, 1); + assertSpyCallArgs(handler4Spy, 0, [ + "PATCH", + "/test/handler4/bob", + "/test/handler4/:bar", + { bar: "bob" }, + ]); + assertSpyCalls(catchAllSpy, 1); + + handler4Spy.restore(); + catchAllSpy.restore(); + + assertEquals(res.body, { + method: "PATCH", + reqPath: "/test/handler4/bob", + regPath: "/test/handler4/:bar", + param: { bar: "bob" }, + body: { bob: true }, + }); +});