DEV Community

Cover image for How to build a URL parameters parser
Gabriel José
Gabriel José

Posted on

How to build a URL parameters parser

Here is a simple tutorial showing a way to achieve a URL parameters parser. I need to say that might have some other ways which I didn't known to achieve it, so if you like leave a comment about it below.

I made this tutorial using TypeScript. But basically you can abstract the idea to your language of choose.

First, lets create an object to store our routes callbacks. The key of the object is a join of method + path and the value is the route callback. For example:

type RouteHandler = (params: Record<string, string>) => void

const routes: Record<string, RouteHandler> = {
  'get::/': () => console.log('Get on /'),
  'post::/:id': (params) => console.log(`Post on /${params.id}`)
}
Enter fullscreen mode Exit fullscreen mode

You can notice that the method and path are separated by a ::, this string was choose by me to be the separator, but you can use another one, like an space, @, #, or anything you want. I choose :: because we already use : to identify the url parameters.

This routes object can be a Map too, if you prefer. Like this:

const routes = new Map<string, RouteHandler>([
  ['get::/', () => console.log('Get on /')],
  ['post::/:id', (params) => console.log(`Post on /${params.id}`]
])
Enter fullscreen mode Exit fullscreen mode

Now we must get this information and define an array with some information to use later. We need the method, path, path regex and the handler. Lets create a function called defineRoutesInfo to loop through our routes object and define this data.

First, in the loop lets verify if the route path end with / this will help us ensure that our routes don't have some inconsistency, like we define /about and in the request is /about/, so we will ensure our paths and the path from request must end with /.

function defineRoutesInfo(routes: Record<string, RouteHandler>) {
  return Object.entries(routes).map(([routeName, routeHandler]) => {
    if (!routeName.endsWith('/')) {
      routeName += '/'
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Now we can ensure that our routeName follows the correct format, by verifying if the string includes the separator symbol, in my case ::. If not we throw an error for invalid route definition, this is not necessary to work, but i think its good to ensure that everything is correct.

if (!routeName.includes('::')) {
  throw new Error('Invalid route definition')
}
Enter fullscreen mode Exit fullscreen mode

After it, now in can extract the method and path from our routeName. And here you can make another validation to ensure that the path always start with /.

const [method, path] = routeName.split('::')

if (!(/^\//).test(path)) {
  throw new Error('Invalid path definition')
}
Enter fullscreen mode Exit fullscreen mode

Now we need to create a regex representation of our path, even more if it uses url parameters. To do this we use a function called createPathRegex, but we'll only call it for now, after ending this function we'll make this another one. To finish this the defineRoutesInfo function we must return an object with all needed data.

const pathRegex = createPathRegex(path)

return {
  method,
  path,
  pathRegex,
  handler: routeHandler
}
Enter fullscreen mode Exit fullscreen mode

The full function would be like this:

function defineRoutesInfo(routes: Record<string, RouteHandler>) {
  return Object.entries(routes).map(([routeName, routeHandler]) => {
    if (!routeName.endsWith('/')) {
      routeName += '/'
    }

        if (!routeName.includes('::')) {
        throw new Error('Invalid route definition')
      }

      const [method, path] = routeName.split('::')

        if (!(/^\//).test(path)) {
          throw new Error('Invalid path definition')
        }

      const pathRegex = createPathRegex(path)

      return {
        method,
        path,
        pathRegex,
        handler: routeHandler
      }
  })
}
Enter fullscreen mode Exit fullscreen mode

Lets create now the createPathRegex function. First of all, we can check if the path does not includes the url param symbol, which in my case is :, and return the path directly.

function createPathRegex(path: string) {
  if (!path.includes(':')) {
    return path
  }
}
Enter fullscreen mode Exit fullscreen mode

We must retrive the parameters names from the path, replace it with the correct regex in the path string and then return a RegExp instance of it. For example for /posts/:postId will be /posts/(?<postId>[\\w_\\-$@]+), we'll use the named capture group because when use the String.match method it will resolve the matched values and put it in an object on the groups property of the match result, you can see more about it on MDN. And this regex has double backslashes because the backslash already is an escape character and the backslash with another letter has some special meanings on regular expressions not only to escape a character, like we did in \\- to escape the dash character.

function createPathRegex(path: string) {
  if (!path.includes(':')) {
    return path
  }

    const identifiers = Array.from(path.matchAll(/\/:([\w_\-$]+)/g))
    .map(match => match[1])

    const pathRegexString = identifiers.reduce((acc, value) => {
      return acc.replace(`:${value}`, `(?<${value}>[\\w_\\-$@]+)`)
    }, path)

    return new RegExp(pathRegexString)
}
Enter fullscreen mode Exit fullscreen mode

We have our paths data ready to be used and when we receive the requested path and method, we must compare it with what we've. Let's create a function to find this path match.

To do so, we must follow this steps:

  1. Verify if we already called the defineRoutesInfo.
  2. Ensure that the given request path ends with a slash.
  3. Define an empty object called params, it will be replaced for the url parameters if it has some.
  4. Filter the match results, using the filter method from the definedRoutes variable.
  5. Verify if has more than one result on filter, which probably means that one route is a parameter and other is a identical one.
  6. If has more than one result we search for the identical.
  7. Return an object with the correct handler, if it has some, and the found params.
function findPathMatch(requestedMethod: string, requestedPath: string) {
  if (!definedRoutes) {
    definedRoutes = defineRoutesInfo(routes)
  }

  if (!requestedPath.endsWith('/')) {
    requestedPath += '/'
  }

  let params: Record<string, string> = {}

  const filteredRouteRecords = definedRoutes.map(routeRecord => {
    const match = requestedPath.match(routeRecord.pathRegex)

    if (!match) return

    const params: Record<string, string> = match?.groups ? match.groups : {}
    const methodHasMatched = requestedMethod.toLowerCase() === routeRecord.method

    const pathHasMatched = (
      match?.[0] === requestedPath
      && match?.input === requestedPath
    )

    if (methodHasMatched && pathHasMatched) {
      return { routeRecord, params }
    }
  })
    .filter(Boolean)

    let findedRouteRecord = null

  if (filteredRouteRecords.length > 1) {
    for(const routeRecord of filteredRouteRecords) {
      if (routeRecord.path === requestedPath) {
        findedRouteRecord = routeRecord
      }
    }
  } else {
    findedRouteRecord = filteredRouteRecords[0]
  }

  return {
    handler: findedRouteRecord?.handler ?? null,
    params
  }
}
Enter fullscreen mode Exit fullscreen mode

We must filter the routes instead to find the correct directly because its possible to define a route /about and a route /:id, and it can make a conflict of which to choose.

To filter the routes info it must match with both the method and path. With the method we must set it to lower case and compare with the the current route record. With the path we must match it with the path regex we made, the group property of this match give us an object with a correct match of parameter name and parameter value, that we can set it to the params object we previously created. And to ensure the correct match on the path we must compare the match result that position zero and the property input, both has to be equal to the requested path. Then we return the booleans the correspond if the method and path has a match.

To test it out, just pass the current method and path, and see the magic works.

const requestMethod = 'POST'
const requestPath = '/12'
const { handler, params } = findPathMatch(requestMethod, requestPath)

if (handler) {
  handler(params)
}
Enter fullscreen mode Exit fullscreen mode

If think that the findPathMatch function is too big you can separate in two other functions, one for filtering the route matches and other to find the correct route for the given path

interface RouteMatch {
  routeRecord: RouteInfo
  params: Record<string, string>
}

function filterRouteMatches(requestedMethod: string, requestedPath: string) {
  const matchedRouteRecords = definedRoutes.map(routeRecord => {
    const match = requestedPath.match(routeRecord.pathRegex)

    if (!match) return

    const params: Record<string, string> = match?.groups ? match.groups : {}
    const methodHasMatched = requestedMethod.toLowerCase() === routeRecord.method

    const pathHasMatched = (
      match?.[0] === requestedPath
      && match?.input === requestedPath
    )

    if (methodHasMatched && pathHasMatched) {
      return { routeRecord, params }
    }
  })
    .filter(Boolean)

  return matchedRouteRecords
}

function findCorrectRouteRecord(routeMatches: RouteMatch[], requestedPath: string) {
  if (routeMatches.length > 1) {
    for(const routeMatch of routeMatches) {
      if (routeMatch.routeRecord.path === requestedPath) {
        return routeMatch
      }
    }
  }

  return routeMatches[0]
}

function findPathMatch(requestedMethod: string, requestedPath: string) {
  if (!definedRoutes) {
    definedRoutes = defineRoutesInfo(routes)
  }

  if (!requestedPath.endsWith('/')) {
    requestedPath += '/'
  }

  const matchedRouteRecords = filterRouteMatches(requestedMethod, requestedPath)

  const findedRouteRecord = findCorrectRouteRecord(
    matchedRouteRecords,
    requestedPath
  )

  return {
    handler: findedRouteRecord?.routeRecord?.handler ?? null,
    params: findedRouteRecord?.params ?? {}
  }
}
Enter fullscreen mode Exit fullscreen mode

The end code

I hope you enjoy and could understand everything, any question leave a comment below, and happy coding!!!

type RouteHandler = (params: Record<string, string>) => void

interface RouteInfo {
    method: string
    path: string
  pathRegex: string | RegExp
  handler: RouteHandler
}

interface RouteMatch {
  routeRecord: RouteInfo
  params: Record<string, string>
}

const routes: Record<string, RouteHandler> = {
  'get::/': () => console.log('Get on /'),
  'post::/:id': (params) => console.log(`Post on /${params.id}`)
}

let definedRoutes: RouteInfo[] | null = null

function createPathRegex(path: string) {
  if (!path.includes(':')) {
    return path
  }

    const identifiers = Array.from(path.matchAll(/\/:([\w_\-$]+)/g))
    .map(match => match[1])

    const pathRegexString = identifiers.reduce((acc, value) => {
      return acc.replace(`:${value}`, `(?<${value}>[\\w_\\-$@]+)`)
    }, path)

    return new RegExp(pathRegexString)
}

function defineRoutesInfo(routes: Record<string, RouteHandler>) {
  return Object.entries(routes).map(([routeName, routeHandler]) => {
    if (!routeName.endsWith('/')) {
      routeName += '/'
    }

        if (!routeName.includes('::')) {
        throw new Error('Invalid route definition')
      }

      const [method, path] = routeName.split('::')

        if (!(/^\//).test(path)) {
          throw new Error('Invalid path definition')
        }

      const pathRegex = createPathRegex(path)

      return {
        method,
        path,
        pathRegex,
        handler: routeHandler
      }
  })
}

function filterRouteMatches(requestedMethod: string, requestedPath: string) {

  const matchedRouteRecords = definedRoutes.map(routeRecord => {
    const match = requestedPath.match(routeRecord.pathRegex)

    if (!match) return

    const params: Record<string, string> = match?.groups ? match.groups : {}
    const methodHasMatched = requestedMethod.toLowerCase() === routeRecord.method

    const pathHasMatched = (
      match?.[0] === requestedPath
      && match?.input === requestedPath
    )

    if (methodHasMatched && pathHasMatched) {
      return { routeRecord, params }
    }
  })
    .filter(Boolean)

  return matchedRouteRecords
}

function findCorrectRouteRecord(routeMatches: RouteMatch[], requestedPath: string) {

  if (routeMatches.length > 1) {
    for(const routeMatch of routeMatches) {
      if (routeMatch.routeRecord.path === requestedPath) {
        return routeMatch
      }
    }
  }

  return routeMatches[0]
}

function findPathMatch(requestedMethod: string, requestedPath: string) {
  if (!definedRoutes) {
    definedRoutes = defineRoutesInfo(routes)
  }

  if (!requestedPath.endsWith('/')) {
    requestedPath += '/'
  }

  const matchedRouteRecords = filterRouteMatches(requestedMethod, requestedPath)

  const findedRouteRecord = findCorrectRouteRecord(
    matchedRouteRecords,
    requestedPath
  )

  return {
    handler: findedRouteRecord?.routeRecord?.handler ?? null,
    params: findedRouteRecord?.params ?? {}
  }
}

const { handler, params } = findPathMatch('POST', '/12')

if (handler) {
  handler(params) // Post on /12
}
Enter fullscreen mode Exit fullscreen mode

Discussion (1)

Collapse
lukeshiru profile image
LUKESHIRU

For folks interested in the topic, there's a proposal for a new URLPattern API for native routing, you can read about it here and you can check the proposal here.

Cheers!