From b8bda0ac05b15d6dbb646c4f39b519b1eb090a34 Mon Sep 17 00:00:00 2001 From: Anton Bessonov Date: Fri, 17 Jun 2022 20:21:58 +0200 Subject: [PATCH] allow nested routes - EndpointMatcher accepts now multiple methods --- .npmignore | 6 +- .nvmrc | 2 +- README.md | 226 ++++++++---- jest.config.js | 5 +- package.json | 32 +- sponsors/superlative.gmbh.png | Bin 0 -> 51154 bytes src/Router.ts | 54 +++ src/__tests__/Router.test.ts | 324 ++++++++++++++++++ src/__tests__/router.test.ts | 122 ------- src/examples/micro.ts | 21 +- src/examples/node.ts | 29 +- src/index.ts | 18 +- src/matchers/AndMatcher.ts | 28 +- src/matchers/BooleanMatcher.ts | 24 ++ src/matchers/EndpointMatcher.ts | 48 ++- src/matchers/ExactQueryMatcher.ts | 115 ++++--- src/matchers/ExactUrlPathnameMatcher.ts | 33 +- src/matchers/MatchResult.ts | 15 +- src/matchers/Matcher.ts | 13 +- src/matchers/MethodMatcher.ts | 21 +- src/matchers/OrMatcher.ts | 24 +- src/matchers/RegExpUrlMatcher.ts | 35 +- src/matchers/__tests__/AndMatcher.test.ts | 38 +- src/matchers/__tests__/BooleanMatcher.test.ts | 20 ++ .../__tests__/EndpointMatcher.test.ts | 58 ++++ .../__tests__/ExactQueryMatcher.test.ts | 108 ++++++ .../ExactQueryMatcherMatcher.test.ts | 86 ----- .../__tests__/ExactUrlPathnameMatcher.test.ts | 54 ++- src/matchers/__tests__/MethodMatcher.test.ts | 40 ++- src/matchers/__tests__/OrMatcher.test.ts | 82 +++-- .../__tests__/RegExpUrlMatcher.test.ts | 44 ++- src/matchers/index.ts | 32 +- src/middlewares/CorsMiddleware.ts | 58 +++- src/middlewares/MiddlewareData.ts | 11 + .../__tests__/CorsMiddleware.test.ts | 66 +++- src/middlewares/index.ts | 5 + src/node/NodeHttpRouter.ts | 40 +++ src/node/ServerRequest.ts | 18 + src/node/__tests__/NodeHttpRouter.test.ts | 17 + src/router.ts | 50 --- 40 files changed, 1368 insertions(+), 654 deletions(-) create mode 100644 sponsors/superlative.gmbh.png create mode 100644 src/Router.ts create mode 100644 src/__tests__/Router.test.ts delete mode 100644 src/__tests__/router.test.ts create mode 100644 src/matchers/BooleanMatcher.ts create mode 100644 src/matchers/__tests__/BooleanMatcher.test.ts create mode 100644 src/matchers/__tests__/EndpointMatcher.test.ts create mode 100644 src/matchers/__tests__/ExactQueryMatcher.test.ts delete mode 100644 src/matchers/__tests__/ExactQueryMatcherMatcher.test.ts create mode 100644 src/middlewares/MiddlewareData.ts create mode 100644 src/node/NodeHttpRouter.ts create mode 100644 src/node/ServerRequest.ts create mode 100644 src/node/__tests__/NodeHttpRouter.test.ts delete mode 100644 src/router.ts diff --git a/.npmignore b/.npmignore index 6467913..49757b0 100644 --- a/.npmignore +++ b/.npmignore @@ -1,12 +1,12 @@ coverage/ .eslintignore .eslintrc.js +.git +.github .npmignore .nvmrc -.travis/ -.travis.yml jest.config.js tsconfig.json src/ **/__tests__/** -**/examples/** \ No newline at end of file +**/examples/** diff --git a/.nvmrc b/.nvmrc index bf79505..1b575ab 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.14.0 +v18.4.0 diff --git a/README.md b/README.md index bf6eb55..4d17297 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -Router for Node.js, micro and others -==================================== +Router for Node.js, micro and other use cases +============================================= [![Project is](https://img.shields.io/badge/Project%20is-fantastic-ff69b4.svg)](https://github.com/Bessonov/node-http-router) [![Build Status](https://api.travis-ci.org/Bessonov/node-http-router.svg?branch=master)](https://travis-ci.org/Bessonov/node-http-router) @@ -18,7 +18,18 @@ This router is intended to be used with native node http interface. Features: - Convenient [`EndpointMatcher`](#endpointmatcher) - `AndMatcher` and `OrMatcher` - Can be used with [path-to-regexp](https://github.com/pillarjs/path-to-regexp). -- Work with another servers? Tell it me! +- Work with other servers? Tell it me! + +From 2.0.0 the router isn't tied to node or even http anymore! Although the primary use case is still node's request routing, you can use it for use cases like event processing. + +## Sponsoring + +Contact me if you want to become a sponsor or need paid support. + +Sponsored by Superlative GmbH + +![Superlative GmbH](./sponsors/superlative.gmbh.png) + ## Installation @@ -30,19 +41,20 @@ yarn add @bessonovs/node-http-router pnpm add @bessonovs/node-http-router ``` +## Changelog + +See [releases](https://github.com/Bessonov/node-http-router/releases). + ## Documentation and examples ### Binding -The router works with native http interfaces like `IncomingMessage` and `ServerResponse`. Therefore it should be possible to use it with most of existing servers. +The router doesn't depends on the native http interfaces like `IncomingMessage` and `ServerResponse`. Therefore, you can use it for everything. Below are some use cases. #### Usage with native node http server ```typescript -const router = new Router((req, res) => { - res.statusCode = 404 - res.end() -}) +const router = new NodeHttpRouter() const server = http.createServer(router.serve).listen(8080, 'localhost') @@ -50,17 +62,22 @@ router.addRoute({ matcher: new ExactUrlPathnameMatcher(['/hello']), handler: () => 'Hello kitty!', }) + +// 404 handler +router.addRoute({ + matcher: new BooleanMatcher(true), + handler: ({ data: { res } }) => send(res, 404) +}) ``` See [full example](src/examples/node.ts) and [native node http server](https://nodejs.org/api/http.html#http_class_http_server) documentation. -#### Usage with micro +#### Usage with micro server [micro](https://github.com/vercel/micro) is a very lightweight layer around the native node http server with some convenience methods. ```typescript -// specify default handler -const router = new Router((req, res) => send(res, 404)) +const router = new NodeHttpRouter() http.createServer(micro(router.serve)).listen(8080, 'localhost') @@ -68,10 +85,66 @@ router.addRoute({ matcher: new ExactUrlPathnameMatcher(['/hello']), handler: () => 'Hello kitty!', }) + +// 404 handler +router.addRoute({ + matcher: new BooleanMatcher(true), + handler: ({ data: { res } }) => send(res, 404) +}) ``` See [full example](src/examples/micro.ts). +#### Usage for event processing or generic use case + +```typescript +// Custom type +type MyEvent = { + name: 'test1', +} | { + name: 'test2', +} | { + name: 'invalid', +} + +const eventRouter = new Router() + +eventRouter.addRoute({ + // define matchers for event processing + matcher: ({ + match(params: MyEvent): MatchResult { + const result = /^test(?\d+)$/.exec(params.name) + if (result?.groups?.num) { + return { + matched: true, + result: parseInt(result.groups.num) + } + } + return { + matched: false, + } + }, + }), + // define event handler for matched events + handler({ data, match: { result } }) { + return `the event ${data.name} has number ${result}` + } +}) + +// add default handler +eventRouter.addRoute({ + matcher: new BooleanMatcher(true), + handler({ data }) { + return `the event '${data.name}' is unknown` + } +}) + +// execute and get processing result +const result = eventRouter.exec({ + name: 'test1', +}) +``` + ### Matchers In the core, matchers are responsible to decide if particular handler should be called or not. There is no magic: matchers are iterated on every request and first positive "match" calls defined handler. @@ -84,7 +157,7 @@ Method matcher is the simplest matcher and matches any of the passed http method router.addRoute({ matcher: new MethodMatcher(['OPTIONS', 'POST']), // method is either OPTIONS or POST - handler: (req, res, { method }) => `Method: ${method}`, + handler: ({ match: { result: { method } } }) => `Method: ${method}`, }) ``` @@ -96,7 +169,7 @@ Matches given pathnames (but ignores query parameters): router.addRoute({ matcher: new ExactUrlPathnameMatcher(['/v1/graphql', '/v2/graphql']), // pathname is /v1/graphql or /v2/graphql - handler: (req, res, { pathname }) => `Path is ${pathname}`, + handler: ({ match: { result: { pathname } } }) => `Path is ${pathname}`, }) ``` @@ -115,11 +188,11 @@ router.addRoute({ // undefined defines optional parameters. They // aren't used for matching, but available as type isOptional: undefined, - // a string defines expected parameter name and value - mustExact: 'exactValue', + // array of strings defines expected parameter name and value + mustExact: ['exactValue'] as const, }), // query parameter isOptional has type string | undefined - handler: (req, res, { query }) => query.isOptional, + handler: ({ match: { result: { query } } }) => query.isOptional, }) ``` @@ -130,10 +203,10 @@ Allows powerful expressions: ```typescript router.addRoute({ matcher: new RegExpUrlMatcher<{ userId: string }>([/^\/group\/(?[^/]+)$/]), - handler: (req, res, { match }) => `User id is: ${match.groups.userId}`, + handler: ({ match: { result: { match } } }) => `User id is: ${match.groups.userId}`, }) ``` -Ordinal parameters can be used too. Be aware that regular expression must match the whole base url (also with query parameters) and not only `pathname`. +Be aware that regular expression must match the whole base url (also with query parameters) and not only `pathname`. Ordinal parameters can be used too. #### EndpointMatcher ([source](./src/matchers/EndpointMatcher.ts)) @@ -142,13 +215,13 @@ EndpointMatcher is a combination of Method and RegExpUrl matcher for convenient ```typescript router.addRoute({ matcher: new EndpointMatcher<{ userId: string }>('GET', /^\/group\/(?[^/]+)$/), - handler: (req, res, { match }) => `Group id is: ${match.groups.userId}`, + handler: ({ match: { result: { method, match } } }) => `Group id ${match.groups.userId} matched with ${method} method`, }) ``` ### Middlewares -**This section is highly experimental!** +**This whole section is highly experimental!** Currently, there is no built-in API for middlewares. It seems like there is no aproach to provide centralized and typesafe way for middlewares. And it need some conceptual work, before it will be added. Open an issue, if you have a great idea! @@ -157,8 +230,17 @@ Currently, there is no built-in API for middlewares. It seems like there is no a Example of CorsMiddleware usage: ```typescript -const corsMiddleware = CorsMiddleware({ - origins: corsOrigins, +const cors = CorsMiddleware(async () => { + return { + origins: ['https://my-cool.site'], + } +}) + +const router = new NodeHttpRouter() +router.addRoute({ + matcher: new MethodMatcher(['OPTIONS', 'POST']), + // use it + handler: cors(({ match: { result: { method } } }) => `Method: ${method}.`), }) ``` @@ -179,22 +261,42 @@ interface CorsMiddlewareOptions { } ``` -See source file for defaults. +See ([source](./src/middlewares/CorsMiddleware.ts)) file for defaults. #### Create own middleware ```typescript -// example of a generic middleware, not a cors middleware! +// example of a generic middleware, not a real cors middleware! function CorsMiddleware(origin: string) { - return function corsWrapper>( - wrappedHandler: Handler, + return function corsWrapper< + T extends MatchResult, + D extends { + // add requirements of middleware + req: ServerRequest, + res: ServerResponse, + } + >( + wrappedHandler: Handler, ): Handler { - return async function corsHandler(req, res, ...args) { + return async function corsHandler(params) { + const { req, res } = params.data + const isCors = !!req.headers.origin // -> executed before handler // it's even possible to skip the handler at all - const result = await wrappedHandler(req, res, ...args) + const result = await wrappedHandler({ + ...params, + data: { + ...params.data, + isCors, + } + }) // -> executed after handler, like: - res.setHeader('Access-Control-Allow-Origin', origin) + if (isCors) { + res.setHeader('Access-Control-Allow-Origin', origin) + } return result } } @@ -203,64 +305,52 @@ function CorsMiddleware(origin: string) { // create a configured instance of middleware const cors = CorsMiddleware('http://0.0.0.0:8080') +const router = new NodeHttpRouter() + router.addRoute({ matcher: new MethodMatcher(['OPTIONS', 'POST']), // use it - handler: cors((req, res, { method }) => `Method: ${method}`), -}) -``` - -Apropos typesafety. You can modify types in middleware: - -```typescript -function ValueMiddleware(myValue: string) { - return function valueWrapper( - handler: Handler & { - // add additional type - myValue: string - }>, - ): Handler { - return function valueHandler(req, res, match) { - return handler(req, res, { - ...match, - // add additional property - myValue, - }) - } - } -} - -const value = ValueMiddleware('world') - -router.addRoute({ - matcher: new MethodMatcher(['GET']), - handler: value((req, res, { myValue }) => `Hello ${myValue}`), + handler: cors(({ match: { result: { method } }, data: { isCors } }) => `Method: ${method}. Cors: ${isCors}`), }) ``` -#### DRY approach +#### Combine middlewares Of course you can create a `middlewares` wrapper and put all middlewares inside it: ```typescript -type Middleware) => Handler> = Parameters[0]>[2] - -function middlewares( - handler: Handler - & Middleware - & Middleware>, -): Handler { +function middlewares< + T extends MatchResultAny, + D extends { + req: ServerRequest + res: ServerResponse + } +>( + handler: Handler + & MiddlewareData + >, +): Handler { return function middlewaresHandler(...args) { - return cors(session(handler))(...args) + return corsMiddleware(sessionMiddleware(handler))(...args) } } router.addRoute({ matcher, // use it - handler: middlewares((req, res, { csrftoken }) => `Token: ${csrftoken}`), + handler: middlewares(({ data: { csrftoken } }) => `Token: ${csrftoken}`), }) ``` +### Nested routers + +There are some use cases for nested routers: +- Add features like multi-tenancy +- Implement modularity +- Apply middlewares globally + +See [example](./src/__tests__/Router.test.ts#216). + ## License MIT License diff --git a/jest.config.js b/jest.config.js index 9abbdb4..5d8a9a9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,9 +2,8 @@ const { defaults } = require('jest-config') module.exports = { roots: ['/src'], - transform: { - '^.+\\.tsx?$': 'ts-jest', - }, + preset: 'ts-jest', + testEnvironment: 'node', testRegex: '(/__tests__/.*|(\\.|/)test)\\.tsx?$', testPathIgnorePatterns: ['/node_modules/'], moduleFileExtensions: [...defaults.moduleFileExtensions, 'ts', 'tsx'], diff --git a/package.json b/package.json index 8ca9bf1..ad6fdff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bessonovs/node-http-router", - "version": "1.0.0", + "version": "2.0.0", "description": "Extensible http router for node and micro", "keywords": [ "router", @@ -34,28 +34,36 @@ "update": "pnpm update --interactive --recursive --latest" }, "dependencies": { - "urlite": "3.0.0" + "urlite": "3.0.1" }, "devDependencies": { "@bessonovs/eslint-config": "0.0.7", "@types/express": "4.17.13", - "@types/jest": "27.4.1", - "@types/node": "16.11.7", - "@typescript-eslint/eslint-plugin": "5.20.0", - "@typescript-eslint/parser": "5.20.0", - "eslint": "8.13.0", + "@types/jest": "28.1.3", + "@types/node": "18.0.0", + "@typescript-eslint/eslint-plugin": "5.29.0", + "@typescript-eslint/parser": "5.29.0", + "eslint": "8.18.0", "eslint-config-airbnb": "19.0.4", "eslint-plugin-import": "2.26.0", "eslint-plugin-jsx-a11y": "6.5.1", - "eslint-plugin-react": "7.29.4", - "jest": "27.5.1", + "eslint-plugin-react": "7.30.0", + "jest": "28.1.1", "micro": "9.3.5-canary.3", "node-mocks-http": "1.11.0", - "path-to-regexp": "6.2.0", - "ts-jest": "27.1.4", - "typescript": "4.6.3" + "path-to-regexp": "6.2.1", + "ts-jest": "28.0.5", + "ts-toolbelt": "9.6.0", + "typescript": "4.7.4" }, "publishConfig": { "access": "public" + }, + "pnpm": { + "peerDependencyRules": { + "ignoreMissing": [ + "eslint-plugin-react-hooks" + ] + } } } diff --git a/sponsors/superlative.gmbh.png b/sponsors/superlative.gmbh.png new file mode 100644 index 0000000000000000000000000000000000000000..1b24e46024f39fff6a51cfed955bf1972141c240 GIT binary patch literal 51154 zcmYIv2T)VZ_ck5rMM@9?Ql(cF2u-O{q<5q@1*C=&K%^)&1gV0MAYFP#krt#E6M|Aj z49$QPDWV_>-;M9@Ki|$c&RosCyJz>D^PJ~7n__CL$3T09mV|_aLH~}nISC0>9SI5P z!%I}alVCkVb>J_Wz&my!BqUO2#6P5ek_-sILoTR}Ez}~w3mWbk>`4+H9xnaB-#5hF zHPBN!AlSQbPxT53$yE}4?c0_SMLVBi(Mwhf7Z-Ck^c~(#QA+0BefH>!n18aU_{&wY zzx?Co_vQ7|a=9(F%~Ax!rJb26UX=Tl;vary=IrhXps=JE?@bU$yH2_AyrcZ?*WZ6L z9~gbwDLB37E%KePhD##bEeco2Hc>AQL56^}wEr99!-N)gr0Nx9S>XYT@7HgeAOYe~ z+bAVZ{%=^eGkWRj*$#+v=;<-D0K=}3*81On5)jz%&}XkhwHe+|W8^13YPrc?_kS-Q z%NDnN5T2WS=UsP-eZX{4eKOp*9Y^}Vf3C?TaFOq@Bg>K;%78Dy!{^@aM`M$#*IxA+i6x;!gXa zDg!!xlSc;$m3=S0GP8MAK%netFabW&uPtTZ)xb2u>08C>?7U=4_5W`ZZ!c&p??*;c zZ6R`}5$*?TRCsD=`>{loJsz5O$gL4~JfWJGe8;ZZMfiW;pc+aU0GEaQco`2E64=U) zYa@5jDlOprUltQG(H!myU>oi7CzegD#EoHL5lgcF$zMU(`)7@f^^o~Q<8yjHcD9wP zl|i~w^8x~bmCKa^g7^^yq@3T=R+?cE5Vx6&AiVk(1I9A=BZ)xv-&m%6W!)RKtwChWOV}0T$S;VB=z51}^ zcENieTjP`OaHVe~p&4;Dl%cTRJdH;j@bIa2fnW`nGQ4d< zJM9VeuoU`>tiKx-lkMfFn=bz>U6xLO(av9o=;4%S--+S)wY6(9=R%Z*v85qD_OD3f zyV^8KLXwk5O<%bPlSP14vepvNx1r!vWqu`du^zG|`acYBQd0gbU|e+qMTk?(tofSm zXAoCa8UhVLv{=Wm0v=bsc%mwT>(?r8y;YKS#P7SIh?CRSPSlhl_566KdPbT=&PUZj zhp;ftj{C*_xL@8n0>vHJKpE4B8vZv)&#AG=^1S5PU|%Q&@PUv0LFLwUWU|YQfIr;v zH0w9eWiOtvh6Y|A#-TfKuy{B%_s(**qt}FSm zZW1#~_08p(9v>$t!bw?07w~c}arV0MZ>N$z`NK)EozO!k$?8yq*!&eGmNjiW40~ij zdv8ed+)XG%yN&2YcRb~Q5V3i?3vh+;5=1zOiq6PpZnF+?nkDA_1~qwDcUI(j&^g__ zD#YX|;NFIa&UM%{vNo`x?dj5Gun{pTl$%FfR$iRH2GLWr={L|Wmkj2)0xsdRAVwnk zTv}`MP{6%S2Oi`4Q_xg6nD~65ta`=)3>(53NE%qt-lPi2|2LWGye7fn-#EFq8$`bl z%l-wp>Bz|@iGYAsZW`lNbMYAZ%RML=(}6PdbF=1}oS!82aaF3=a5d~KF797=V$GXA zwKA4UE7M9&w&X5;Z~6ACp?QVExK8;hu>b7X!ciFchytq5J(@iK3bH!&F1b3n>gw^I z*=^2j$7AaNvz?$0S-#)gGKfu;EBr{$Wkhc_bw$!0oPoMOlc|6cb zF|P!{ie?Qcv8{!#cTQ|8FBL-7hD=?5f%%C*=O!&o0rHVl!2f%YWg7FPCScm zKpT7Xi1v?E^GsS9&L5;vmfqD9CusGC7zr|QlSf7*Vd)s$d#isYcvwbGk9Q^p-$aAU z6Xrpx*f=^zf6X=JS(?4=kG>>r!tHu-;rzB*vIT;xAK+CnNHL81_+{ zlJfM!zjmV+i8cVn6IriS9DgeO)5p_W<&i|I*P}T+BTkEL>5j(t-_E@66s&e^zc?WG zEfplrJ`gyYW1<|jG3lvRx!>4}D2bl{hJX?X(~DD%d?O4TpBzhYlU{+-S)6>2f9R(i zl?Q8{E(XvXUX⋘h*&y?m87>s2Se$J2FzqW_M`J-2A36X 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

hQH zkR3!vew-4zxLH;#^_=CwErygBd8wd z1Mb?(;gCZC`ilR2QtlsJLo^$kO$<8fmKh(#z8H`=I(^%DFhg zS7+5Hx0A_D#P#mlqMF-f<%ipCu99tgLfi8@i1K&i{wWYB-c?#ou^ZPBdPpSYB2$%^ z!pQ^RN=arP;)B3YFYJ-@x&hf=Rkaxt>6_H-Ikg()ZhU%{vhb10HQch?+(%im(Z)P| zO2q!_T0a6dE;YP0{WBLCeqKB1Z4>$NdwuZyh-zztmCJl=+}fb{bqg#8@)K0rmeml< zEW0k8WqTk1rs-v}@6Cg<;lx-7g`;OC-_F*p410u4z8Z(mEkAiD)7o`Dl0JIcD)o6( zzH7pwOJ+NY4tQ3OBu06|P^`Azui-7~j2E$@|HzXeCGp}V%r5xm z+p^cwmuB3i42Yr*Og3^|mm>$OqhkzXa~$yxPQNS)qKi;jN7scgIP(YMF>27xezw#l zx{4ke)A8u$2*1WzQQB5Cu#-Vk{z8+#&S-@eh%NHmD+%Wv4Q%h{_SWMp*o*DW?^O&+ z+d>+G^|#b_@-hRqFSSZKR+9#H+oYxhboymaG=WJ_nBc=WJJgzIU9w{!@wwB)!&<}V^O%3r zh-g&DIeT*-3@We?wP^ElUTxLYgoAG@Q3q;4io&KV`M$KfJFFzp8|z3hL=>_=CSn8~ z+Q|B)f@>Iir3^l}rCEqm&ShPujA)O@d8XqpfR&yp{JJEPHMS(+7X3mI!H)lqHljv# zRa{Nn4S?7eeEtT+B@HbkG{x%+NlHGnz55EaoDu#w1Sp<}jqQs@-P(k zK0QnYLZe=*w}h}8?J^LxClT>@P=Ut@hW$RZPfjQ?nPtSDFMe3Z^ZU1_;SyIp>qauv zIN)rsD|i?2WW_Y(ypO(llOREA)D%$qbqunt@u|YH)7bzg2Gpfm!BaQ=G5JnVmF_GdEu4GNcvWN5zN@mz0h7UXo2RJkq^1A$w&nUXbW0J4Z<^O0? z+skQpgn5Kqk*04_*dr3oIjJ6JN$x^JW-~m^Pkoo`4rS*~zZc>iPOpBReBRE`yIak% zOyI2H>=fQfmTmCE(a$15X6CLkN2cK{2cs_M?$9DqDDK&$R@Tb(pUJ3ah?akd^ii`i zgkR66^=a$$AG@DjKv2R_!sCK8@k#<5nObrv1FPu*-5&OeyrPRFw<1a0OB_h$icnfi z&{TX{$4J8T$X+`7VDJ26JGX1!ST%GUN;~tD%HQ!Y1rW0iAZBPRB8<~x8u29pH#l}H zW)fXFQnIVz40{>^+=-|do;KfRu@nzsOgpACz}J9e^$ZCG*ygJA)01rz#H-`EsP1n2 z!K+#u9>2wfKgbX0GFdCrC$AY+j}D)h$Yb^om2 zu4B0-(rfV3@_M1I2M*LDc!b}~Qua=XP{ttf7lP2JXg;(}Pk=;#> zn$;)4q7AP8ZGrD+pK2YqEN%N&NI@rpGG`KYjRPY0}#}P z`%Eb(XXGX;%sHC%B$bGdx*}O+hi1v<^arRYo~y#O;|_G19j%I%?+}ERj~ZGc$5&zv zvh(l~(Y4ye$IXlogrWro=&88xtl+N1R_G^ggFju*xg+LlT)OO^_wKUld?7W+%=>Yc zHhTKCb9>yQ$aUE`TF%e|;aV5g-eE#=Z3noIR&)2FJ_t0i)x4A%IZWFfR_{GV=Rf(& z5Iyj5)$*gZnNU2p8A<_|%IRoRk*>NnwLvCUX`Htzf_g>

    ncf5p80l-7($cF?B! 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 StT~3r1`uiG*VV5~LjFIRGe^Y$ literal 0 HcmV?d00001 diff --git a/src/Router.ts b/src/Router.ts new file mode 100644 index 0000000..38cfe22 --- /dev/null +++ b/src/Router.ts @@ -0,0 +1,54 @@ +import { + Matched, + Matcher, + isMatched, +} from './matchers' +import { + MatchResultAny, +} from './matchers/MatchResult' + +interface HandlerParams { + match: Matched + data: D +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type MatchedHandler, D = any> = Handler, D> + +export type Handler< + MR extends MatchResultAny, + D, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +> = (params: HandlerParams) => any + +export interface Route { + matcher: Matcher + handler: Handler +} + +export class Router { + private routes: Route[] = [] + + constructor() { + this.addRoute = this.addRoute.bind(this) + this.exec = this.exec.bind(this) + } + + addRoute(route: Route): void { + this.routes.push(route as unknown as Route) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + exec(params: D): ReturnType> { + for (const route of this.routes) { + const match = route.matcher.match(params) + if (isMatched(match)) { + return route.handler({ + data: params, + match, + }) + } + } + return undefined + } +} diff --git a/src/__tests__/Router.test.ts b/src/__tests__/Router.test.ts new file mode 100644 index 0000000..6e98545 --- /dev/null +++ b/src/__tests__/Router.test.ts @@ -0,0 +1,324 @@ +import { + createRequest, + createResponse, +} from 'node-mocks-http' +import { + compile, + pathToRegexp, +} from 'path-to-regexp' +import { + Test, +} from 'ts-toolbelt' +import { + MatchedHandler, + Router, +} from '../Router' +import { + AndMatcher, + AndMatcherResult, + EndpointMatcher, + ExactUrlPathnameMatchResult, + ExactUrlPathnameMatcher, + Matched, + Method, + MethodMatchResult, + MethodMatcher, + RegExpUrlMatcher, +} from '../matchers' +import { + ServerRequest, +} from '../node/ServerRequest' +import { + MatchResult, +} from '../matchers/MatchResult' +import { + Matcher, +} from '../matchers/Matcher' +import { + BooleanMatcher, +} from '../matchers/BooleanMatcher' +import { + NodeHttpRouter, +} from '../node/NodeHttpRouter' + +let router: Router<{ req: ServerRequest }> + +beforeEach(() => { + router = new Router() +}) + +it('nothing matched', () => { + expect(router.exec({ req: createRequest() })).toBe(undefined) +}) + +it('match not found route if no routes specified', () => { + router.addRoute({ + matcher: new BooleanMatcher(true), + handler: () => 'not found', + }) + expect(router.exec({ req: createRequest() })).toBe('not found') +}) + +it('match method', () => { + // example of usage, if matcher is defined first + const matcher = new MethodMatcher(['GET', 'DELETE']) + + const handler: MatchedHandler = ({ match }) => { + if (match.result.method === 'DELETE') { + return 'matched DELETE' + } + return `matched ${match.result.method}` + } + + router.addRoute({ matcher, handler }) + + expect(router.exec({ req: createRequest() })).toBe('matched GET') + expect(router.exec({ req: createRequest({ method: 'DELETE' }) })).toBe('matched DELETE') +}) + +it('no match for POST route', () => { + router.addRoute({ + matcher: new MethodMatcher(['GET']), + handler: () => 'matched GET', + }) + router.addRoute({ + matcher: new BooleanMatcher(true), + handler: () => 'not found', + }) + + const req = createRequest({ + method: 'POST', + }) + + expect(router.exec({ req })).toBe('not found') +}) + +it('match POST /test route', () => { + // example of usage, if handler is defined first + const handler = ({ + match, + }: { + match: Matched, + ExactUrlPathnameMatchResult<[string]> + >> + }) => { + const [{ result: { method } }, { result: { pathname } }] = match.result.and + return `matched ${method} ${pathname}` + } + + router.addRoute({ + matcher: new AndMatcher([ + new MethodMatcher(['POST']), + new ExactUrlPathnameMatcher(['/test']), + ]), + handler, + }) + + const req = createRequest({ + method: 'POST', + url: '/test', + }) + + expect(router.exec({ req })).toBe('matched POST /test') +}) + +it('match POST /group/123 endpoint', () => { + // define an endpoint + const endpoint = ((pattern: string) => ({ + pattern: pathToRegexp(pattern), + path: compile<{ groupId: number }>(pattern), + }))('/group/:groupId') + + router.addRoute({ + matcher: new EndpointMatcher('POST', endpoint.pattern), + handler: ({ match }) => { + return `matched ${match.result.method} for group ${match.result.match[1]}` + }, + }) + router.addRoute({ + matcher: new BooleanMatcher(true), + handler: () => 'not found', + }) + + const req = createRequest({ + method: 'POST', + url: endpoint.path({ groupId: 123 }), + }) + + expect(router.exec({ req })).toBe('matched POST for group 123') + expect(router.exec({ req: createRequest() })).toBe('not found') +}) + +class TestMatcherWithParams< + T extends MatchResult, + D extends { test: string } +> implements Matcher { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this + match(neededForTypeScript: D): T { + throw new Error('Method not implemented.') + } +} + +it('simple route match', () => { + const routerWithoutParams = new Router() + routerWithoutParams.addRoute>({ + matcher: new BooleanMatcher(true), + handler: params => { + Test.checks([ + Test.check(), + ]) + return 'test' + }, + }) +}) + +it('simple route match with custom type', () => { + const typedRouter = new Router<{ test: string }>() + typedRouter.addRoute({ + matcher: new TestMatcherWithParams(), + handler: params => { + Test.checks([ + Test.check(), + ]) + return 'myreturn' + }, + }) +}) + +it('simple non-matching router type', () => { + const typedRouter = new Router<{ test2: string }>() + typedRouter.addRoute({ + // @ts-expect-error + matcher: new TestMatcherWithParams(), + handler: params => { + Test.checks([ + Test.check(), + Test.check(), + ]) + return 'myreturn' + }, + }) +}) + +it('nested router', () => { + const appRouter = new Router<{ + tenant: string + req: ServerRequest + }>() + + appRouter.addRoute({ + matcher: new RegExpUrlMatcher<{ name: string }>([/\/app\/(?[\w\W]+)/]), + handler: ({ + data: { tenant }, + match: { result: { match: { groups: { name } } } }, + }) => `myapp ${tenant} ${name}`, + }) + + appRouter.addRoute({ + matcher: new BooleanMatcher(true), + handler: () => '404 app route', + }) + + const rootRouter = new NodeHttpRouter() + rootRouter.addRoute({ + matcher: new RegExpUrlMatcher<{ + tenant: string + url: string + }>([/^\/auth\/realms\/(?[^/]+)(?.+)/]), + handler: ({ data, match }) => { + const { req } = data + const { tenant, url } = match.result.match.groups + req.url = url + return appRouter.exec({ + tenant, + req, + }) + }, + }) + + rootRouter.addRoute({ + matcher: new BooleanMatcher(true), + handler: () => '404 tenant route', + }) + + expect(rootRouter.serve( + createRequest({ + url: '/unknown', + }), + createResponse(), + )).toBe('404 tenant route') + + expect(rootRouter.serve( + createRequest({ + url: '/auth/realms/mytenant/app/test', + }), + createResponse(), + )).toBe('myapp mytenant test') + + expect(rootRouter.serve( + createRequest({ + url: '/auth/realms/mytenant/app', + }), + createResponse(), + )).toBe('404 app route') +}) + +it('event processing', () => { + type MyEvent = { + name: 'test1' + } | { + name: 'test2' + } | { + name: 'invalid' + } + + const eventRouter = new Router() + + eventRouter.addRoute({ + matcher: { + match(params: MyEvent): MatchResult { + const result = /^test(?\d+)$/.exec(params.name) + if (result?.groups?.num) { + return { + matched: true, + result: parseInt(result.groups.num, 10), + } + } + return { + matched: false, + } + }, + }, + handler({ data, match: { result } }) { + return `the event ${data.name} has number ${result}` + }, + }) + + eventRouter.addRoute({ + matcher: new BooleanMatcher(true), + handler({ data }) { + return `the event '${data.name}' is unknown` + }, + }) + + expect(eventRouter.exec({ + name: 'test1', + })).toBe('the event test1 has number 1') + + expect(eventRouter.exec({ + name: 'invalid', + })).toBe(`the event 'invalid' is unknown`) +}) diff --git a/src/__tests__/router.test.ts b/src/__tests__/router.test.ts deleted file mode 100644 index ff11065..0000000 --- a/src/__tests__/router.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - IncomingMessage, - ServerResponse, -} from 'http' -import { - createRequest, - createResponse, -} from 'node-mocks-http' -import { - compile, - pathToRegexp, -} from 'path-to-regexp' -import { - MatchedHandler, - Router, -} from '../router' -import { - AndMatcher, - EndpointMatcher, - ExactUrlPathnameMatcher, - MethodMatcher, -} from '../matchers' - -let router: Router - -beforeEach(() => { - router = new Router(() => 'not found') -}) - -it('match not found route if no routes specified', () => { - expect(router.serve(createRequest(), createResponse())).toBe('not found') -}) - -it('match method', () => { - // example of usage, if matcher is defined first - const matcher = new MethodMatcher(['GET', 'DELETE']) - - const handler: MatchedHandler = (req, res, match) => { - if (match.method === 'DELETE') { - return 'matched DELETE' - } - return `matched ${match.method}` - } - - router.addRoute({ matcher, handler }) - - expect(router.serve(createRequest(), createResponse())).toBe('matched GET') - expect(router.serve(createRequest({ method: 'DELETE' }), createResponse())).toBe('matched DELETE') -}) - -it('no match for POST route', () => { - router.addRoute({ - matcher: new MethodMatcher(['GET']), - handler: () => 'matched GET', - }) - - const request = createRequest({ - method: 'POST', - }) - - expect(router.serve(request, createResponse())).toBe('not found') -}) - -it('match POST /test route', () => { - // example of usage, if handler is defined first - const handler = ( - req: IncomingMessage, - res: ServerResponse, - match: {and: [{method: string}, {pathname: string}]}, - ) => { - const [{ method }, { pathname }] = match.and - return `matched ${method} ${pathname}` - } - - // fully typed: - // const handler = ( - // req: IncomingMessage, - // res: ServerResponse, - // match: Matched, - // ExactUrlPathnameMatchResult<[string]>>>) => { - // const [{ method }, { pathname }] = match.and - // return `matched ${method} ${pathname}` - // } - - router.addRoute({ - matcher: new AndMatcher([ - new MethodMatcher(['POST']), - new ExactUrlPathnameMatcher(['/test']), - ]), - handler, - }) - - const request = createRequest({ - method: 'POST', - url: '/test', - }) - - expect(router.serve(request, createResponse())).toBe('matched POST /test') -}) - -it('match POST /group/123 endpoint', () => { - // define an endpoint - const endpoint = ((pattern: string) => ({ - pattern: pathToRegexp(pattern), - path: compile<{groupId: number}>(pattern), - }))('/group/:groupId') - - router.addRoute({ - matcher: new EndpointMatcher('POST', endpoint.pattern), - handler: (req, res, match) => { - return `matched ${match.method} for group ${match.match[1]}` - }, - }) - - const request = createRequest({ - method: 'POST', - url: endpoint.path({ groupId: 123 }), - }) - - expect(router.serve(request, createResponse())).toBe('matched POST for group 123') - expect(router.serve(createRequest(), createResponse())).toBe('not found') -}) diff --git a/src/examples/micro.ts b/src/examples/micro.ts index 29155ef..fcffb92 100644 --- a/src/examples/micro.ts +++ b/src/examples/micro.ts @@ -2,13 +2,16 @@ import http from 'http' import micro, { send, } from 'micro' -import { - Router, -} from '../router' import { EndpointMatcher, ExactUrlPathnameMatcher, } from '../matchers' +import { + BooleanMatcher, +} from '../matchers/BooleanMatcher' +import { + NodeHttpRouter, +} from '../node/NodeHttpRouter' /* @@ -22,7 +25,7 @@ yarn example-micro-start */ -const router = new Router((req, res) => send(res, 404)) +const router = new NodeHttpRouter() const [address, port] = ['localhost', 8080] @@ -35,8 +38,8 @@ server.once('listening', () => { router.addRoute({ // it's not necessary to type the matcher, but it give you a confidence matcher: new EndpointMatcher<{ name: string }>('GET', /^\/hello\/(?[^/]+)$/), - handler: (req, res, match) => { - return `Hello ${match.match.groups.name}!` + handler: ({ match }) => { + return `Hello ${match.result.match.groups.name}!` }, }) @@ -47,3 +50,9 @@ router.addRoute({ return 'Shutdown the server' }, }) + +// 404 handler +router.addRoute({ + matcher: new BooleanMatcher(true), + handler: ({ data: { res } }) => send(res, 404), +}) diff --git a/src/examples/node.ts b/src/examples/node.ts index 9a1f110..2180b54 100644 --- a/src/examples/node.ts +++ b/src/examples/node.ts @@ -1,11 +1,14 @@ import http from 'http' -import { - Router, -} from '../router' import { EndpointMatcher, ExactUrlPathnameMatcher, } from '../matchers' +import { + BooleanMatcher, +} from '../matchers/BooleanMatcher' +import { + NodeHttpRouter, +} from '../node/NodeHttpRouter' /* @@ -19,10 +22,7 @@ yarn example-node-start */ -const router = new Router((req, res) => { - res.statusCode = 404 - res.end() -}) +const router = new NodeHttpRouter() const [address, port] = ['localhost', 8080] @@ -35,17 +35,26 @@ server.once('listening', () => { router.addRoute({ // it's not necessary to type the matcher, but it give you a confidence matcher: new EndpointMatcher<{ name: string }>('GET', /^\/hello\/(?[^/]+)$/), - handler: (req, res, match) => { - res.write(`Hello ${match.match.groups.name}!`) + handler: ({ data: { res }, match }) => { + res.write(`Hello ${match.result.match.groups.name}!`) res.end() }, }) router.addRoute({ matcher: new ExactUrlPathnameMatcher(['/shutdown']), - handler: (req, res) => { + handler: ({ data: { res } }) => { res.write('Shutdown the server') res.end() server.close() }, }) + +// 404 handler +router.addRoute({ + matcher: new BooleanMatcher(true), + handler: ({ data: { res } }) => { + res.statusCode = 404 + res.end() + }, +}) diff --git a/src/index.ts b/src/index.ts index a291d4b..c8978cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,16 @@ export * from './matchers' export * from './middlewares' -export type { - Handler, - Route, - MatchedHandler, -} from './router' export { + type Handler, + type Route, + type MatchedHandler, Router, -} from './router' +} from './Router' +export { + type ServerRequest, + toServerRequest, +} from './node/ServerRequest' +export { + NodeHttpRouter, + type NodeHttpRouterParams, +} from './node/NodeHttpRouter' diff --git a/src/matchers/AndMatcher.ts b/src/matchers/AndMatcher.ts index 16ef79b..f320f24 100644 --- a/src/matchers/AndMatcher.ts +++ b/src/matchers/AndMatcher.ts @@ -1,37 +1,39 @@ import { - IncomingMessage, - ServerResponse, -} from 'http' + Matcher, +} from './Matcher' import { MatchResult, + MatchResultAny, Matched, - Matcher, isMatched, -} from '.' +} from './MatchResult' -export type AndMatcherResult = MatchResult<{ +export type AndMatcherResult = MatchResult<{ and: [Matched, Matched] }> /** * Match if every matcher matches */ -export class AndMatcher -implements Matcher> { - constructor(private readonly matchers: [Matcher, Matcher]) { +export class AndMatcher +implements Matcher, P1 & P2> { + constructor(private readonly matchers: [Matcher, Matcher]) { + this.match = this.match.bind(this) } - match(req: IncomingMessage, res: ServerResponse): AndMatcherResult { + match(params: P1 & P2): AndMatcherResult { const [matcher1, matcher2] = this.matchers - const result1 = matcher1.match(req, res) + const result1 = matcher1.match(params) if (isMatched(result1)) { - const result2 = matcher2.match(req, res) + const result2 = matcher2.match(params) if (isMatched(result2)) { return { matched: true, - and: [result1, result2], + result: { + and: [result1, result2], + }, } } } diff --git a/src/matchers/BooleanMatcher.ts b/src/matchers/BooleanMatcher.ts new file mode 100644 index 0000000..7a2b31f --- /dev/null +++ b/src/matchers/BooleanMatcher.ts @@ -0,0 +1,24 @@ +import { + MatchResult, +} from './MatchResult' +import { + Matcher, +} from './Matcher' + +export class BooleanMatcher implements Matcher, void> { + constructor(private value: T) { + this.match = this.match.bind(this) + } + + match(): MatchResult { + if (this.value) { + return { + matched: true, + result: true, + } as MatchResult + } + return { + matched: false, + } + } +} diff --git a/src/matchers/EndpointMatcher.ts b/src/matchers/EndpointMatcher.ts index e34263e..2eb95ee 100644 --- a/src/matchers/EndpointMatcher.ts +++ b/src/matchers/EndpointMatcher.ts @@ -1,7 +1,3 @@ -import { - IncomingMessage, - ServerResponse, -} from 'http' import { Matcher, } from './Matcher' @@ -27,35 +23,49 @@ import { // https://github.com/microsoft/TypeScript/pull/26349 // to resolve http method +export interface EndpointMatcherInput { + req: { + url: string + method: string + } +} + export type EndpointMatchResult = -MatchResult<{ - method: Method - match: RegExpExecGroupArray -}> + MatchResult<{ + method: Method + match: RegExpExecGroupArray + }> /** * higher order matcher which is combine matching of method * with regular expression */ -export class EndpointMatcher -implements Matcher> { - private readonly matcher: AndMatcher, RegExpUrlMatchResult> - constructor(method: Method, url: RegExp) { +export class EndpointMatcher< + R extends object, + P extends EndpointMatcherInput = EndpointMatcherInput +> +implements Matcher, P> { + private readonly matcher: AndMatcher, RegExpUrlMatchResult, P, P> + constructor(methods: Method | Method[], url: RegExp) { + this.match = this.match.bind(this) this.matcher = new AndMatcher([ - new MethodMatcher([method]), - new RegExpUrlMatcher([url]), + new MethodMatcher(Array.isArray(methods) ? methods : [methods]), + new RegExpUrlMatcher([url]), ]) } - match(req: IncomingMessage, res: ServerResponse): EndpointMatchResult { - const result = this.matcher.match(req, res) + match(params: EndpointMatcherInput): EndpointMatchResult { + // @ts-expect-error + const result = this.matcher.match(params) if (result.matched) { - const [methodResult, urlResult] = result.and + const [methodResult, urlResult] = result.result.and return { matched: true, - method: methodResult.method, - match: urlResult.match, + result: { + method: methodResult.result.method, + match: urlResult.result.match, + }, } } diff --git a/src/matchers/ExactQueryMatcher.ts b/src/matchers/ExactQueryMatcher.ts index 6660682..af2cb01 100644 --- a/src/matchers/ExactQueryMatcher.ts +++ b/src/matchers/ExactQueryMatcher.ts @@ -1,6 +1,3 @@ -import { - IncomingMessage, -} from 'http' import Url from 'urlite' import { Matcher, @@ -9,14 +6,21 @@ import { MatchResult, } from './MatchResult' -type QueryMatch = {[key: string]: string | true | false | undefined} +export interface ExactQueryMatcherInput { + req: { + url: string + } +} + +type QueryMatch = { [key: string]: readonly string[] | true | false | undefined } type QueryResult = { [P in keyof T]: T[P] extends true ? string : T[P] extends false ? never : T[P] extends undefined ? string | undefined - : T[P] + : T[P] extends readonly string[] ? T[P][number] + : never } export type ExactQueryMatchResult = MatchResult<{ @@ -32,73 +36,70 @@ export type ExactQueryMatchResult = MatchResult<{ * undefined: optional * 'some string': must be exact value */ -export class ExactQueryMatcher -implements Matcher> { - private readonly listConfig: [string, string | true | false | undefined][] +export class ExactQueryMatcher +implements Matcher, P> { + private readonly listConfig: [string, readonly string[] | true | false | undefined][] constructor(config: U) { + this.match = this.match.bind(this) this.listConfig = Object.entries(config) } - match(req: IncomingMessage): ExactQueryMatchResult { - /* istanbul ignore else */ - if (req.url !== undefined) { - // original URL returns '' if search is empty - const search = Url.parse(req.url).search ?? '' + match({ req }: P): ExactQueryMatchResult { + // original URL returns '' if search is empty + const search = Url.parse(req.url).search ?? '' - // parse query string into dict - let params = {} as QueryResult - if (search !== '') { - params = search.substring(1).split(/&/).reduce((acc, parts) => { - const part = parts.split(/=/) - const [key, value] = part - // @ts-ignore - acc[key] = value - return acc - }, params) - } + // parse query string into dict + let params = {} as QueryResult + if (search !== '') { + params = search.substring(1).split(/&/).reduce((acc, parts) => { + const part = parts.split(/=/) + const [key, value] = part + // @ts-ignore + acc[key] = value + return acc + }, params) + } - // validate query params - for (const [key, value] of this.listConfig) { - switch (value) { + // validate query params + for (const [key, value] of this.listConfig) { + switch (value) { // key must be absent - case false: - if (key in params) { - return { - matched: false, - } + case false: + if (key in params) { + return { + matched: false, } - break - // key must be present with any value - case true: - if (key in params === false) { - return { - matched: false, - } + } + break + // key must be present with any value + case true: + if (key in params === false) { + return { + matched: false, } - break - // don't care about optional keys - case undefined: - break - // assume string and therefore exact key and value - default: - if (key in params === false || params[key] !== value) { - return { - matched: false, - } + } + break + // don't care about optional keys + case undefined: + break + // assume string[] and therefore exact key and value + default: { + const paramsValue = params[key] as string | undefined + if (!paramsValue || value.includes(paramsValue) === false) { + return { + matched: false, } + } } } - - // everything is fine - return { - matched: true, - query: params, - } } - /* istanbul ignore next */ + // everything is fine return { - matched: false, + matched: true, + result: { + query: params, + }, } } } diff --git a/src/matchers/ExactUrlPathnameMatcher.ts b/src/matchers/ExactUrlPathnameMatcher.ts index 43757d8..a750400 100644 --- a/src/matchers/ExactUrlPathnameMatcher.ts +++ b/src/matchers/ExactUrlPathnameMatcher.ts @@ -1,6 +1,3 @@ -import { - IncomingMessage, -} from 'http' import Url from 'urlite' import { Matcher, @@ -9,6 +6,12 @@ import { MatchResult, } from './MatchResult' +export interface ExactUrlPathnameMatcherInput { + req: { + url: string + } +} + export type ExactUrlPathnameMatchResult = MatchResult<{ pathname: U[number] }> @@ -16,21 +19,25 @@ export type ExactUrlPathnameMatchResult = Match /** * Match exact urls */ -export class ExactUrlPathnameMatcher -implements Matcher> { +export class ExactUrlPathnameMatcher< + U extends [string, ...string[]], + P extends ExactUrlPathnameMatcherInput +> +implements Matcher, P> { constructor(private readonly urls: U) { + this.match = this.match.bind(this) } - match(req: IncomingMessage): ExactUrlPathnameMatchResult { + match({ req }: P): ExactUrlPathnameMatchResult { /* istanbul ignore else */ - if (req.url !== undefined) { - // original URL returns '/' if pathname is empty - const pathname = Url.parse(req.url).pathname ?? '/' - if (this.urls.indexOf(pathname) >= 0) { - return { - matched: true, + // original URL returns '/' if pathname is empty + const pathname = Url.parse(req.url).pathname ?? '/' + if (this.urls.indexOf(pathname) >= 0) { + return { + matched: true, + result: { pathname, - } + }, } } return { diff --git a/src/matchers/MatchResult.ts b/src/matchers/MatchResult.ts index 08c7b39..a319452 100644 --- a/src/matchers/MatchResult.ts +++ b/src/matchers/MatchResult.ts @@ -1,21 +1,26 @@ -type MatchedResult = { +export type MatchedResult = { matched: true + result: M } -type UnmatchedResult = { +export type UnmatchedResult = { matched: false } -export type MatchResult> = UnmatchedResult | MatchedResult & T +export type MatchResult = UnmatchedResult | MatchedResult + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type MatchResultAny = MatchResult /** * reperesent matcher result which is matched */ -export type Matched = Extract +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Matched = Extract> /** * check for matched result */ -export function isMatched(matchResult: MR): matchResult is Matched { +export function isMatched(matchResult: MR): matchResult is Matched { return matchResult.matched } diff --git a/src/matchers/Matcher.ts b/src/matchers/Matcher.ts index d38bfa1..f4422cb 100644 --- a/src/matchers/Matcher.ts +++ b/src/matchers/Matcher.ts @@ -1,13 +1,10 @@ import { - IncomingMessage, - ServerResponse, -} from 'http' -import { - MatchResult, + MatchResultAny, } from './MatchResult' -export type ExtractMatchResult = M extends Matcher ? MR : never +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ExtractMatchResult = M extends Matcher ? MR : never -export interface Matcher { - match(req: IncomingMessage, res: ServerResponse): T +export interface Matcher { + match(params: P): T } diff --git a/src/matchers/MethodMatcher.ts b/src/matchers/MethodMatcher.ts index 1542740..e715758 100644 --- a/src/matchers/MethodMatcher.ts +++ b/src/matchers/MethodMatcher.ts @@ -1,6 +1,3 @@ -import { - IncomingMessage, -} from 'http' import { Matcher, } from './Matcher' @@ -10,6 +7,12 @@ import { const validMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as const +export interface MethodMatcherInput { + req: { + method: string + } +} + export type Method = typeof validMethods[number] export type MethodMatchResult = MatchResult<{ @@ -19,16 +22,22 @@ export type MethodMatchResult = MatchResult<{ /** * Match methods */ -export class MethodMatcher implements Matcher> { +export class MethodMatcher< + M extends Method[], + P extends MethodMatcherInput +> implements Matcher, P> { constructor(private readonly methods: M) { + this.match = this.match.bind(this) } - match(req: IncomingMessage): MethodMatchResult { + match({ req }: MethodMatcherInput): MethodMatchResult { const { method } = req if (method && this.methods.indexOf(method as Method) >= 0) { return { matched: true, - method: method as M[number], + result: { + method: method as M[number], + }, } } diff --git a/src/matchers/OrMatcher.ts b/src/matchers/OrMatcher.ts index 0add990..2c3ee7e 100644 --- a/src/matchers/OrMatcher.ts +++ b/src/matchers/OrMatcher.ts @@ -1,16 +1,13 @@ -import { - IncomingMessage, - ServerResponse, -} from 'http' import { MatchResult, + MatchResultAny, isMatched, } from './MatchResult' import { Matcher, } from './Matcher' -export type OrMatcherResult = MatchResult<{ +export type OrMatcherResult = MatchResult<{ or: [MR1, MR2] }> @@ -18,23 +15,26 @@ export type OrMatcherResult = * Match if at least one matcher matches. * For completeness both matcher are executed. */ -export class OrMatcher -implements Matcher> { - constructor(private readonly matchers: [Matcher, Matcher]) { +export class OrMatcher +implements Matcher, P1 & P2> { + constructor(private readonly matchers: [Matcher, Matcher]) { + this.match = this.match.bind(this) } - match(req: IncomingMessage, res: ServerResponse): OrMatcherResult { + match(params: P1 & P2): OrMatcherResult { const [matcher1, matcher2] = this.matchers - const result1 = matcher1.match(req, res) - const result2 = matcher2.match(req, res) + const result1 = matcher1.match(params) + const result2 = matcher2.match(params) const matched = isMatched(result1) || isMatched(result2) if (matched) { return { matched: true, - or: [result1, result2], + result: { + or: [result1, result2], + }, } } return { diff --git a/src/matchers/RegExpUrlMatcher.ts b/src/matchers/RegExpUrlMatcher.ts index 5acffb4..dec3648 100644 --- a/src/matchers/RegExpUrlMatcher.ts +++ b/src/matchers/RegExpUrlMatcher.ts @@ -1,6 +1,3 @@ -import { - IncomingMessage, -} from 'http' import { Matcher, } from './Matcher' @@ -8,6 +5,12 @@ import { MatchResult, } from './MatchResult' +export interface RegExpUrlMatcherInput { + req: { + url: string + } +} + export interface RegExpExecGroupArray< T extends object > extends Array { @@ -20,20 +23,24 @@ export type RegExpUrlMatchResult = MatchResult<{ match: RegExpExecGroupArray }> -export class RegExpUrlMatcher -implements Matcher> { - constructor(private readonly urls: [RegExp, ...RegExp[]]) { +export class RegExpUrlMatcher< + R extends object, + P extends RegExpUrlMatcherInput = RegExpUrlMatcherInput +> +implements Matcher, P> { + constructor(private readonly urls: RegExp[]) { + this.match = this.match.bind(this) } - match(req: IncomingMessage): RegExpUrlMatchResult { - if (req.url) { - for (const url of this.urls) { - const result = url.exec(req.url) as never as RegExpExecGroupArray - if (result !== null) { - return { - matched: true, + match({ req }: RegExpUrlMatcherInput): RegExpUrlMatchResult { + for (const url of this.urls) { + const result = url.exec(req.url) as never as RegExpExecGroupArray + if (result !== null) { + return { + matched: true, + result: { match: result, - } + }, } } } diff --git a/src/matchers/__tests__/AndMatcher.test.ts b/src/matchers/__tests__/AndMatcher.test.ts index 34828df..651498f 100644 --- a/src/matchers/__tests__/AndMatcher.test.ts +++ b/src/matchers/__tests__/AndMatcher.test.ts @@ -9,7 +9,7 @@ it('none match', () => { const result = new AndMatcher([ new MethodMatcher(['POST']), new ExactUrlPathnameMatcher(['/test']), - ]).match(httpMocks.createRequest(), httpMocks.createResponse()) + ]).match({ req: httpMocks.createRequest() }) expect(result).toStrictEqual({ matched: false, @@ -20,7 +20,7 @@ it('first match, second not', () => { const result = new AndMatcher([ new MethodMatcher(['GET']), new ExactUrlPathnameMatcher(['/test']), - ]).match(httpMocks.createRequest(), httpMocks.createResponse()) + ]).match({ req: httpMocks.createRequest() }) expect(result).toStrictEqual({ matched: false, @@ -28,7 +28,7 @@ it('first match, second not', () => { }) it('first not match, but second', () => { - const request = httpMocks.createRequest({ + const req = httpMocks.createRequest({ method: 'POST', url: '/test', }) @@ -36,7 +36,7 @@ it('first not match, but second', () => { const result = new AndMatcher([ new MethodMatcher(['GET']), new ExactUrlPathnameMatcher(['/test']), - ]).match(request, httpMocks.createResponse()) + ]).match({ req }) expect(result).toStrictEqual({ matched: false, @@ -44,26 +44,32 @@ it('first not match, but second', () => { }) it('both match', () => { - const request = httpMocks.createRequest({ + const req = httpMocks.createRequest({ url: '/test', }) const result = new AndMatcher([ new MethodMatcher(['GET']), new ExactUrlPathnameMatcher(['/test']), - ]).match(request, httpMocks.createResponse()) + ]).match({ req }) expect(result).toStrictEqual({ matched: true, - and: [ - { - matched: true, - method: 'GET', - }, - { - matched: true, - pathname: '/test', - }, - ], + result: { + and: [ + { + matched: true, + result: { + method: 'GET', + }, + }, + { + matched: true, + result: { + pathname: '/test', + }, + }, + ], + }, }) }) diff --git a/src/matchers/__tests__/BooleanMatcher.test.ts b/src/matchers/__tests__/BooleanMatcher.test.ts new file mode 100644 index 0000000..c9c3a13 --- /dev/null +++ b/src/matchers/__tests__/BooleanMatcher.test.ts @@ -0,0 +1,20 @@ +import { + BooleanMatcher, +} from '..' + +it('match', () => { + const result = new BooleanMatcher(true) + .match() + expect(result).toStrictEqual({ + matched: true, + result: true, + }) +}) + +it('not match', () => { + const result = new BooleanMatcher(false) + .match() + expect(result).toStrictEqual({ + matched: false, + }) +}) diff --git a/src/matchers/__tests__/EndpointMatcher.test.ts b/src/matchers/__tests__/EndpointMatcher.test.ts new file mode 100644 index 0000000..8adb463 --- /dev/null +++ b/src/matchers/__tests__/EndpointMatcher.test.ts @@ -0,0 +1,58 @@ +import * as httpMocks from 'node-mocks-http' +import { + EndpointMatcher, +} from '..' + +it('not match empty', () => { + const result = new EndpointMatcher('GET', /\/test/) + .match({ req: httpMocks.createRequest() }) + expect(result).toStrictEqual({ + matched: false, + }) +}) + +it('usual usage', () => { + const matcher = new EndpointMatcher(['GET', 'POST'], /\/test/) + // https://github.com/facebook/jest/issues/8475#issuecomment-656629010 + expect(() => { + expect(matcher.match({ + req: httpMocks.createRequest({ + url: '/test', + }), + })).toStrictEqual({ + matched: true, + result: { + method: 'GET', + match: [ + '/test', + ], + }, + }) + }).toThrow('serializes to the same string') + + expect(() => { + expect(matcher.match({ + req: httpMocks.createRequest({ + method: 'POST', + url: '/test', + }), + })).toStrictEqual({ + matched: true, + result: { + method: 'POST', + match: [ + '/test', + ], + }, + }) + }).toThrow('serializes to the same string') + + expect(matcher.match({ + req: httpMocks.createRequest({ + method: 'OPTIONS', + url: '/test', + }), + })).toStrictEqual({ + matched: false, + }) +}) diff --git a/src/matchers/__tests__/ExactQueryMatcher.test.ts b/src/matchers/__tests__/ExactQueryMatcher.test.ts new file mode 100644 index 0000000..6c31a7c --- /dev/null +++ b/src/matchers/__tests__/ExactQueryMatcher.test.ts @@ -0,0 +1,108 @@ +import * as httpMocks from 'node-mocks-http' +import { + ExactQueryMatcher, +} from '..' + +const matcher = new ExactQueryMatcher({ + mustPresent: true, + mustAbsent: false, + isOptional: undefined, + mustExact: ['exactValue1', 'exactValue2'] as const, +}) + +it('not match (mustPresent and mustExact are missing)', () => { + const result = matcher.match({ req: httpMocks.createRequest() }) + expect(result).toStrictEqual({ + matched: false, + }) +}) + +it('not match (mustPresent is missing)', () => { + const result = matcher.match({ + req: httpMocks.createRequest({ + url: '/test?mustExact=exactValue1', + }), + }) + expect(result).toStrictEqual({ + matched: false, + }) +}) + +it('not match (mustExact is missing)', () => { + const result = matcher.match({ + req: httpMocks.createRequest({ + url: '/test?mustPresent=foo', + }), + }) + expect(result).toStrictEqual({ + matched: false, + }) +}) + +it('match', () => { + const result = matcher.match({ + req: httpMocks.createRequest({ + url: '/test?mustExact=exactValue1&mustPresent=foo', + }), + }) + expect(result).toStrictEqual({ + matched: true, + result: { + query: { + mustExact: 'exactValue1', + mustPresent: 'foo', + }, + }, + }) +}) + +it('match with optional param', () => { + const result = matcher.match({ + req: httpMocks.createRequest({ + url: '/test?mustExact=exactValue1&mustPresent=foo&isOptional=bar', + }), + }) + expect(result).toStrictEqual({ + matched: true, + result: { + query: { + mustExact: 'exactValue1', + mustPresent: 'foo', + isOptional: 'bar', + }, + }, + }) +}) + +it('not match because of forbidden param', () => { + const result = matcher.match({ + req: httpMocks.createRequest({ + url: '/test?mustExact=exactValue1&mustPresent=foo&mustAbsent=foo', + }), + }) + expect(result).toStrictEqual({ + matched: false, + }) +}) + +it('match with additional param', () => { + const result = matcher.match({ + req: httpMocks.createRequest({ + url: '/test?mustExact=exactValue1&mustPresent=foo&additional=bar', + }), + }) + expect(result).toStrictEqual({ + matched: true, + result: { + query: { + mustExact: 'exactValue1', + mustPresent: 'foo', + additional: 'bar', + }, + }, + }) + if (result.matched) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const checkType: 'exactValue1' | 'exactValue2' = result.result.query.mustExact + } +}) diff --git a/src/matchers/__tests__/ExactQueryMatcherMatcher.test.ts b/src/matchers/__tests__/ExactQueryMatcherMatcher.test.ts deleted file mode 100644 index a642edf..0000000 --- a/src/matchers/__tests__/ExactQueryMatcherMatcher.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as httpMocks from 'node-mocks-http' -import { - ExactQueryMatcher, -} from '..' - -const matcher = new ExactQueryMatcher({ - mustPresent: true, - mustAbsent: false, - isOptional: undefined, - mustExact: 'exactValue', -}) - -it('not match (mustPresent and mustExact are missing)', () => { - const result = matcher.match(httpMocks.createRequest()) - expect(result).toStrictEqual({ - matched: false, - }) -}) - -it('not match (mustPresent is missing)', () => { - const result = matcher.match(httpMocks.createRequest({ - url: '/test?mustExact=exactValue', - })) - expect(result).toStrictEqual({ - matched: false, - }) -}) - -it('not match (mustExact is missing)', () => { - const result = matcher.match(httpMocks.createRequest({ - url: '/test?mustPresent=foo', - })) - expect(result).toStrictEqual({ - matched: false, - }) -}) - -it('match', () => { - const result = matcher.match(httpMocks.createRequest({ - url: '/test?mustExact=exactValue&mustPresent=foo', - })) - expect(result).toStrictEqual({ - matched: true, - query: { - mustExact: 'exactValue', - mustPresent: 'foo', - }, - }) -}) - -it('match with optional param', () => { - const result = matcher.match(httpMocks.createRequest({ - url: '/test?mustExact=exactValue&mustPresent=foo&isOptional=bar', - })) - expect(result).toStrictEqual({ - matched: true, - query: { - mustExact: 'exactValue', - mustPresent: 'foo', - isOptional: 'bar', - }, - }) -}) - -it('not match because of forbidden param', () => { - const result = matcher.match(httpMocks.createRequest({ - url: '/test?mustExact=exactValue&mustPresent=foo&mustAbsent=foo', - })) - expect(result).toStrictEqual({ - matched: false, - }) -}) - -it('match with additional param', () => { - const result = matcher.match(httpMocks.createRequest({ - url: '/test?mustExact=exactValue&mustPresent=foo&additional=bar', - })) - expect(result).toStrictEqual({ - matched: true, - query: { - mustExact: 'exactValue', - mustPresent: 'foo', - additional: 'bar', - }, - }) -}) diff --git a/src/matchers/__tests__/ExactUrlPathnameMatcher.test.ts b/src/matchers/__tests__/ExactUrlPathnameMatcher.test.ts index fd107af..e2140a2 100644 --- a/src/matchers/__tests__/ExactUrlPathnameMatcher.test.ts +++ b/src/matchers/__tests__/ExactUrlPathnameMatcher.test.ts @@ -5,7 +5,7 @@ import { it('not match empty', () => { const result = new ExactUrlPathnameMatcher(['/test']) - .match(httpMocks.createRequest()) + .match({ req: httpMocks.createRequest() }) expect(result).toStrictEqual({ matched: false, }) @@ -13,9 +13,11 @@ it('not match empty', () => { it('not match with postfix', () => { const result = new ExactUrlPathnameMatcher(['/test']) - .match(httpMocks.createRequest({ - url: '/test2', - })) + .match({ + req: httpMocks.createRequest({ + url: '/test2', + }), + }) expect(result).toStrictEqual({ matched: false, }) @@ -23,9 +25,11 @@ it('not match with postfix', () => { it('not match prefix', () => { const result = new ExactUrlPathnameMatcher(['/test']) - .match(httpMocks.createRequest({ - url: '/tes', - })) + .match({ + req: httpMocks.createRequest({ + url: '/tes', + }), + }) expect(result).toStrictEqual({ matched: false, }) @@ -33,33 +37,45 @@ it('not match prefix', () => { it('match', () => { const result = new ExactUrlPathnameMatcher(['/test']) - .match(httpMocks.createRequest({ - url: '/test?foo=bar', - })) + .match({ + req: httpMocks.createRequest({ + url: '/test?foo=bar', + }), + }) expect(result).toStrictEqual({ matched: true, - pathname: '/test', + result: { + pathname: '/test', + }, }) }) it('match first', () => { const result = new ExactUrlPathnameMatcher(['/test', '/foo']) - .match(httpMocks.createRequest({ - url: '/test', - })) + .match({ + req: httpMocks.createRequest({ + url: '/test', + }), + }) expect(result).toStrictEqual({ matched: true, - pathname: '/test', + result: { + pathname: '/test', + }, }) }) it('match second', () => { const result = new ExactUrlPathnameMatcher(['/test', '/foo']) - .match(httpMocks.createRequest({ - url: '/foo', - })) + .match({ + req: httpMocks.createRequest({ + url: '/foo', + }), + }) expect(result).toStrictEqual({ matched: true, - pathname: '/foo', + result: { + pathname: '/foo', + }, }) }) diff --git a/src/matchers/__tests__/MethodMatcher.test.ts b/src/matchers/__tests__/MethodMatcher.test.ts index d0f3c32..e09d54c 100644 --- a/src/matchers/__tests__/MethodMatcher.test.ts +++ b/src/matchers/__tests__/MethodMatcher.test.ts @@ -1,11 +1,11 @@ import * as httpMocks from 'node-mocks-http' import { MethodMatcher, -} from '..' +} from '../MethodMatcher' it('not match', () => { const result = new MethodMatcher(['POST']) - .match(httpMocks.createRequest()) + .match({ req: httpMocks.createRequest() }) expect(result).toStrictEqual({ matched: false, }) @@ -13,33 +13,45 @@ it('not match', () => { it('match GET', () => { const result = new MethodMatcher(['GET']) - .match(httpMocks.createRequest({ - url: '/test', - })) + .match({ + req: httpMocks.createRequest({ + url: '/test', + }), + }) expect(result).toStrictEqual({ matched: true, - method: 'GET', + result: { + method: 'GET', + }, }) }) it('match first', () => { const result = new MethodMatcher(['POST', 'GET']) - .match(httpMocks.createRequest({ - method: 'POST', - })) + .match({ + req: httpMocks.createRequest({ + method: 'POST', + }), + }) expect(result).toStrictEqual({ matched: true, - method: 'POST', + result: { + method: 'POST', + }, }) }) it('match second', () => { const result = new MethodMatcher(['POST', 'GET']) - .match(httpMocks.createRequest({ - method: 'GET', - })) + .match({ + req: httpMocks.createRequest({ + method: 'GET', + }), + }) expect(result).toStrictEqual({ matched: true, - method: 'GET', + result: { + method: 'GET', + }, }) }) diff --git a/src/matchers/__tests__/OrMatcher.test.ts b/src/matchers/__tests__/OrMatcher.test.ts index 0f709fb..3e18aa8 100644 --- a/src/matchers/__tests__/OrMatcher.test.ts +++ b/src/matchers/__tests__/OrMatcher.test.ts @@ -10,7 +10,7 @@ it('none match', () => { new MethodMatcher(['DELETE']), new MethodMatcher(['POST']), ]) - .match(httpMocks.createRequest(), httpMocks.createResponse()) + .match({ req: httpMocks.createRequest() }) expect(result).toStrictEqual({ matched: false, }) @@ -20,64 +20,78 @@ it('first match, second not', () => { const result = new OrMatcher([ new MethodMatcher(['DELETE']), new MethodMatcher(['POST']), - ]).match(httpMocks.createRequest({ method: 'DELETE' }), httpMocks.createResponse()) + ]).match({ req: httpMocks.createRequest({ method: 'DELETE' }) }) expect(result).toStrictEqual({ matched: true, - or: [ - { - matched: true, - method: 'DELETE', - }, - { - matched: false, - }, - ], + result: { + or: [ + { + matched: true, + result: { + method: 'DELETE', + }, + }, + { + matched: false, + }, + ], + }, }) }) it('first not match, but second', () => { - const request = httpMocks.createRequest({ + const req = httpMocks.createRequest({ method: 'POST', }) const result = new OrMatcher([ new MethodMatcher(['DELETE']), new MethodMatcher(['POST']), - ]).match(request, httpMocks.createResponse()) + ]).match({ req }) expect(result).toStrictEqual({ matched: true, - or: [ - { - matched: false, - }, - { - matched: true, - method: 'POST', - }, - ], + result: { + or: [ + { + matched: false, + }, + { + matched: true, + result: { + method: 'POST', + }, + }, + ], + }, }) }) it('both match', () => { - const request = httpMocks.createRequest({ + const req = httpMocks.createRequest({ url: '/test', }) const result = new OrMatcher([ new MethodMatcher(['GET']), new ExactUrlPathnameMatcher(['/test']), - ]).match(request, httpMocks.createResponse()) + ]).match({ req }) expect(result).toStrictEqual({ matched: true, - or: [ - { - matched: true, - method: 'GET', - }, - { - matched: true, - pathname: '/test', - }, - ], + result: { + or: [ + { + matched: true, + result: { + method: 'GET', + }, + }, + { + matched: true, + result: { + pathname: '/test', + }, + }, + ], + }, }) }) diff --git a/src/matchers/__tests__/RegExpUrlMatcher.test.ts b/src/matchers/__tests__/RegExpUrlMatcher.test.ts index a5760ac..1054347 100644 --- a/src/matchers/__tests__/RegExpUrlMatcher.test.ts +++ b/src/matchers/__tests__/RegExpUrlMatcher.test.ts @@ -5,7 +5,7 @@ import { it('not match', () => { const result = new RegExpUrlMatcher([/^\/test$/]) - .match(httpMocks.createRequest()) + .match({ req: httpMocks.createRequest() }) expect(result).toStrictEqual({ matched: false, }) @@ -13,45 +13,53 @@ it('not match', () => { it('match', () => { const result = new RegExpUrlMatcher([/^\/test$/]) - .match(httpMocks.createRequest({ - url: '/test', - })) + .match({ + req: httpMocks.createRequest({ + url: '/test', + }), + }) expect(result.matched).toBe(true) if (result.matched) { - expect(result.match.input).toBe('/test') + expect(result.result.match.input).toBe('/test') } }) it('match first', () => { const result = new RegExpUrlMatcher([/^\/test$/, /^\/test2$/]) - .match(httpMocks.createRequest({ - url: '/test', - })) + .match({ + req: httpMocks.createRequest({ + url: '/test', + }), + }) expect(result.matched).toBe(true) if (result.matched) { - expect(result.match.input).toBe('/test') + expect(result.result.match.input).toBe('/test') } }) it('match second', () => { const result = new RegExpUrlMatcher([/^\/test$/, /^\/test2$/]) - .match(httpMocks.createRequest({ - url: '/test2', - })) + .match({ + req: httpMocks.createRequest({ + url: '/test2', + }), + }) expect(result.matched).toBe(true) if (result.matched) { - expect(result.match.input).toBe('/test2') + expect(result.result.match.input).toBe('/test2') } }) it('match group', () => { const result = new RegExpUrlMatcher<{ groupId: string }>([/^\/group\/(?[^/]+)$/]) - .match(httpMocks.createRequest({ - url: '/group/123', - })) + .match({ + req: httpMocks.createRequest({ + url: '/group/123', + }), + }) expect(result.matched).toBe(true) if (result.matched) { - expect(result.match.input).toBe('/group/123') - expect(result.match.groups.groupId).toBe('123') + expect(result.result.match.input).toBe('/group/123') + expect(result.result.match.groups.groupId).toBe('123') } }) diff --git a/src/matchers/index.ts b/src/matchers/index.ts index 89e7c9e..b865bd3 100644 --- a/src/matchers/index.ts +++ b/src/matchers/index.ts @@ -1,36 +1,46 @@ export { AndMatcher, - AndMatcherResult, + type AndMatcherResult, } from './AndMatcher' export { EndpointMatcher, + type EndpointMatcherInput, } from './EndpointMatcher' export { ExactQueryMatcher, } from './ExactQueryMatcher' export { ExactUrlPathnameMatcher, - ExactUrlPathnameMatchResult, + type ExactUrlPathnameMatcherInput, + type ExactUrlPathnameMatchResult, } from './ExactUrlPathnameMatcher' -export type { - ExtractMatchResult, - Matcher, +export { + type ExtractMatchResult, + type Matcher, } from './Matcher' export { isMatched, - Matched, - MatchResult, + type Matched, + type MatchResult, + type MatchResultAny, + type MatchedResult, + type UnmatchedResult, } from './MatchResult' export { - Method, + type Method, MethodMatcher, - MethodMatchResult, + type MethodMatcherInput, + type MethodMatchResult, } from './MethodMatcher' export { OrMatcher, - OrMatcherResult, + type OrMatcherResult, } from './OrMatcher' export { RegExpUrlMatcher, - RegExpUrlMatchResult, + type RegExpUrlMatcherInput, + type RegExpUrlMatchResult, } from './RegExpUrlMatcher' +export { + BooleanMatcher, +} from './BooleanMatcher' diff --git a/src/middlewares/CorsMiddleware.ts b/src/middlewares/CorsMiddleware.ts index 9449a60..cc7e0cb 100644 --- a/src/middlewares/CorsMiddleware.ts +++ b/src/middlewares/CorsMiddleware.ts @@ -1,10 +1,16 @@ import { - MatchResult, - Matched, + MatchResultAny, } from '../matchers/MatchResult' import { Handler, -} from '../router' +} from '../Router' + +export interface CorsMiddlewareInput { + headers: { + origin?: string + } + method: string +} type HttpMethod = | 'POST' @@ -33,7 +39,7 @@ const DEFAULT_ALLOWED_HEADERS = [ const DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 // 24 hours -interface CorsMiddlewareOptions { +export interface CorsMiddlewareCallbackResult { // exact origins like 'http://0.0.0.0:8080' or '*' origins: string[] // methods like 'POST', 'GET' etc. @@ -46,25 +52,41 @@ interface CorsMiddlewareOptions { maxAge?: number } -export function CorsMiddleware({ - origins: originConfig, - allowMethods = DEFAULT_ALLOWED_METHODS, - allowHeaders = DEFAULT_ALLOWED_HEADERS, - allowCredentials = true, - maxAge = DEFAULT_MAX_AGE_SECONDS, -}: CorsMiddlewareOptions) { - return function corsWrapper>( - handler: Handler, - ): Handler { - return async function corsHandler(req, res, ...args) { +export function CorsMiddleware< + D2, + R extends CorsMiddlewareInput = CorsMiddlewareInput +>(callback: ( + req: R, + origin: string, + params: { data: D2 } +) => Promise) { + return function corsWrapper void + statusCode: number + end: () => void + } + }>( + handler: Handler): Handler { + return async function corsHandler(params) { + const { req, res } = params.data // avoid "Cannot set headers after they are sent to the client" if (res.writableEnded) { // TODO: not sure if handler should be called - return handler(req, res, ...args) + return handler(params) } const origin = req.headers.origin ?? '' - if (originConfig.includes(origin) || originConfig.includes('*')) { + + const config = await callback(req, origin, params) + const allowCredentials = config.allowCredentials ?? true + const allowMethods = config.allowMethods ?? DEFAULT_ALLOWED_METHODS + const allowHeaders = config.allowHeaders ?? DEFAULT_ALLOWED_HEADERS + const maxAge = config.maxAge ?? DEFAULT_MAX_AGE_SECONDS + + if (config.origins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Vary', 'Origin') if (allowCredentials) { @@ -89,7 +111,7 @@ export function CorsMiddleware({ return } - const result = await handler(req, res, ...args) + const result = await handler(params) return result } } diff --git a/src/middlewares/MiddlewareData.ts b/src/middlewares/MiddlewareData.ts new file mode 100644 index 0000000..ea7f7bc --- /dev/null +++ b/src/middlewares/MiddlewareData.ts @@ -0,0 +1,11 @@ +import { + MatchResultAny, +} from '../matchers/MatchResult' +import { + Handler, +} from '../Router' + +export type MiddlewareData< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends (handler: Handler) => Handler +> = Parameters[0]>[0]['data'] diff --git a/src/middlewares/__tests__/CorsMiddleware.test.ts b/src/middlewares/__tests__/CorsMiddleware.test.ts index 05e6034..5938944 100644 --- a/src/middlewares/__tests__/CorsMiddleware.test.ts +++ b/src/middlewares/__tests__/CorsMiddleware.test.ts @@ -12,9 +12,9 @@ describe('simple configuration', () => { }) const innerHandler = jest.fn() - const handler = CorsMiddleware({ + const handler = CorsMiddleware(async () => ({ origins: ['http://0.0.0.0:8000'], - })(innerHandler) + }))(innerHandler) it('no action', async () => { const req = createRequest({ @@ -24,7 +24,16 @@ describe('simple configuration', () => { }, }) const res = createResponse() - await handler(req, res, { matched: true }) + await handler({ + data: { + req, + res, + }, + match: { + matched: true, + result: undefined, + }, + }) expect(innerHandler).toBeCalledTimes(1) expect(res.getHeader('Access-Control-Allow-Methods')).toBeUndefined() expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http://0.0.0.0:8000') @@ -38,7 +47,16 @@ describe('simple configuration', () => { }, }) const res = createResponse() - await handler(req, res, { matched: true }) + await handler({ + data: { + req, + res, + }, + match: { + matched: true, + result: undefined, + }, + }) expect(innerHandler).toBeCalledTimes(0) expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,GET,PUT,PATCH,DELETE,OPTIONS') expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http://0.0.0.0:8000') @@ -50,7 +68,16 @@ describe('simple configuration', () => { method: 'OPTIONS', }) const res = createResponse() - await handler(req, res, { matched: true }) + await handler({ + data: { + req, + res, + }, + match: { + matched: true, + result: undefined, + }, + }) expect(innerHandler).toBeCalledTimes(0) expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,GET,PUT,PATCH,DELETE,OPTIONS') expect(res.getHeader('Access-Control-Allow-Origin')).toBeUndefined() @@ -62,7 +89,16 @@ describe('simple configuration', () => { }) const res = createResponse() res.end() - await handler(req, res, { matched: true }) + await handler({ + data: { + req, + res, + }, + match: { + matched: true, + result: undefined, + }, + }) expect(innerHandler).toBeCalledTimes(1) expect(res.getHeader('Access-Control-Allow-Methods')).toBeUndefined() expect(res.getHeader('Access-Control-Allow-Origin')).toBeUndefined() @@ -75,13 +111,14 @@ describe('changed defaults', () => { }) const innerHandler = jest.fn() - const handler = CorsMiddleware({ - origins: ['*'], + const handler = CorsMiddleware(async (req, origin) => ({ + // simulates '*' + origins: [origin], allowMethods: ['POST', 'DELETE'], allowHeaders: ['Authorization'], allowCredentials: false, maxAge: 360, - })(innerHandler) + }))(innerHandler) it('no action', async () => { const req = createRequest({ @@ -91,7 +128,16 @@ describe('changed defaults', () => { }, }) const res = createResponse() - await handler(req, res, { matched: true }) + await handler({ + data: { + req, + res, + }, + match: { + matched: true, + result: undefined, + }, + }) expect(innerHandler).toBeCalledTimes(0) expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,DELETE') expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http:/idontcare:80') diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts index a1e2d44..6915ae7 100644 --- a/src/middlewares/index.ts +++ b/src/middlewares/index.ts @@ -1,3 +1,8 @@ export { CorsMiddleware, + type CorsMiddlewareCallbackResult, + type CorsMiddlewareInput, } from './CorsMiddleware' +export { + type MiddlewareData, +} from './MiddlewareData' diff --git a/src/node/NodeHttpRouter.ts b/src/node/NodeHttpRouter.ts new file mode 100644 index 0000000..c6b780a --- /dev/null +++ b/src/node/NodeHttpRouter.ts @@ -0,0 +1,40 @@ +import { + IncomingMessage, + ServerResponse, +} from 'http' +import { + MatchedResult, +} from '../matchers' +import { + Router, +} from '../Router' +import { + ServerRequest, + toServerRequest, +} from './ServerRequest' + +export interface NodeHttpRouterParams { + data: { + req: ServerRequest + res: ServerResponse + } + match: MatchedResult +} + +export class NodeHttpRouter extends Router<{ + req: ServerRequest + res: ServerResponse +}> { + constructor() { + super() + this.serve = this.serve.bind(this) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + serve(req: IncomingMessage, res: ServerResponse): any { + return this.exec({ + req: toServerRequest(req), + res, + }) + } +} diff --git a/src/node/ServerRequest.ts b/src/node/ServerRequest.ts new file mode 100644 index 0000000..f8222e2 --- /dev/null +++ b/src/node/ServerRequest.ts @@ -0,0 +1,18 @@ +import { + IncomingMessage, +} from 'http' + +export type ServerRequest = IncomingMessage & { + url: string + method: string +} + +export function toServerRequest(req: IncomingMessage): ServerRequest { + if (req.method === undefined) { + throw new Error(`request missing 'method'`) + } + if (req.url === undefined) { + throw new Error(`request missing 'url'`) + } + return req as ServerRequest +} diff --git a/src/node/__tests__/NodeHttpRouter.test.ts b/src/node/__tests__/NodeHttpRouter.test.ts new file mode 100644 index 0000000..175e6dc --- /dev/null +++ b/src/node/__tests__/NodeHttpRouter.test.ts @@ -0,0 +1,17 @@ +import { + IncomingMessage, + ServerResponse, +} from 'http' +import { + NodeHttpRouter, +} from '../NodeHttpRouter' + +it('missing method in request', () => { + const nodeRouter = new NodeHttpRouter() + expect(() => nodeRouter.serve({} as IncomingMessage, {} as ServerResponse)).toThrowError(`request missing 'method'`) +}) + +it('missing url in request', () => { + const nodeRouter = new NodeHttpRouter() + expect(() => nodeRouter.serve({ method: 'GET' } as IncomingMessage, {} as ServerResponse)).toThrowError(`request missing 'url'`) +}) diff --git a/src/router.ts b/src/router.ts deleted file mode 100644 index 28b436c..0000000 --- a/src/router.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - IncomingMessage, - ServerResponse, -} from 'http' -import { - MatchResult, - Matched, - Matcher, - isMatched, -} from './matchers' - -export type Handler> = ( - req: IncomingMessage, - res: ServerResponse, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: D) => any - -export type MatchedHandler = Handler> - -export interface Route { - matcher: Matcher - handler: Handler -} - -export class Router { - private routes: Route[] = [] - private noMatchHandler: Handler - - constructor(noMatchHandler: Handler) { - this.noMatchHandler = noMatchHandler - } - - readonly addRoute = (route: Route): void => { - this.routes.push(route as unknown as Route) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly serve = (req: IncomingMessage, res: ServerResponse): ReturnType> => { - for (const route of this.routes) { - const match = route.matcher.match(req, res) - if (isMatched(match)) { - return route.handler(req, res, match) - } - } - - return this.noMatchHandler(req, res, { - matched: true, - }) - } -}