route.flow
Library provides low-level API for type safe routing, addressing two primary concerns:
-
Parsing
Type safe parseing of routes - Extracting (typed) parameters so that type checker (in this instance Flow) is able to report any missuse.
-
Linking / Formatting
Type safe formating of hyper links - Type checker is able to report if any parameter is missing or mystyed.
The problem
Here is a simlpe example that uses a routing system of Express web framework for Node.js:
Disclaimer: There is no intention to diminish or crticize Express, it's an excellent library. As a matter of fact pointed out shortcomings are shortcomings of an untyped nature of JS, which is what Express is tailored for.
That being said, raise of type checkers for JS like Flow & TypeScript provides an excellent opportunities and there is no better way to illustrate them than to compare it to an established solution.
const express = require("express")
const app = express()
app.get("/", (request, response) => {
response.send(`<a href='/calculator/313/+/3'>Calculate 313 + 3</a>`)
})
app.get("/calculator/:a/+/:b", (request, response) => {
const {a, b} = request.params
response.send(`${parseFloat(a) + parseFloat(b)}\n`)
})
Note: Express does not actually allow
/+/
path segments, and you would have to use/plus/
instead, but for the sake of this example lets prentend it does
Parsing
There are multiple issues with this approach, which can lead to mistakes that can sneak into production:
-
Handling of parameters in routes is too repetitive.
Declaring a route parameter requires choose a name, which you must later repeat to get it from
request.params
. Mistyping the name of the parameter is a mistake which is not caught by the type checker (even if it is used). It is just too easy to make changes which would update names in some places and not other causing program to misbehave. -
Request handler needs to parse route parameters.
All parameter values are passed as strings to a handler, which then needs to parse them, handling all possible edge cases (In our example
/calculator/313/+/bob
would respond withNaN
:)
Linking
Even if we manage to keep parameter nameing in sync across the code base and excell at parsing their values, there still more that could go wrong:
-
Route changes affect hyper links.
Let's say we had to switch to prefix notation for our calculator and switched from URLs like
/calculator/313/+/3
to/calculator/plus/313/3
it's just too easy to forget to update a link in our/
route.
Solution
Note: Example below is more verbose than one above, but that is because it is meant to illustrate low-level API provided by this library, which is more of a building block for something like Express. It is also worth noting that API of this library is designed towards taking advantage of type system that does not quite fit Express API and that shows
import * as Route from "route.flow"
import * as URL from "url"
import express from "express"
const index = Route.Root
const calculator = index
.segment("calculator")
.param(Route.Float)
.segment("+")
.param(Route.Float)
const getIndex = response =>
response.send(`<a href='${plusRoute.formatPath(313, 3)}'>Calculate 313 + 3</a>`)
const getCalculator = (response, a: number, b: number) =>
response.send(`${a + b}`)
const app = express()
app.use((request, response) => {
const url = URL.parse(request.url)
const indexParams = index.parsePath(url)
if (indexParams) {
return getIndex(request, ...indexParams)
}
const calculatorParams = calculator.parsePath(url)
if (calculatorParams) {
return getCalculator(res, ...calculatorParams)
}
})
Presented solution attempts to illustrate building blocks from this library for structuring routes that can be used for:
-
Parsing route parameters in a type safe way.
Type checker (Flow) can ensure that there is no missmatch between extracted parameters and handlers (
getIndex
,getCalculator
) using them.Note: In this specific examlpe Flow will not complain if handler is passed less parameters than it expects due to the way it handles function subtyping rules. That being said, this library comes with solution to address that and ensure that extracted number of parameters matches of what handler expects, it's just seemed little too much for this example.
-
Format hyper-links in type safe way.
Links are formated by calling
.format(313, 3)
on the route itself allowing type checker to report any missmatch in type or number of parameters passed.
This elliminates all of the problems pointed out with original example:
-
No way to mistype parameter names, at least not without type checker reporting that as an error.
-
No need to parse route parameters as our routes are typed parsers already.
Note: Route as presented in the example won't match
/calculator/313/+/bob
sincebob
is not afloat
). -
Route changes will not break links.
Links are formatted from the routes themselves, so if number or order of parameters changes type checker will be at your service and tell you all the places you need to update. For example if we update our routing to prefix notation only our route definition will change & all the links will continue to work as expected:
const calculator = index .segment("calculator") + .segment("plus") .param(Route.Float) - .segment("+") .param(Route.Float)
Usage
Import
Rest of the the document & provided code examples assumes that library is installed (with yarn or npm) and imported as follows:
import * as Route from "route.flow"
Type Signatures
This section explains how to read some of the common type signatures used across this library.
Note: Feel free to skip to the next sectionthis is not necessary to undestanding how this library works. In fact if you're new to the type systems it's recommended to skip as this can be overhelming and discouraging.
Route<a>
The core concept in this library is a Route
which can parse URLs like /blog/42/cat-herding-techniques
into typed data and format it back.
Type signature Route<a>
tells you that this route on successful parse returns data of type a
and that this route can format data of type a
back to URL.
Note: In practice generic
a
is always going to be a tuple of paramaters route containst. For all primitive routesa
is going to be[b]
implying that route contains single parameter of typeb
. For static routes with no parametersa
will be[]
. In all other instancesRoute<a>
will be comprised of other rotues and havea
likeConcat<Concat<[],[integer]>,[string]>
which is equivalent of[integer, string]
implying that route contains of one static segment and two parameters:integer
andstring
parameters.
RouteSegment
It is just a type alias for Route<[]>
and is used to represent a static segments of the route.
RouteParam<a>
As a name suggestest it's a type representing a single parameter of the route. It is a subtype of Route<[a]>
and all primitives in this library are represented with it.
Note:
RouteParam<a>
is a subtype ofRoute<[a]>
but it's not an alias, meaning you can use former in place of later but not other way round. For examlpeRoute<Concat<[], Concat<[], string>>>
is equivalent ofRoute<[string]>
, but unlikeRouteParam<string>
it is comprised of two static segments and one parameter. In this exampleRoute<[string]>
coulde be a route like/blog/tag/:tagname
whileRouteParam<string>
would be:tagname
URL
Library exports URL
type, that Route
instances parse to extract pramaters.
type URL = {
pathname?:string,
search?:string,
hash?:string,
tostring():string
}
Note:
URL
type is compatible withdocument.location
andURL
instances in Node.js so that they could be used out of the box.
Parsing
parsePath<a>(Route<a>, URL):?a
Parses given URL
based on pathname
and search
properties, completely ignoring the hash
property. If URL
is a matched returns tuple a
otherwise returns null
.
Route.parsePath(route, document.location)
(route:Route<a>).parsePath(URL):?a
For convenience parsePath
is also exposed as method on Route
instences:
route.parsePath(document.location)
parseHash<a>(Route<a>, URL):?a
Parse given URL
based on hash
and search
properties, completely ignoring the pathname
property. If URL
is a matched returns tuple a
otherwise returns null
.
Note This is mostly for client side web apps that use
hash
based routing.
Route.parseHash(route, document.location)
(route:Route<a>).parsePath(URL):?a
For convenience parseHash
is also exposed as method on route instences:
route.parseHash(document.location)
Primitives
String:RouteParam<string>
Route that parses / formats a segment of the path (or a query parameter) as a tuple with a single string
type item:
Route.String.parsePath({pathname:"alice"}) //> ["alice"]
Route.String.parsePath({pathname:"alice/"}) //> ["alice"]
Route.String.parsePath({pathname:"alice/blog"}) //> null
Route.String.parsePath({pathname: "/alice"}) //> null
Route.String.parsePath({pathname:"42"}) //> ["42"]
Float:RouteParam<float>
Route that parses / formats a segment of the path (or a query param) as a tuple with a single float
type item.
Note:
float
is a subtype ofnumber
exposed as an opquae type alias from float.flow library.Float
route will not parse segments like"NaN"
and"Infinity"
, or in other words it is guaranteed that parsed parameter will be a finite number.
Route.Float.parsePath({pathname:"42/"}) //> [42]
Route.Float.parsePath({pathname:"-42.5/"}) //> [-42.5]
Route.Float.parsePath({pathname:"NaN/"}) //> null
Route.Float.parsePath({pathname:"Infinity/"}) //> null
Route.Float.parsePath({pathname:"Bob/"}) //> null
Note: For convinience library also exports
float
type, but asnumber
subtype it can be treated as such.
Integer:RouteParam<integer>
Parser that parses a segment of the path (or a query param) as tuple with a single integer
item.
Note:
integer
is subtype ofnumber
exposed as an opquae type alias from integer.flow library.Integer
route will not parse segments like"NaN"
,"Infinity"
or any floating point number, or more simply it is guaranteed that parsed parameter will be an integer number.
Route.parsePath(Route.Integer, {pathname:"42/"}) //> [42]
Route.parsePath(Route.Integer, {pathname:"-7"}) //> [-7]
Route.parsePath(Route.Integer, {pathname:"+8"}) //> [8]
Route.parsePath(Route.Integer, {pathname:"42.2/"}) //> null
Route.parsePath(Route.Integer, {pathname:"/"}) //> null
Route.parsePath(Route.Integer, {pathname:"Infinity"}) //> null
Route.parsePath(Route.Integer, {pathname:"NaN/"}) //> null
Note: For convinience also
integer
type, but asnumber
subtype it can be treated as such.
Root:RouteSegment
Paramatelsess route that only matches the root and extracts no parameters hence []
or fails
Route.Root.parsePath({pathname:"/"}) //> []
Route.Root.parsePath({pathname:""}) //> null
Route.Root.parsePath({pathname:"/foo"}) //> null
Route.Root.parsePath({pathname:"bar"}) //> null
Note: Primary use case for
Route.Root
is to provide a foundation for building up absolute path routes.
segment(string):RouteSegment
Creates a parametless route that consumes segment of the URL
if it is equal to passed string and extract no paramters hence []
or fails.
Route.segment("blog").parsePath({pathname:"blog"}) //> []
Route.segment("blog").parsePath({pathname:"blog/"}) //> []
Route.segment("blog").parsePath({pathname:"blog/cat"}) //> null
Route.segment("blog").parsePath({pathname:"/blog/"}) //> null
Route.segment("blog").parsePath({pathname:"glob"}) //> null
Route.segment("blog").parsePath({pathname:"/"}) //> null
Combinators
concat <a, b> (Route<a>, Route<b>):Route<Concat<a, b>>
Takes two routes and combines them into one that parses first part with first left route and rest with the right route returning concatination of their parameters when seccesfull.
const blogID = Route.concat(Route.segment("blog"), Route.Integer)
blogID.parsePath({pathname:"blog/35/"}) //> [35]
blogID.parsePath({pathname:"blog/42/"}) //> [42]
blogID.parsePath({pathname:"blog/"}) //> null
blogID.parsePath({pathname:"42"}) //> null
Note Parsers being concatinated can and often will be, concatinations themself.
const blogSearch = Route.concat(Route.segment("blog"), Route.segment("search"))
const searchTerm = Route.concat(blogSearch, Route.String)
searchTerm.parsePath({pathname:"blog/search/cats/"}) //> ["cats"]
searchTerm.parsePath({pathname:"blog/search/42/"}) //> ["42"]
searchTerm.parsePath({pathname:"/search/cats/"}) //> null
searchTerm.parsePath({pathname:"/blog/cats/"}) //> null
(route:Route<a>).segment(string):Route<a>
For convenience segment
is also available as a method on route instences, which returns a new routes that in addition will also consumes next segment of the URL
if it matches supplied string
.
Route.Root.segment("blog").parsePath({pathname:"/"}) //> null
Route.Root.segment("blog").parsePath({pathname:"/blog"}) //> []
Route.Float.segment("inc").parsePath({pathname:"cat/inc"}) //> null
Route.Float.segment("inc").parsePath({pathname:"7/inc"}) //> [7]
Note: It is just a shortcut for concatination with a new segment:
const blog = Route.concat(Route.Root, Route.segment("blog")) blog.parsePath({pathname:"/"}) //> null blog.parsePath({pathname:"/blog"}) //> [] const inc = Route.concat(Route.Float, Route.segment("inc")) inc.parsePath({pathname:"cat/inc"}) //> null inc.parsePath({pathname:"7/inc"}) //> [7]
(route:Route<a>).param(RouteParam<[b]>):Route<Concat<a,[b]>>
For convenience there is a param
method on route instences, which returns a new routes that will in addion also parse next path segment with a supplied route.
const calculator = Route.Root
.segment("calculator")
.param(Route.Float)
.segment("+")
.param(Route.Float)
calculator.parsePath({pathname:"/calculator/313/+/3"}) //> [313, 3]
calculator.parsePath({pathname:"/calculator/313/+/"}) //> null
calculator.parsePath({pathname:"/calculator/13/+/4.2/"}) //> [13, 4.2]
Note: It is just a shortcut for
concat
function specialized to take aRouteParam<[a]>
rather arbitraryRoute<b>
(which is enforced by type checker).
(route:Route<a>).concat<b>(Route<b>):Route<Concat<a, b>>
For convenience there is a concat
method on route instences
const blogPosts = Route.Root.segment("blog")
const postID = Route.segment("post").param(Route.Integer)
const blogPostID = blogPosts.concat(postID)
blogPostID.parsePath({pathname:"/blog/post/35/"}) //> [35]
blogPostID.parsePath({pathname:"/post/42/"}) //> null
blogPostID.parsePath({pathname:"blog/post/7"}) //> null
blogPostID.parsePath({pathname:"/blog/post/"}) //> null
Note Parsers passed can and often is going to be, a concatination as well.
const search = Route.concat(Route.segment("blog"), Route.segment("search"))
const term = Route.concat(search, Route.String)
term.parsePath({pathname:"blog/search/cats/"}) //> ["cats"]
term.parsePath({pathname:"blog/search/42/"}) //> ["42"]
term.parsePath({pathname:"/search/cats/"}) //> null
term.parsePath({pathname:"/blog/cats/"}) //> null
param<a>(string => ?a, a => string):RouteParam<a>
Takes a parse function that given a string must either nothing null|void
in which case parse fails (returns null
) or a value of type a
in which case parse succeeds (returns [a]
) and format function which given a value a
must return it's seralization string.
Example: Create a route that will match "only CSS files".
const css = Route.param(
($:string):?string => $.endsWith(".css") ? $ : null,
String
)
css.parsePath({pathname:"base.css"}) //> ["base.css"]
css.parsePath({pathname:"fontawesome-webfont.woff2"}) //> null
css.parsePath({pathname:"style/base.css"}) //> null
Note: As with other routes you can use existing combinators to put togather something more evolved.
const stylesheet = Route
.Root
.segment("style")
.param(css)
stylesheet.parsePath({pathname:"/style/base.css"}) //> ["base.css"]
stylesheet.parsePath({pathname:"base.css"}) //> null
stylesheet.parsePath({pathname:"style/base.css"}) //> null
stylesheet.parsePath({pathname:"/style/font.woff2"}) //> null
Query Parameters
Library also provides a way to parse and format query parameters like ?name=tom&age=42
.
query<b>(string, RouteParam<a>):Route<[a]>
Given that query parameters are named (in contrast to path segments that are ordered), you need to bind a RouteParam<a>
to name. This function does exactly that, it takes parameter name and RouteParam<a>
and turns it into Route<[a]>
that parses / formats query parameter for the given name.
const limit = Route.query("limit", Route.Integer)
limit.parsePath({search:"?limit=5"}) //> [5]
limit.parsePath({search:"?limit="}) //> null
limit.parsePath({search:"?limit=0"}) //> [0]
limit.parsePath({search:"?foo&bar&limit=2"}) //> [2]
Note: Since
query
returnsRoute<[a]>
it can be used with all the other route combinators. In fact you can mix query and path routes.
const find = Route
.segment("find")
.param(Route.String)
.concat(limit)
find.parsePath({search:"?limit=5"}) //> null
find.parsePath({pathname:"find",search:"?limit=5"}) //> null
find.parsePath({pathname:"find/cat",search:"?limit=5"}) //> ["cat", 5]
find.parsePath({pathname:"find/cat",search: "?limit=5&sort=asc"}) //> ["cat", 5]
(p:Route<a>).query(string, RouteParam<b>):Route<Concat<a, b>>
For convenience there is also query
method on the Route
instences, which will just concatinate it with a new query.
const seek = Route.
Root.
segment('seek').
param(Route.String).
query('limit', Route.Integer)
seek.parsePath({search:"?limit=5"}) //> null
seek.parsePath({pathname:"/seek",search:"?limit=5"}) //> null
seek.parsePath({pathname:"/seek/cat",search:"?limit=5"}) //> ["cat", 5]
Note: This is simply a shortcut for:
Route .segment('seek') .param(Route.String), .concat(Route.query('limit', Route.Integer))
Formatting
formatPath<a>(Route<a>, ...a):string
Given a Route<a>
and parameters (...a
) returns an appropriate URL
string:
Route.formatPath(
Route.segment("find").param(Route.String),
"cats"
) //> 'find/cats'
Route.formatPath(
Route
.Root
.segment("blog")
.param(Route.String)
.segment("tag")
.param(Route.String)
.segment()
.query("order", Route.String),
"cats",
"breed",
"color"
) //> '/blog/cats/tag/breed/?order=color'
(route:Route<a>).formatPath(...a):string
For convenience formatPath
is also exposed as method on route instences:
Route
.segment("find")
.param(Route.String)
.formatPath("cats") //> 'find/cats'
Route
.Root
.segment("blog")
.param(Route.String)
.segment("tag")
.param(Route.String)
.segment()
.formatPath("cats", "breed") //> '/blog/cats/tag/breed/'
formatHash<a>(Route<a>, ...a):string
Given a Route<a>
and parameters (...a
) returns an appropriate URL
string formatted as hash (convinient for client side routing)
Route.formatHash(
Route.segment("find").param(Route.String),
"cats"
) //> '#find/cats'
Route.formatHash(
Route
.Root
.segment("blog")
.param(Route.String)
.segment("tag")
.param(Route.String)
.segment(),
"cats",
"breed"
) //> '#/blog/cats/tag/breed/'
(route:Route<a>).formatHash(...a):string
For convenience formatHash
is also exposed as method on route instences:
Route
.segment("find")
.param(Route.String)
.formatHash("cats") //> '#find/cats'
Route
.Root
.segment("blog")
.param(Route.String)
.segment("tag")
.param(Route.String)
.segment()
.formatHash("cats", "breed") //> '#/blog/cats/tag/breed/'
format<a>(Route<a>, ...a):URL
Given a Route<a>
and parameters (...a
) returns an appropriate URL
instance.
Route.format(
Route.segment("find").param(Route.String),
"cats"
) //> {pathname: 'find/cats', search:'', hash:''}
Route.format(
Route
.Root
.segment("blog")
.param(Route.String)
.segment("tag")
.param(Route.String)
.segment(),
"cats",
"breed"
) //> {pathname: '/blog/cats/tag/breed/', search:'', hash:''}
(route:Route<a>).format(...a):URL
For convenience format
is also exposed as method on route instences:
Route
.segment("find")
.param(Route.String)
.format("cats") //> {pathname: 'find/cats', search:'', hash:''}
Route
.Root
.segment("blog")
.param(Route.String)
.segment("tag")
.param(Route.String)
.segment()
.format("cats", "breed") //> {pathname: '/blog/cats/tag/breed/', search:'', hash:''}
Install
npm install route.flow
Prior Art
This was started after url-parser package in Elm, but later on moved towards the type safe routing approach used in Spock - A lightweight Haskell web framework. Both are great source of inspiration for this work.