How to build a URL parameters parser
Gabriel José
Posted on October 14, 2021
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}`)
}
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}`]
])
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 += '/'
}
})
}
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')
}
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')
}
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
}
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
}
})
}
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
}
}
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)
}
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:
- Verify if we already called the
defineRoutesInfo
. - Ensure that the given request path ends with a slash.
- Define an empty object called params, it will be replaced for the url parameters if it has some.
- Filter the match results, using the filter method from the
definedRoutes
variable. - Verify if has more than one result on filter, which probably means that one route is a parameter and other is a identical one.
- If has more than one result we search for the identical.
- 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
}
}
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)
}
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 ?? {}
}
}
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
}
Posted on October 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.