├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── README.md ├── lib └── index.js ├── package-lock.json ├── package.json └── test ├── .eslintrc └── test.js /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: standard 3 | env: 4 | node: true 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 5 8 | versioning-strategy: increase-if-necessary 9 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 20.x, 22.x] 13 | 14 | env: 15 | CODECOV_TOKEN: 849dde91-e5e3-438d-a75e-07745ed7948a 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm run eslint 25 | - run: npm run test -- --coverage 26 | - run: npx codecov 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store* 3 | *.log 4 | *.map 5 | *.gz 6 | 7 | # modules 8 | node_modules 9 | 10 | # coverage 11 | .nyc_output 12 | coverage 13 | 14 | # cache 15 | .eslintcache 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2015 Jonathan Ong me@jongleberry.com 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Koa Path Match 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | [![Node.js CI](https://github.com/koajs/path-match/workflows/Node.js%20CI/badge.svg?branch=master)](https://github.com/koajs/path-match/actions?query=workflow%3A%22Node.js+CI%22) 6 | [![Test coverage][codecov-image]][codecov-url] 7 | [![License][license-image]][license-url] 8 | [![Downloads][downloads-image]][downloads-url] 9 | 10 | A simple routing wrapper around [path-match](https://github.com/expressjs/path-match). 11 | Similar to [koa-route](https://github.com/koajs/route), except it optionally handles methods better. 12 | All of these routers use [path-to-regexp](https://github.com/component/path-to-regexp) 13 | underneath, which is what Express uses as well. 14 | 15 | ```js 16 | const route = require('koa-path-match')({/* options passed to path-to-regexp */}) 17 | 18 | app.use(route('/:id', (ctx, next) => { 19 | const id = ctx.params.id 20 | 21 | // do stuff 22 | switch (ctx.request.method) { 23 | 24 | } 25 | })) 26 | ``` 27 | 28 | Or you can create middleware per method: 29 | 30 | ```js 31 | app.use(route('/:id') 32 | .get(async ctx => { 33 | ctx.body = await Things.getById(ctx.params.id) 34 | }) 35 | .delete(async ctx => { 36 | await Things.delete(ctx.params.id) 37 | ctx.status = 204 38 | }) 39 | ) 40 | ``` 41 | 42 | ## Maintainer 43 | 44 | - Lead: @jonathanong [@jongleberry](https://twitter.com/jongleberry) 45 | - Team: @koajs/routing 46 | 47 | ## API 48 | 49 | ### route(path, fns...) 50 | 51 | `path`s are just like Express routes. `fns` is either a single middleware 52 | or nested arrays of middleware, just like Express. 53 | 54 | ### const router = route(path) 55 | 56 | When you don't set `fns` in the `route()` function, a router instance is returned. 57 | 58 | ### router\[method\]\(fns...\) 59 | 60 | Define a middleware just for a specific method. 61 | 62 | ```js 63 | app.use(route('/:id').get(async ctx => { 64 | ctx.body = await Things.getById(ctx.params.id) 65 | })) 66 | ``` 67 | 68 | - `next` is not passed as a parameter. 69 | I consider this an anti-pattern in Koa - one route/method, one function. 70 | 71 | ### this.params 72 | 73 | Any keys defined in the path will be set to `ctx.params`, 74 | overwriting any already existing keys defined. 75 | 76 | [npm-image]: https://img.shields.io/npm/v/koa-path-match.svg?style=flat 77 | [npm-url]: https://npmjs.org/package/koa-path-match 78 | [codecov-image]: https://img.shields.io/codecov/c/github/koajs/path-match/master.svg?style=flat-square 79 | [codecov-url]: https://codecov.io/github/koajs/path-match 80 | [license-image]: http://img.shields.io/npm/l/koa-path-match.svg?style=flat-square 81 | [license-url]: LICENSE 82 | [downloads-image]: http://img.shields.io/npm/dm/koa-path-match.svg?style=flat-square 83 | [downloads-url]: https://npmjs.org/package/koa-path-match 84 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const compile = require('path-to-regexp').match 2 | const flatten = require('lodash/flattenDeep') 3 | const METHODS = require('http').METHODS 4 | const unique = require('lodash/uniq') 5 | 6 | module.exports = function (options) { 7 | options = options || {} 8 | 9 | return function (path, fn) { 10 | const match = compile(path, options) 11 | 12 | // app.use(route(:path, function () {})) 13 | if (Array.isArray(fn) || typeof fn === 'function') { 14 | if (arguments.length > 2) fn = [].slice.call(arguments, 1) 15 | if (Array.isArray(fn)) fn = compose(flatten(fn)) 16 | 17 | return function (ctx, next) { 18 | const result = match(ctx.request.path) 19 | if (!result) return next() 20 | 21 | ctx.params = result.params 22 | return fn(ctx, next) 23 | } 24 | } 25 | 26 | // app.use(route(:path).get(function () {}).post(function () {})) 27 | return newRoute(match) 28 | } 29 | } 30 | 31 | function newRoute (match) { 32 | const route = function (ctx, next) { 33 | const dispatcher = route.dispatcher || (route.dispatcher = createDispatcher(route)) 34 | return dispatcher(ctx, next) 35 | } 36 | 37 | route.match = match 38 | route.methods = Object.create(null) 39 | 40 | METHODS.forEach((method) => { 41 | route[method] = 42 | route[method.toLowerCase()] = function (fn) { 43 | if (arguments.length > 2) fn = [].slice.call(arguments, 1) 44 | if (Array.isArray(fn)) fn = compose(flatten(fn)) 45 | if (typeof route.methods[method] === 'function') throw new Error(`Method ${method} is already defined for this route!`) 46 | route.methods[method] = fn 47 | return route 48 | } 49 | }) 50 | 51 | return route 52 | } 53 | 54 | function createDispatcher (route) { 55 | const match = route.match 56 | const controllers = route.methods 57 | if (!controllers.HEAD && controllers.GET) controllers.HEAD = controllers.GET 58 | const ALLOW = unique(['OPTIONS'].concat(Object.keys(route.methods))).join(',') 59 | 60 | return function (ctx, next) { 61 | const result = match(ctx.request.path) 62 | if (!result) return next() 63 | 64 | ctx.params = result.params 65 | ctx.allow = ALLOW 66 | 67 | const method = ctx.method 68 | 69 | // handle OPTIONS 70 | if (method === 'OPTIONS') { 71 | ctx.set('Allow', ALLOW) 72 | if (controllers.OPTIONS) return controllers.OPTIONS(ctx) 73 | ctx.status = 204 74 | return 75 | } 76 | 77 | // handle other methods 78 | const fn = controllers[method] 79 | if (fn) return fn(ctx, next) 80 | 81 | // 405 82 | ctx.set('Allow', ALLOW) 83 | ctx.status = 405 84 | } 85 | } 86 | 87 | function compose (fns) { 88 | return function (ctx, next) { 89 | let rn = fns[fns.length - 1].bind(null, ctx, next) 90 | 91 | for (let n = fns.length - 2; n >= 0; n--) { 92 | rn = fns[n].bind(null, ctx, rn) 93 | } 94 | 95 | return rn() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-path-match", 3 | "description": "koa route middleware", 4 | "version": "5.0.0", 5 | "author": "Jonathan Ong (http://jongleberry.com)", 6 | "license": "MIT", 7 | "repository": "koajs/path-match", 8 | "dependencies": { 9 | "lodash": "^4.17.21", 10 | "path-to-regexp": "^8.2.0" 11 | }, 12 | "devDependencies": { 13 | "codecov": "^3.8.3", 14 | "eslint": "^7.32.0", 15 | "eslint-config-standard": "^16.0.3", 16 | "eslint-plugin-import": "^2.24.0", 17 | "eslint-plugin-node": "^11.1.0", 18 | "eslint-plugin-promise": "^5.1.0", 19 | "eslint-plugin-standard": "^5.0.0", 20 | "jest": "^29.7.0", 21 | "koa": "^2.13.1", 22 | "supertest": "^7.0.0" 23 | }, 24 | "scripts": { 25 | "eslint": "eslint . --ignore-path .gitignore", 26 | "test": "jest" 27 | }, 28 | "keywords": [ 29 | "koa", 30 | "route", 31 | "router" 32 | ], 33 | "files": [ 34 | "lib" 35 | ], 36 | "main": "lib", 37 | "jest": { 38 | "testEnvironment": "node" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | jest: true 4 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const request = require('supertest') 4 | const assert = require('assert') 5 | const Koa = require('koa') 6 | 7 | const match = require('..')() 8 | 9 | describe('match(path, fn)', () => { 10 | describe('when the route matches', () => { 11 | it('should execute the fn', async () => { 12 | const app = new Koa() 13 | app.use(match('/a/b', (ctx) => { 14 | ctx.status = 204 15 | })) 16 | 17 | await request(app.callback()).get('/a/b').expect(204) 18 | }) 19 | 20 | it('should populate this.params', async () => { 21 | const app = new Koa() 22 | app.use(match('/:a/:b', (ctx) => { 23 | ctx.status = 204 24 | assert.equal('a', ctx.params.a) 25 | assert.equal('b', ctx.params.b) 26 | })) 27 | 28 | await request(app.callback()).get('/a/b').expect(204) 29 | }) 30 | }) 31 | 32 | describe('when the route does not match', () => { 33 | it('should not execute the fn', async () => { 34 | const app = new Koa() 35 | app.use(match('/a/b', (ctx) => { 36 | ctx.status = 204 37 | })) 38 | 39 | await request(app.callback()).get('/a').expect(404) 40 | }) 41 | }) 42 | }) 43 | 44 | describe('match(path, fns...)', () => { 45 | it('should support multiple functions', async () => { 46 | let calls = 0 47 | function call (ctx, next) { 48 | return next().then(() => { 49 | ctx.body = String(++calls) 50 | }) 51 | } 52 | 53 | const app = new Koa() 54 | app.use(match('/a/b', call, call, call)) 55 | 56 | await request(app.callback()).get('/a/b').expect(200).expect('3') 57 | }) 58 | 59 | it('should support nested functions', async () => { 60 | let calls = 0 61 | function call (ctx, next) { 62 | return next().then(() => { 63 | ctx.body = String(++calls) 64 | }) 65 | } 66 | 67 | const app = new Koa() 68 | app.use(match('/a/b', [call, [call, call]])) 69 | 70 | await request(app.callback()).get('/a/b').expect(200).expect('3') 71 | }) 72 | 73 | it('should support both multiple and nested functions', async () => { 74 | let calls = 0 75 | function call (ctx, next) { 76 | return next().then(() => { 77 | ctx.body = String(++calls) 78 | }) 79 | } 80 | 81 | const app = new Koa() 82 | app.use(match('/a/b', [call, [call, call]], call, [call, call])) 83 | 84 | await request(app.callback()).get('/a/b').expect(200).expect('6') 85 | }) 86 | }) 87 | 88 | describe('match(path)[method](fn)', () => { 89 | describe('when the route matches', () => { 90 | it('should execute the fn', async () => { 91 | const app = new Koa() 92 | app.use(match('/a/b').get(function (ctx) { 93 | ctx.status = 204 94 | })) 95 | 96 | await request(app.callback()).get('/a/b').expect(204) 97 | }) 98 | 99 | it('should populate this.params', async () => { 100 | const app = new Koa() 101 | app.use(match('/:a/:b').get(function (ctx) { 102 | ctx.status = 204 103 | assert.equal('a', ctx.params.a) 104 | assert.equal('b', ctx.params.b) 105 | })) 106 | 107 | await request(app.callback()).get('/a/b').expect(204) 108 | }) 109 | 110 | it('should support OPTIONS', async () => { 111 | const app = new Koa() 112 | app.use(match('/:a/:b').get(function (ctx, next) { 113 | ctx.status = 204 114 | assert.equal('a', ctx.params.a) 115 | assert.equal('b', ctx.params.b) 116 | })) 117 | 118 | await request(app.callback()) 119 | .options('/a/b') 120 | .expect('Allow', /\bHEAD\b/) 121 | .expect('Allow', /\bGET\b/) 122 | .expect('Allow', /\bOPTIONS\b/) 123 | .expect(204) 124 | }) 125 | 126 | it('should support HEAD as GET', async () => { 127 | const app = new Koa() 128 | let called = false 129 | app.use(match('/:a/:b').get(function (ctx, next) { 130 | ctx.status = 204 131 | called = true 132 | assert.equal('a', ctx.params.a) 133 | assert.equal('b', ctx.params.b) 134 | })) 135 | 136 | await request(app.callback()).head('/a/b').expect(204) 137 | 138 | assert(called) 139 | }) 140 | }) 141 | 142 | describe('when the route does not match', () => { 143 | it('should not execute the fn', async () => { 144 | const app = new Koa() 145 | app.use(match('/a/b').get(function (ctx) { 146 | ctx.status = 204 147 | })) 148 | 149 | await request(app.callback()).get('/a').expect(404) 150 | }) 151 | }) 152 | 153 | describe('when the method does not match', () => { 154 | it('should 405', async () => { 155 | const app = new Koa() 156 | app.use(match('/a/b').get(function (ctx) { 157 | ctx.status = 204 158 | })) 159 | 160 | await request(app.callback()) 161 | .post('/a/b') 162 | .expect('Allow', /\bHEAD\b/) 163 | .expect('Allow', /\bGET\b/) 164 | .expect('Allow', /\bOPTIONS\b/) 165 | .expect(405) 166 | }) 167 | }) 168 | }) 169 | 170 | describe('match(path)[method](fn).[method](fn)...', () => { 171 | describe('when the route matches', () => { 172 | it('should execute the fn', async () => { 173 | const app = new Koa() 174 | app.use(match('/a/b').get(function (ctx) { 175 | ctx.status = 204 176 | }).post(function (ctx) { 177 | ctx.status = 201 178 | })) 179 | 180 | await Promise.all([ 181 | request(app.callback()).get('/a/b').expect(204), 182 | request(app.callback()).post('/a/b').expect(201) 183 | ]) 184 | }) 185 | 186 | it('should support OPTIONS', async () => { 187 | const app = new Koa() 188 | app.use(match('/:a/:b').get(function (ctx, next) { 189 | ctx.status = 204 190 | assert.equal('a', ctx.params.a) 191 | assert.equal('b', ctx.params.b) 192 | }).post(function (ctx, next) { 193 | ctx.status = 201 194 | })) 195 | 196 | await request(app.callback()) 197 | .options('/a/b') 198 | .expect('Allow', /\bHEAD\b/) 199 | .expect('Allow', /\bGET\b/) 200 | .expect('Allow', /\bPOST\b/) 201 | .expect('Allow', /\bOPTIONS\b/) 202 | .expect(204) 203 | }) 204 | }) 205 | }) 206 | --------------------------------------------------------------------------------