├── test
├── static
│ └── index.html
├── routes
│ ├── albums.js
│ ├── artists.js
│ └── cats.js
├── routes.md
└── index.js
├── .gitignore
├── package.json
├── index.js
├── parser.js
└── README.md
/test/static/index.html:
--------------------------------------------------------------------------------
1 |
Hello, World
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | *.sw*
3 | *.log
4 | .DS_Store
5 |
--------------------------------------------------------------------------------
/test/routes/albums.js:
--------------------------------------------------------------------------------
1 | exports.test = (req, res, context, next) => {
2 | next()
3 | }
4 |
5 | exports.handler = function (req, res, context, next) {
6 | res.statusCode = 201
7 | res.end()
8 | next()
9 | }
10 |
11 |
--------------------------------------------------------------------------------
/test/routes/artists.js:
--------------------------------------------------------------------------------
1 | exports.test = (req, res, context, next) => {
2 | next()
3 | }
4 |
5 | exports.handler = function (req, res, context, next) {
6 | res.statusCode = 201
7 | res.end()
8 | next()
9 | }
10 |
11 |
--------------------------------------------------------------------------------
/test/routes/cats.js:
--------------------------------------------------------------------------------
1 | exports.test = (req, res, context, next) => {
2 | if (context.id === 2) return next(new Error())
3 | next()
4 | }
5 |
6 | exports.handler = function (req, res, context, next) {
7 | res.statusCode = 200
8 | res.end('OK')
9 | next()
10 | }
11 |
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "literate-router",
3 | "version": "1.5.0",
4 | "description": "Markdown powered routing",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "cd test; node index.js"
8 | },
9 | "author": "",
10 | "license": "ISC",
11 | "devDependencies": {
12 | "send": "^0.14.1",
13 | "tape": "^4.6.3"
14 | },
15 | "dependencies": {
16 | "path-to-regexp": "^1.7.0",
17 | "stream-body": "^1.0.2",
18 | "stream-response": "^1.0.0"
19 | },
20 | "directories": {
21 | "test": "test"
22 | },
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/0x00A/literate-router.git"
26 | },
27 | "bugs": {
28 | "url": "https://github.com/0x00A/literate-router/issues"
29 | },
30 | "homepage": "https://github.com/0x00A/literate-router#readme"
31 | }
32 |
--------------------------------------------------------------------------------
/test/routes.md:
--------------------------------------------------------------------------------
1 | # ROUTES EXAMPLE A
2 |
3 | Lorem Ipsum is simply dummy text of the printing and typesetting industry.
4 | Lorem Ipsum has been the industry's standard dummy text ever since the 1500s,
5 | when an unknown printer took a galley of type and scrambled it to make a type
6 | specimen book. It has survived not only five centuries, but also the leap into
7 | electronic typesetting, remaining essentially unchanged. It was popularised in
8 | the 1960s with the release of Letraset sheets containing Lorem Ipsum passages,
9 | and more recently with desktop publishing software like Aldus PageMaker
10 | including versions of Lorem Ipsum.
11 |
12 | RESPONSE EXAMPLE
13 |
14 | ```lang
15 | func foo {
16 | if (true) {
17 | if (false) {
18 | console.log('true')
19 | }
20 | }
21 | return bar
22 | }
23 | ```
24 |
25 |
26 |
27 | GET /artists/listing routes/artists (public)
28 | PUT|POST /data/:artist/:album routes/albums (public)
29 |
30 | # ROUTES EXAMPLE B
31 |
32 | Here is another set of routes. It has survived not only five centuries, but
33 | electronic typesetting, remaining essentially unchanged. It was popularised in
34 | the 1960s with the release of Letraset sheets containing Lorem Ipsum passages,
35 | and more recently with desktop publishing software like Aldus PageMaker
36 | including versions of Lorem Ipsum.
37 |
38 | GET /cats/:id routes/cats (beep, boop)
39 | GET / routes/cats
40 |
41 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const parseUrl = require('url').parse
2 | const parse = require('./parser')
3 | const querystring = require('querystring')
4 |
5 | module.exports = function Router (s, match, nonmatch, resolver) {
6 | const table = parse(s, resolver)
7 |
8 | return function Listener (req, res) {
9 | const parsedUrl = parseUrl(req.url)
10 | const pathname = parsedUrl.pathname
11 | const routed = false
12 |
13 | const next = i => {
14 | const r = table[i]
15 |
16 | if (!r) {
17 | if (!routed) return nonmatch(req, res)
18 | return
19 | }
20 |
21 | const m = r.routeExp.exec(pathname)
22 |
23 | if (m && r.methodExp.test(req.method)) {
24 | const context = {
25 | method: r.method,
26 | route: r.route,
27 | params: {},
28 | query: querystring.parse(parsedUrl.query),
29 | args: r.args
30 | }
31 |
32 | r.routeKeys.map((key, i) => {
33 | const value = m[i + 1]
34 | if (value) context.params[key] = decodeURI(value)
35 | })
36 |
37 | return match(req, res, context, err => {
38 | if (err) return next(++i)
39 |
40 | const handler = () => {
41 | const fn = r.module.handler || r.module
42 |
43 | fn(req, res, context, err => {
44 | if (err) return next(++i)
45 | })
46 | }
47 |
48 | if (!r.module.test) return handler()
49 |
50 | r.module.test(req, res, context, err => {
51 | if (err) return next(++i)
52 | handler()
53 | })
54 | })
55 | }
56 |
57 | next(++i)
58 | }
59 | next(0)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/parser.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const fmt = require('util').format
3 | const pathToRegexp = require('path-to-regexp')
4 |
5 | const lineRE = /^[ \t]{2,}(\S+)\s+(\S+)\s+(\S+)\s*(?:\((.*?)\))?/
6 |
7 | function die (msg, ...args) {
8 | console.error(fmt(msg, ...args))
9 | process.exit(1)
10 | }
11 |
12 | module.exports = function parse (s, resolver) {
13 | let gates = false
14 | return s
15 | .split('\n')
16 | .map((line, index) => {
17 | if (line.includes('```')) gates = !gates
18 | if (gates) return
19 |
20 | const match = line.match(lineRE)
21 | if (!match) return null
22 |
23 | let method = match[1]
24 | const route = match[2]
25 | const pathToModule = match[3]
26 | const args = match[4]
27 |
28 | if (!method) die('Expected method (line #%d)', index)
29 | if (!route) die('Expected tokenized url (line #%d)', index)
30 | if (!pathToModule) die('Expected path to file (line #%d)', index)
31 |
32 | const routeKeys = []
33 | route.replace(/:(\w+)/g, (_, k) => routeKeys.push(k))
34 |
35 | let location = resolver
36 | ? resolver(pathToModule)
37 | : path.join(process.cwd(), pathToModule)
38 |
39 | let module = require(location)
40 |
41 | if (!module.test && !module.handler && typeof module !== 'function') {
42 | die('Expected module to export at least one method (line #%d)', index)
43 | }
44 |
45 | if (args && args.includes('cors')) method += '|OPTIONS'
46 |
47 | return {
48 | method,
49 | methodExp: new RegExp(method),
50 | route,
51 | routeExp: pathToRegexp(route),
52 | routeKeys,
53 | pathToModule,
54 | module,
55 | args
56 | }
57 | })
58 | .filter(r => !!r)
59 | }
60 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SYNOPSIS
2 | Markdown powered routing for building http based APIs. A generalized routing
3 | system meant to be both abstracted and self-documenting.
4 |
5 | # MOTIVATION
6 | Don't allow routing to become tangled with implementations. Enforce
7 | markdown based documentation so it can be served as an endpoint. A single
8 | canonical reference can also be used to test routes reliably.
9 |
10 | # SYNTAX
11 | Lines that are indented using two or more spaces or one or more tabs) are
12 | parsed. All other lines are ignored (considered to be documentation). Arguments
13 | (arbitrary string values separated by commas) are optional.
14 |
15 | ```
16 | Lorem Ipsum is simply dummy text of the printing and typesetting industry.
17 | Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.
18 |
19 | METHOD ROUTE PATH (P1, P2, ...)
20 |
21 | Lorem Ipsum is simply dummy text of the printing and typesetting industry.
22 | Lorem Ipsum has been the industry's standard dummy text ever since the 1500s
23 |
24 | METHOD ROUTE PATH (P1, P2, ...)
25 | METHOD ROUTE PATH (P1, P2, ...)
26 | ```
27 |
28 | ### METHOD
29 | A regular expression, matching the http method, for example `GET|PUT`.
30 |
31 | ### ROUTE
32 | A tokenized route, for example `/books/:page`, see
33 | [this](https://github.com/pillarjs/path-to-regexp#parameters) documentation.
34 |
35 | ### PATH
36 | A path to a file that exports one or more functions to handle testing and
37 | fullfilling the request.
38 |
39 | ### (P1, P2, ...)
40 | An array of arbitrary parameters that can be passed to the supplied functions.
41 |
42 | # USAGE
43 | ## ROUTES.MD
44 | ```md
45 | Lorem Ipsum is simply dummy text of the printing and typesetting industry.
46 | Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.
47 |
48 | GET /books routes/books (5000/hr)
49 | PUT|POST /books/:book routes/books (10000/hr)
50 | ```
51 |
52 | ## SERVER.JS
53 | ```js
54 | const http = require('http')
55 | const Router = require('literate-router')
56 | const send = require('send')
57 | const fs = require('fs')
58 |
59 | function nonmatch (req, res) {
60 | send(req, req.url).pipe(res)
61 | }
62 |
63 | function match (req, res, context, next) {
64 | next()
65 | }
66 |
67 | const routes = fs.readFileSync('./routes.md', 'utf8')
68 | const router = Router(routes, match, nonmatch)
69 |
70 | http.createServer(router).listen(8080)
71 | ```
72 |
73 |
--------------------------------------------------------------------------------
/test/index.js:
--------------------------------------------------------------------------------
1 | const test = require('tape')
2 | const send = require('send')
3 | const http = require('http')
4 | const body = require('stream-body')
5 | const Respond = require('stream-response')
6 | const Router = require('../index')
7 | const fs = require('fs')
8 |
9 | const path = require('path')
10 | const file = path.join(__dirname, 'routes.md')
11 | const routes = fs.readFileSync(file, 'utf8')
12 |
13 | const port = 8080
14 |
15 | const request = {}
16 | ;['get', 'put', 'head', 'post'].map(method => {
17 | request[method] = (path, cb) => http.request({ method, path, port }, cb)
18 | })
19 |
20 | test('setup', assert => {
21 | function nonmatch (req, res) {
22 | const root = path.join(__dirname, 'static')
23 | const s = send(req, req.url, { root })
24 |
25 | s.on('error', err => {
26 | if (err.code === 'ENOENT') {
27 | res.statusCode = 404
28 | return res.end('Not Found')
29 | }
30 | res.statusCode = 500
31 | res.end('Server Error')
32 | })
33 |
34 | s.pipe(res) // serve static files
35 | }
36 |
37 | function match (req, res, context, next) {
38 | const response = Respond(res)
39 | if (context.params.artist === 'Black Sabbath') {
40 | return response.json(200, context)
41 | }
42 | next()
43 | }
44 |
45 | function resolver (p) {
46 | return path.join(__dirname, p)
47 | }
48 |
49 | const router = Router(routes, match, nonmatch, resolver)
50 | http.createServer(router).listen(port, () => assert.end())
51 | })
52 |
53 | test('[passing] not found', assert => {
54 | request.get('/foobar', res => {
55 | res.on('data', d => console.log(d))
56 | res.on('end', () => {
57 | assert.equal(res.statusCode, 404, 'responds not found')
58 | assert.end()
59 | })
60 | }).end()
61 | })
62 |
63 | test('[passing] root found', assert => {
64 | request.get('/', res => {
65 | assert.equal(res.statusCode, 200)
66 | assert.end()
67 | }).end()
68 | })
69 |
70 | test('[passing] fall through to static file', assert => {
71 | request.get('/index.html', res => {
72 | body.parse(res, (err, data) => {
73 | assert.ok(!err, 'no error from parsing body')
74 | assert.equal(data, 'Hello, World
\n', 'correct html')
75 | assert.end()
76 | })
77 | }).end()
78 | })
79 |
80 | test('[failing] test prevents handler from being fired', assert => {
81 | request.get('/cats/2', res => {
82 | assert.ok(res.statusCode, 401, 'responds unauthorized')
83 | assert.end()
84 | }).end()
85 | })
86 |
87 | test('[passing] test allows handler to be fired', assert => {
88 | request.get('/cats/1', res => {
89 | assert.ok(res.statusCode, 200, 'responds ok')
90 | assert.end()
91 | }).end()
92 | })
93 |
94 | test('[failing] method mismatch', assert => {
95 | request.head('/cats/1', res => {
96 | assert.equal(res.statusCode, 404)
97 | assert.end()
98 | }).end()
99 | })
100 |
101 | test('[passing] alternate methods (put)', assert => {
102 | request.put('/data/artist/1', res => {
103 | assert.equal(res.statusCode, 201)
104 | assert.end()
105 | }).end()
106 | })
107 |
108 | test('[passing] alternate methods (post)', assert => {
109 | request.post('/data/artist/1', res => {
110 | assert.equal(res.statusCode, 201)
111 | assert.end()
112 | }).end()
113 | })
114 |
115 | test('[failing] intercepted by match function', assert => {
116 | request.post('/data/Black%20Sabbath/Master%20of%20Reality', res => {
117 | body.parse(res, (err, data) => {
118 | assert.ok(!err, 'Successfully got body of response')
119 | assert.equal(data.method, 'PUT|POST')
120 | assert.equal(data.route, '/data/:artist/:album')
121 | assert.equal(data.params.artist, 'Black Sabbath')
122 | assert.equal(data.params.album, 'Master of Reality')
123 | assert.equal(res.statusCode, 200)
124 | assert.end()
125 | })
126 | }).end()
127 | })
128 |
129 | test('[failing] intercepted by match function (should accept PUT or POST)', assert => {
130 | request.put('/data/Black%20Sabbath/Master%20of%20Reality', res => {
131 | body.parse(res, (err, data) => {
132 | assert.ok(!err, 'Successfully got body of response')
133 | assert.equal(data.method, 'PUT|POST')
134 | assert.equal(data.route, '/data/:artist/:album')
135 | assert.equal(data.params.artist, 'Black Sabbath')
136 | assert.equal(data.params.album, 'Master of Reality')
137 | assert.equal(res.statusCode, 200)
138 | assert.end()
139 | })
140 | }).end()
141 | })
142 |
143 | test('teardown', assert => {
144 | assert.end()
145 | process.exit(0)
146 | })
147 |
--------------------------------------------------------------------------------