Repository: delvedor/find-my-way
Pull request #209 (2021-11-02 12:45:32)

Feature: Support optional parameters at the end of the path
Reopen Pull Request https://github.com/delvedor/find-my-way/pull/167

Support optional parameters (with a '?' suffix) if they are at the end of the path.


Patch:
diff --git a/README.md b/README.md
index 3c5f1cb..ebb2f6c 100644
--- a/README.md
+++ b/README.md
@@ -313,6 +313,12 @@ router.on('GET', '/example/at/:hour(^\\d{2})h:minute(^\\d{2})m', (req, res, para
 ```
 In this case as parameter separator it's possible to use whatever character is not matched by the regular expression.
 
+The last parameter can be made optional if you add a question mark ("?") at the end of the parameters name.
+```js
+router.on('GET', '/example/posts/:id?', (req, res, params) => {}))
+```
+In this case you can request `/example/posts` as well as `/example/posts/1`. The optional param will be undefined if not specified.
+
 Having a route with multiple parameters may affect negatively the performance, so prefer single parameter approach whenever possible, especially on routes which are on the hot path of your application.
 
 <a name="match-order"></a>
diff --git a/index.js b/index.js
index 9276fe7..9f0f9df 100644
--- a/index.js
+++ b/index.js
@@ -23,11 +23,16 @@ const Constrainer = require('./lib/constrainer')
 const NODE_TYPES = Node.prototype.types
 const httpMethods = http.METHODS
 const FULL_PATH_REGEXP = /^https?:\/\/.*?\//
+const OPTIONAL_PARAM_REGEXP = /(\/:[^/()]*?)\?(\/?)/
 
 if (!isRegexSafe(FULL_PATH_REGEXP)) {
   throw new Error('the FULL_PATH_REGEXP is not safe, update this module')
 }
 
+if (!isRegexSafe(OPTIONAL_PARAM_REGEXP)) {
+  throw new Error('the OPTIONAL_PARAM_REGEXP is not safe, update this module')
+}
+
 function Router (opts) {
   if (!(this instanceof Router)) {
     return new Router(opts)
@@ -79,6 +84,19 @@ Router.prototype.on = function on (method, path, opts, handler, store) {
   // handler validation
   assert(typeof handler === 'function', 'Handler should be a function')
 
+  // path ends with optional parameter
+  const optionalParamMatch = path.match(OPTIONAL_PARAM_REGEXP)
+  if (optionalParamMatch) {
+    assert(path.length === optionalParamMatch.index + optionalParamMatch[0].length, 'Optional Parameter needs to be the last parameter of the path')
+
+    const pathFull = path.replace(OPTIONAL_PARAM_REGEXP, '$1$2')
+    const pathOptional = path.replace(OPTIONAL_PARAM_REGEXP, '$2')
+
+    this.on(method, pathFull, opts, handler, store)
+    this.on(method, pathOptional, opts, handler, store)
+    return
+  }
+
   this._on(method, path, opts, handler, store)
 
   if (this.ignoreTrailingSlash && path !== '/' && !path.endsWith('*')) {
@@ -311,6 +329,19 @@ Router.prototype.off = function off (method, path) {
   assert(path.length > 0, 'The path could not be empty')
   assert(path[0] === '/' || path[0] === '*', 'The first character of a path should be `/` or `*`')
 
+  // path ends with optional parameter
+  const optionalParamMatch = path.match(OPTIONAL_PARAM_REGEXP)
+  if (optionalParamMatch) {
+    assert(path.length === optionalParamMatch.index + optionalParamMatch[0].length, 'Optional Parameter needs to be the last parameter of the path')
+
+    const pathFull = path.replace(OPTIONAL_PARAM_REGEXP, '$1$2')
+    const pathOptional = path.replace(OPTIONAL_PARAM_REGEXP, '$2')
+
+    this.off(method, pathFull)
+    this.off(method, pathOptional)
+    return
+  }
+
   // Rebuild tree without the specific route
   const ignoreTrailingSlash = this.ignoreTrailingSlash
   var newRoutes = self.routes.filter(function (route) {


Test patch:
diff --git a/test/optional-params.test.js b/test/optional-params.test.js
new file mode 100644
index 0000000..713032e
--- /dev/null
+++ b/test/optional-params.test.js
@@ -0,0 +1,145 @@
+'use strict'
+
+const t = require('tap')
+const test = t.test
+const FindMyWay = require('../')
+
+test('Test route with optional parameter', (t) => {
+  t.plan(2)
+  const findMyWay = FindMyWay({
+    defaultRoute: (req, res) => {
+      t.fail('Should not be defaultRoute')
+    }
+  })
+
+  findMyWay.on('GET', '/a/:param/b/:optional?', (req, res, params) => {
+    if (params.optional) {
+      t.equal(params.optional, 'foo')
+    } else {
+      t.equal(params.optional, undefined)
+    }
+  })
+
+  findMyWay.lookup({ method: 'GET', url: '/a/foo-bar/b', headers: {} }, null)
+  findMyWay.lookup({ method: 'GET', url: '/a/foo-bar/b/foo', headers: {} }, null)
+})
+
+test('Test for duplicate route with optional param', (t) => {
+  t.plan(1)
+  const findMyWay = FindMyWay({
+    defaultRoute: (req, res) => {
+      t.fail('Should not be defaultRoute')
+    }
+  })
+
+  findMyWay.on('GET', '/foo/:bar?', (req, res, params) => {})
+
+  try {
+    findMyWay.on('GET', '/foo', (req, res, params) => {})
+    t.fail('method is already declared for route with optional param')
+  } catch (e) {
+    t.equal(e.message, 'Method \'GET\' already declared for route \'/foo\' with constraints \'{}\'')
+  }
+})
+
+test('Test for param with ? not at the end', (t) => {
+  t.plan(1)
+  const findMyWay = FindMyWay({
+    defaultRoute: (req, res) => {
+      t.fail('Should not be defaultRoute')
+    }
+  })
+
+  try {
+    findMyWay.on('GET', '/foo/:bar?/baz', (req, res, params) => {})
+    t.fail('Optional Param in the middle of the path is not allowed')
+  } catch (e) {
+    t.equal(e.message, 'Optional Parameter needs to be the last parameter of the path')
+  }
+})
+
+test('Multi parametric route with optional param', (t) => {
+  t.plan(2)
+  const findMyWay = FindMyWay({
+    defaultRoute: (req, res) => {
+      t.fail('Should not be defaultRoute')
+    }
+  })
+
+  findMyWay.on('GET', '/a/:p1-:p2?', (req, res, params) => {
+    if (params.p1 && params.p2) {
+      t.equal(params.p1, 'foo')
+      t.equal(params.p2, 'bar-baz')
+    }
+  })
+
+  findMyWay.lookup({ method: 'GET', url: '/a/foo-bar-baz', headers: {} }, null)
+  findMyWay.lookup({ method: 'GET', url: '/a', headers: {} }, null)
+})
+
+test('Optional Parameter with ignoreTrailingSlash = true', (t) => {
+  t.plan(4)
+  const findMyWay = FindMyWay({
+    ignoreTrailingSlash: true,
+    defaultRoute: (req, res) => {
+      t.fail('Should not be defaultRoute')
+    }
+  })
+
+  findMyWay.on('GET', '/test/hello/:optional?', (req, res, params) => {
+    if (params.optional) {
+      t.equal(params.optional, 'foo')
+    } else {
+      t.equal(params.optional, undefined)
+    }
+  })
+
+  findMyWay.lookup({ method: 'GET', url: '/test/hello/', headers: {} }, null)
+  findMyWay.lookup({ method: 'GET', url: '/test/hello', headers: {} }, null)
+  findMyWay.lookup({ method: 'GET', url: '/test/hello/foo', headers: {} }, null)
+  findMyWay.lookup({ method: 'GET', url: '/test/hello/foo/', headers: {} }, null)
+})
+
+test('Optional Parameter with ignoreTrailingSlash = false', (t) => {
+  t.plan(4)
+  const findMyWay = FindMyWay({
+    ignoreTrailingSlash: false,
+    defaultRoute: (req, res) => {
+      t.match(req.url, '/test/hello/foo/')
+    }
+  })
+
+  findMyWay.on('GET', '/test/hello/:optional?', (req, res, params) => {
+    if (req.url === '/test/hello/') {
+      t.same(params, { optional: '' })
+    } else if (req.url === '/test/hello') {
+      t.same(params, {})
+    } else if (req.url === '/test/hello/foo') {
+      t.same(params, { optional: 'foo' })
+    }
+  })
+
+  findMyWay.lookup({ method: 'GET', url: '/test/hello/', headers: {} }, null)
+  findMyWay.lookup({ method: 'GET', url: '/test/hello', headers: {} }, null)
+  findMyWay.lookup({ method: 'GET', url: '/test/hello/foo', headers: {} }, null)
+  findMyWay.lookup({ method: 'GET', url: '/test/hello/foo/', headers: {} }, null)
+})
+
+test('derigister a route with optional param', (t) => {
+  t.plan(4)
+  const findMyWay = FindMyWay({
+    defaultRoute: (req, res) => {
+      t.fail('Should not be defaultRoute')
+    }
+  })
+
+  findMyWay.on('GET', '/a/:param/b/:optional?', (req, res, params) => {})
+
+  t.ok(findMyWay.find('GET', '/a/:param/b'))
+  t.ok(findMyWay.find('GET', '/a/:param/b/:optional'))
+
+  findMyWay.off('GET', '/a/:param/b/:optional?')
+
+  t.notOk(findMyWay.find('GET', '/a/:param/b'))
+  t.notOk(findMyWay.find('GET', '/a/:param/b/:optional'))
+})