route.flow
v0.3.0
Published
Library for type safe routing
Maintainers
Readme
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/+/bobwould 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/+/3to/calculator/plus/313/3it'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/+/bobsincebobis 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
ais always going to be a tuple of paramaters route containst. For all primitive routesais going to be[b]implying that route contains single parameter of typeb. For static routes with no parametersawill be[]. In all other instancesRoute<a>will be comprised of other rotues and havealikeConcat<Concat<[],[integer]>,[string]>which is equivalent of[integer, string]implying that route contains of one static segment and two parameters:integerandstringparameters.
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/:tagnamewhileRouteParam<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:
URLtype is compatible withdocument.locationandURLinstances 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
hashbased 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:
floatis a subtype ofnumberexposed as an opquae type alias from float.flow library.Floatroute 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/"}) //> nullNote: For convinience library also exports
floattype, but asnumbersubtype 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:
integeris subtype ofnumberexposed as an opquae type alias from integer.flow library.Integerroute 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/"}) //> nullNote: For convinience also
integertype, but asnumbersubtype 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"}) //> nullNote: Primary use case for
Route.Rootis 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:"/"}) //> nullCombinators
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"}) //> nullNote 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
concatfunction 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/"}) //> nullNote 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/"}) //> nullparam<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"}) //> nullNote: 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"}) //> nullQuery 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
queryreturnsRoute<[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.flowPrior 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.
