Repository: delvedor/find-my-way (js)

Issue:
the lib doesn't handle url-encoded URI properly
### The problem

The lib doesn't handle url-encoded URI properly. It's impossible to set tricky routes. There are many issues are related to this root reason ([fastify-static-143](https://github.com/fastify/fastify-static/issues/143), [fastify-static-234](https://github.com/fastify/fastify-static/issues/234), [fastify-3356](https://github.com/fastify/fastify/issues/3356)).

### The example

Let's assume that we want to serve 2 files:
1. `./[...]/a .md`
2. `./[...]/a%20.md`

To do so we create two routes:
```js
const http = require('http')
const router = require('find-my-way')()

router.on('GET', '/[...]/a .md', (req, res, params) => {
    res.end('serve 1st file with space')
})
router.on('GET', '/[...]/a%20.md', (req, res, params) => {
    res.end('serve 2nd file with %')
})

const server = http.createServer((req, res) => {
    router.lookup(req, res)
})

server.listen(3000)
```

The 1st route is not reachable at all.
The 2nd route is reachable only if we try to get to the 1st route.

This is very confusing.

There is more to this:
The browser (at least now) doesn't encode `[` and `]`. But it should be encoded to support IPv6.
Let's assume we create a client app which will consume the files. We will definitely use `encodeURI` before we send the request.
In this case we'll send `/%5B...%5D/a%20.md` as the URI and we will never reach the route.

I want to highlight here that `encodeURI` before setup a route in the example **will not solve the problem** as well, it just makes things worse instead.

### The theory

The URI is restricted to ASCII only. To **transport** the unicode character over ASCII we must encode it in percent-notation (`µ` -> `%C2%B5`).
Besides unicode characters there are special characters like: ?, #, ;, ... It is also possible to have those in URI but they also must be encoded  in percent-notation (` ` -> `%20`)

According to [rfc2616](https://datatracker.ietf.org/doc/html/rfc2616#section-3.2.3):
```
      the following three URIs are equivalent:

      http://abc.com:80/~smith/home.html
      http://ABC.com/%7Esmith/home.html
      http://ABC.com:/%7esmith/home.html
```

Based on listed above we must convert all the variants of the URI to a single final format, in other words we must `decodeURI` it.
It must be done right after we get the request because the encoding was done only for **transport** purposes.

### The solution

Use `decodeURI` somewhere [here](https://github.com/delvedor/find-my-way/blob/c561d220e6c0c93b76b72a7461dcdc8620158ba2/index.js#L344) and never use `encodeURI` while setting up the routes.

Pull request description:
fix decoding url
Fixes #206 

Patch:
diff --git a/README.md b/README.md
index 3c5f1cb..d7f8146 100644
--- a/README.md
+++ b/README.md
@@ -313,8 +313,16 @@ 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.
 
+**Note** that you must encode the parameters containing [reserved characters](https://www.rfc-editor.org/rfc/rfc3986#section-2.2).
+
 <a name="match-order"></a>
 ##### Match order
 
diff --git a/bench.js b/bench.js
index b8489db..5ddcf8c 100644
--- a/bench.js
+++ b/bench.js
@@ -79,6 +79,12 @@ suite
   .add('find dynamic route', function () {
     findMyWay.find('GET', '/user/tomas', undefined)
   })
+  .add('find dynamic route with encoded parameter unoptimized', function () {
+    findMyWay.find('GET', '/user/maintainer%2Btomas', undefined)
+  })
+  .add('find dynamic route with encoded parameter optimized', function () {
+    findMyWay.find('GET', '/user/maintainer%20tomas', undefined)
+  })
   .add('find dynamic multi-parametric route', function () {
     findMyWay.find('GET', '/customer/john-doe', undefined)
   })
@@ -94,6 +100,12 @@ suite
   .add('find long nested dynamic route', function () {
     findMyWay.find('GET', '/posts/10/comments/42/author', undefined)
   })
+  .add('find long nested dynamic route with encoded parameter unoptimized', function () {
+    findMyWay.find('GET', '/posts/10%2C10/comments/42%2C42/author', undefined)
+  })
+  .add('find long nested dynamic route with encoded parameter optimized', function () {
+    findMyWay.find('GET', '/posts/10%2510/comments/42%2542/author', undefined)
+  })
   .add('find long nested dynamic route with other method', function () {
     findMyWay.find('POST', '/posts/10/comments', undefined)
   })
diff --git a/index.js b/index.js
index 9276fe7..ce56c57 100644
--- a/index.js
+++ b/index.js
@@ -2,32 +2,50 @@
 
 /*
   Char codes:
-    '#': 35
-    '*': 42
-    '-': 45
-    '.': 46
-    '/': 47
-    ':': 58
-    ';': 59
-    '?': 63
+    '!': 33 - !
+    '#': 35 - %23
+    '$': 36 - %24
+    '%': 37 - %25
+    '&': 38 - %26
+    ''': 39 - '
+    '(': 40 - (
+    ')': 41 - )
+    '*': 42 - *
+    '+': 43 - %2B
+    ',': 44 - %2C
+    '-': 45 - -
+    '.': 46 - .
+    '/': 47 - %2F
+    ':': 58 - %3A
+    ';': 59 - %3B
+    '=': 61 - %3D
+    '?': 63 - %3F
+    '@': 64 - %40
+    '_': 95 - _
+    '~': 126 - ~
 */
 
 const assert = require('assert')
 const http = require('http')
-const fastDecode = require('fast-decode-uri-component')
 const isRegexSafe = require('safe-regex2')
 const { flattenNode, compressFlattenedNode, prettyPrintFlattenedNode, prettyPrintRoutesArray } = require('./lib/pretty-print')
 const Node = require('./node')
 const Constrainer = require('./lib/constrainer')
+const sanitizeUrl = require('./lib/url-sanitizer')
 
 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 +97,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 +342,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) {
@@ -341,7 +385,7 @@ Router.prototype.off = function off (method, path) {
 }
 
 Router.prototype.lookup = function lookup (req, res, ctx) {
-  var handle = this.find(req.method, sanitizeUrl(req.url), this.constrainer.deriveConstraints(req, ctx))
+  var handle = this.find(req.method, req.url, this.constrainer.deriveConstraints(req, ctx))
   if (handle === null) return this._defaultRoute(req, res, ctx)
   return ctx === undefined
     ? handle.handler(req, res, handle.params, handle.store)
@@ -356,6 +400,14 @@ Router.prototype.find = function find (method, path, derivedConstraints) {
     path = path.replace(FULL_PATH_REGEXP, '/')
   }
 
+  let sanitizedUrl
+  try {
+    sanitizedUrl = sanitizeUrl(path)
+    path = sanitizedUrl.path
+  } catch (error) {
+    return this._onBadUrl(path)
+  }
+
   var originalPath = path
   var originalPathLength = path.length
 
@@ -463,11 +515,9 @@ Router.prototype.find = function find (method, path, derivedConstraints) {
       i = path.indexOf('/')
       if (i === -1) i = pathLen
       if (i > maxParamLength) return null
-      decoded = fastDecode(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
+      decoded = sanitizedUrl.sliceParameter(idxInOriginalPath, idxInOriginalPath + i)
       if (decoded === null) {
-        return this.onBadUrl !== null
-          ? this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
-          : null
+        return this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
       }
       params || (params = [])
       params[pindex++] = decoded
@@ -478,11 +528,9 @@ Router.prototype.find = function find (method, path, derivedConstraints) {
 
     // wildcard route
     if (kind === NODE_TYPES.MATCH_ALL) {
-      decoded = fastDecode(originalPath.slice(idxInOriginalPath))
+      decoded = sanitizedUrl.sliceParameter(idxInOriginalPath)
       if (decoded === null) {
-        return this.onBadUrl !== null
-          ? this._onBadUrl(originalPath.slice(idxInOriginalPath))
-          : null
+        return this._onBadUrl(originalPath.slice(idxInOriginalPath))
       }
       params || (params = [])
       params[pindex] = decoded
@@ -497,11 +545,9 @@ Router.prototype.find = function find (method, path, derivedConstraints) {
       i = path.indexOf('/')
       if (i === -1) i = pathLen
       if (i > maxParamLength) return null
-      decoded = fastDecode(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
+      decoded = sanitizedUrl.sliceParameter(idxInOriginalPath, idxInOriginalPath + i)
       if (decoded === null) {
-        return this.onBadUrl !== null
-          ? this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
-          : null
+        return this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
       }
       if (!node.regex.test(decoded)) return null
       params || (params = [])
@@ -523,11 +569,9 @@ Router.prototype.find = function find (method, path, derivedConstraints) {
         while (i < pathLen && path.charCodeAt(i) !== 47 && path.charCodeAt(i) !== 45 && path.charCodeAt(i) !== 46) i++
         if (i > maxParamLength) return null
       }
-      decoded = fastDecode(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
+      decoded = sanitizedUrl.sliceParameter(idxInOriginalPath, idxInOriginalPath + i)
       if (decoded === null) {
-        return this.onBadUrl !== null
-          ? this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
-          : null
+        return this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
       }
       params || (params = [])
       params[pindex++] = decoded
@@ -540,13 +584,11 @@ Router.prototype.find = function find (method, path, derivedConstraints) {
   }
 }
 
-Router.prototype._getWildcardNode = function (node, path, len, derivedConstraints, params) {
+Router.prototype._getWildcardNode = function (node, sanitizedUrl, len, derivedConstraints, params) {
   if (node === null) return null
-  var decoded = fastDecode(path.slice(-len))
+  var decoded = sanitizedUrl.slice(-len)
   if (decoded === null) {
-    return this.onBadUrl !== null
-      ? this._onBadUrl(path.slice(-len))
-      : null
+    return this._onBadUrl(sanitizedUrl.slice(-len))
   }
 
   var handle = derivedConstraints !== undefined ? node.getMatchingHandler(derivedConstraints) : node.unconstrainedHandler
@@ -585,6 +627,9 @@ Router.prototype._defaultRoute = function (req, res, ctx) {
 }
 
 Router.prototype._onBadUrl = function (path) {
+  if (this.onBadUrl === null) {
+    return null
+  }
   const onBadUrl = this.onBadUrl
   return {
     handler: (req, res, ctx) => onBadUrl(path, req, res),
@@ -632,19 +677,6 @@ Router.prototype.all = function (path, handler, store) {
 
 module.exports = Router
 
-function sanitizeUrl (url) {
-  for (var i = 0, len = url.length; i < len; i++) {
-    var charCode = url.charCodeAt(i)
-    // Some systems do not follow RFC and separate the path and query
-    // string with a `;` character (code 59), e.g. `/foo;jsessionid=123456`.
-    // Thus, we need to split on `;` as well as `?` and `#`.
-    if (charCode === 63 || charCode === 59 || charCode === 35) {
-      return url.slice(0, i)
-    }
-  }
-  return url
-}
-
 function getClosingParenthensePosition (path, idx) {
   // `path.indexOf()` will always return the first position of the closing parenthese,
   // but it's inefficient for grouped or wrong regexp expressions.
diff --git a/lib/url-sanitizer.js b/lib/url-sanitizer.js
new file mode 100644
index 0000000..3fba906
--- /dev/null
+++ b/lib/url-sanitizer.js
@@ -0,0 +1,83 @@
+'use strict'
+
+const fastDecode = require('fast-decode-uri-component')
+
+// It must spot all the chars where decodeURIComponent(x) !== decodeURI(x)
+// The chars are: # $ & + , / : ; = ? @
+const uriComponentsCharMap = new Array(53).fill(0x00)
+uriComponentsCharMap[50] = new Array(103).fill(0x00)
+uriComponentsCharMap[50][51] = true // # '%23'
+uriComponentsCharMap[50][52] = true // $ '%24'
+uriComponentsCharMap[50][54] = true // & '%26'
+uriComponentsCharMap[50][66] = true // + '%2B'
+uriComponentsCharMap[50][98] = true // + '%2b'
+uriComponentsCharMap[50][67] = true // , '%2C'
+uriComponentsCharMap[50][99] = true // , '%2c'
+uriComponentsCharMap[50][70] = true // / '%2F'
+uriComponentsCharMap[50][102] = true // / '%2f'
+
+uriComponentsCharMap[51] = new Array(103).fill(0x00)
+uriComponentsCharMap[51][65] = true // : '%3A'
+uriComponentsCharMap[51][97] = true // : '%3a'
+uriComponentsCharMap[51][66] = true // ; '%3B'
+uriComponentsCharMap[51][98] = true // ; '%3b'
+uriComponentsCharMap[51][68] = true // = '%3D'
+uriComponentsCharMap[51][100] = true // = '%3d'
+uriComponentsCharMap[51][70] = true // ? '%3F'
+uriComponentsCharMap[51][102] = true // ? '%3f'
+
+uriComponentsCharMap[52] = new Array(49).fill(0x00)
+uriComponentsCharMap[52][48] = true // @ '%40'
+
+function sanitizeUrl (url) {
+  let originPath = url
+  let shouldDecode = false
+  let containsEncodedComponents = false
+  let highChar
+  let lowChar
+  for (var i = 0, len = url.length; i < len; i++) {
+    var charCode = url.charCodeAt(i)
+
+    if (shouldDecode && !containsEncodedComponents) {
+      if (highChar === 0 && uriComponentsCharMap[charCode]) {
+        highChar = charCode
+        lowChar = 0x00
+      } else if (highChar && lowChar === 0 && uriComponentsCharMap[highChar][charCode]) {
+        containsEncodedComponents = true
+      } else {
+        highChar = undefined
+        lowChar = undefined
+      }
+    }
+
+    // Some systems do not follow RFC and separate the path and query
+    // string with a `;` character (code 59), e.g. `/foo;jsessionid=123456`.
+    // Thus, we need to split on `;` as well as `?` and `#`.
+    if (charCode === 63 || charCode === 59 || charCode === 35) {
+      originPath = url.slice(0, i)
+      break
+    } else if (charCode === 37) {
+      shouldDecode = true
+      highChar = 0x00
+    }
+  }
+  const decoded = shouldDecode ? decodeURI(originPath) : originPath
+
+  return {
+    path: decoded,
+    originPath,
+    sliceParameter: containsEncodedComponents
+      ? sliceAndDecode
+      : slicePath
+  }
+}
+
+module.exports = sanitizeUrl
+
+function sliceAndDecode (from, to) {
+  return fastDecode(this.originPath.slice(from, to))
+}
+
+function slicePath (from, to) {
+  return this.path.slice(from, to)
+}
diff --git a/package.json b/package.json
index 7e93c8d..e173fde 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "find-my-way",
-  "version": "4.3.3",
+  "version": "4.4.0",
   "description": "Crazy fast http radix based router",
   "main": "index.js",
   "types": "index.d.ts",


Test patch:
diff --git a/test/issue-206.test.js b/test/issue-206.test.js
new file mode 100644
index 0000000..76ebef5
--- /dev/null
+++ b/test/issue-206.test.js
@@ -0,0 +1,107 @@
+'use strict'
+
+const t = require('tap')
+const test = t.test
+const FindMyWay = require('..')
+
+test('Decode the URL before the routing', t => {
+  t.plan(8)
+  const findMyWay = FindMyWay()
+
+  function space (req, res, params) {}
+  function percentTwenty (req, res, params) {}
+  function percentTwentyfive (req, res, params) {}
+
+  findMyWay.on('GET', '/static/:pathParam', () => {})
+  findMyWay.on('GET', '/[...]/a .html', space)
+  findMyWay.on('GET', '/[...]/a%20.html', percentTwenty)
+  findMyWay.on('GET', '/[...]/a%2520.html', percentTwentyfive)
+
+  t.equal(findMyWay.find('GET', '/[...]/a .html').handler, space)
+  t.equal(findMyWay.find('GET', '/%5B...%5D/a .html').handler, space)
+  t.equal(findMyWay.find('GET', '/[...]/a%20.html').handler, space, 'a%20 decode is a ')
+  t.equal(findMyWay.find('GET', '/%5B...%5D/a%20.html').handler, space, 'a%20 decode is a ')
+  t.equal(findMyWay.find('GET', '/[...]/a%2520.html').handler, percentTwenty, 'a%2520 decode is a%20')
+  t.equal(findMyWay.find('GET', '/%5B...%5D/a%252520.html').handler, percentTwentyfive, 'a%252520.html is a%2520')
+  t.equal(findMyWay.find('GET', '/[...]/a  .html'), null, 'double space')
+  t.equal(findMyWay.find('GET', '/static/%25E0%A4%A'), null, 'invalid encoded path param')
+})
+
+test('double encoding', t => {
+  t.plan(8)
+  const findMyWay = FindMyWay()
+
+  function pathParam (req, res, params) {
+    t.same(params, this.expect, 'path param')
+    t.same(pathParam, this.handler, 'match handler')
+  }
+  function regexPathParam (req, res, params) {
+    t.same(params, this.expect, 'regex param')
+    t.same(regexPathParam, this.handler, 'match handler')
+  }
+  function wildcard (req, res, params) {
+    t.same(params, this.expect, 'wildcard param')
+    t.same(wildcard, this.handler, 'match handler')
+  }
+
+  findMyWay.on('GET', '/:pathParam', pathParam)
+  findMyWay.on('GET', '/reg/:regExeParam(^.*$)', regexPathParam)
+  findMyWay.on('GET', '/wild/*', wildcard)
+
+  findMyWay.lookup(get('/' + doubleEncode('reg/hash# .png')), null,
+    { expect: { pathParam: singleEncode('reg/hash# .png') }, handler: pathParam }
+  )
+  findMyWay.lookup(get('/' + doubleEncode('special # $ & + , / : ; = ? @')), null,
+    { expect: { pathParam: singleEncode('special # $ & + , / : ; = ? @') }, handler: pathParam }
+  )
+  findMyWay.lookup(get('/reg/' + doubleEncode('hash# .png')), null,
+    { expect: { regExeParam: singleEncode('hash# .png') }, handler: regexPathParam }
+  )
+  findMyWay.lookup(get('/wild/' + doubleEncode('mail@mail.it')), null,
+    { expect: { '*': singleEncode('mail@mail.it') }, handler: wildcard }
+  )
+
+  function doubleEncode (str) {
+    return encodeURIComponent(encodeURIComponent(str))
+  }
+  function singleEncode (str) {
+    return encodeURIComponent(str)
+  }
+})
+
+test('Special chars on path parameter', t => {
+  t.plan(10)
+  const findMyWay = FindMyWay()
+
+  function pathParam (req, res, params) {
+    t.same(params, this.expect, 'path param')
+    t.same(pathParam, this.handler, 'match handler')
+  }
+  function regexPathParam (req, res, params) {
+    t.same(params, this.expect, 'regex param')
+    t.same(regexPathParam, this.handler, 'match handler')
+  }
+  function staticEncoded (req, res, params) {
+    t.same(params, this.expect, 'static match')
+    t.same(staticEncoded, this.handler, 'match handler')
+  }
+
+  findMyWay.on('GET', '/:pathParam', pathParam)
+  findMyWay.on('GET', '/reg/:regExeParam(^\\d+) .png', regexPathParam)
+  findMyWay.on('GET', '/[...]/a%2520.html', staticEncoded)
+
+  findMyWay.lookup(get('/%5B...%5D/a%252520.html'), null, { expect: {}, handler: staticEncoded })
+  findMyWay.lookup(get('/[...].html'), null, { expect: { pathParam: '[...].html' }, handler: pathParam })
+  findMyWay.lookup(get('/reg/123 .png'), null, { expect: { regExeParam: '123' }, handler: regexPathParam })
+  findMyWay.lookup(get('/reg%2F123 .png'), null, { expect: { pathParam: 'reg/123 .png' }, handler: pathParam }) // en encoded / is considered a parameter
+  findMyWay.lookup(get('/reg/123%20.png'), null, { expect: { regExeParam: '123' }, handler: regexPathParam })
+})
+
+function get (url) {
+  return { method: 'GET', url, headers: {} }
+}
+
+// http://localhost:3000/parameter with / in it
+// http://localhost:3000/parameter%20with%20%2F%20in%20it
+
+// http://localhost:3000/parameter with %252F in it
diff --git a/test/on-bad-url.test.js b/test/on-bad-url.test.js
index fddf043..b3beb5c 100644
--- a/test/on-bad-url.test.js
+++ b/test/on-bad-url.test.js
@@ -11,7 +11,7 @@ test('If onBadUrl is defined, then a bad url should be handled differently (find
       t.fail('Should not be defaultRoute')
     },
     onBadUrl: (path, req, res) => {
-      t.equal(path, '/%world')
+      t.equal(path, '/%world', { todo: 'this is not executed' })
     }
   })
 
@@ -30,7 +30,7 @@ test('If onBadUrl is defined, then a bad url should be handled differently (look
       t.fail('Should not be defaultRoute')
     },
     onBadUrl: (path, req, res) => {
-      t.equal(path, '%world')
+      t.equal(path, '/hello/%world')
     }
   })
 
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'))
+})