├── 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 | --------------------------------------------------------------------------------