diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9b0055 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# Koa Router +Koa Router with support for recursive nesting and regexp and dynamic urls. No dependecies and lightweight code. + +### Simple Example +```javascript +const + Koa = require('koa'), + router = require('cca-koa-router') + +const + app = new Koa(), + port = 3001 + +app.use(router(_ => { + _.get('/user/:user/', (c, n) => { + c.body = c.request.param['user'] + // GET /user/foo/ => 'foo' + }) +})) + +app.listen(port) +``` + +## Documentation + +- [Options](#options) +- [Modes](#modes) +- [Nesting](#nesting) +- [Methods](#methods) +- [Parameters](#params) + +##### ~ `router(options, builder)` + +##### Options: + +- `prefix` Prefix for the paths +- `end` If trailing paths sould be counted +- `case` Case sentitive + +###### Default +```javascript +{ + prefix: '', + end: false, + case: false +} +``` + +##### Modes: +1. `router(builder)` No options specified, use default +2. `router('string', builder)` String will be taken as the prefix +3. `router({}, builder)` Specify custom options + +###### Example +```javascript +// 1 +app.use(router(_ => { + _.get('/mypath', (c, n) => { + // GET /mypath + c.body = 'Some Response' + }) +})) + +// 2 +app.use(router('/myprefix', _ => { + _.get('/mypath', (c, n) => { + // GET /myprefix/mypath + c.body = 'Some Response' + }) +})) + +// 3 +app.use(router({ + prefix: '/myprefix', + case: true +}, _ => { + _.get('/myPath', (c, n) => { + // GET /myprefix/myPath + c.body = 'Some Response' + }) +})) +```` + +##### Nesting +You can nest recursively `routers`. Each can have its own `options`. + +###### Example + +```javascript +app.use(router(_ => { + _.nest(router('/user', _ => { + _.get('/view', (c, n) => { + c.body = 'View User' + }) + + _.get('/edit', (c, n) => { + c.body = 'Edit User' + }) + })) + + _.get('/', c => { + c.body = 'Root' + }) +})) + +/* +GET / => 'Root' +GET /user/view => 'View User' +GET /user/edit => 'Edit User' +*/ +``` + +##### Methods +Supported methods: +- `GET` +- `POST` +- `PUT` +- `PATCH` +- `DELETE` + +Special "methods": +- `ALL` Used if none other method is defined +- `NEST` Used to nest layers of the router + +###### Example +```javascript +app.use(router(_ => { + _.get('/path', c => { + c.body = 'GET' + }) + _.post('/path', c => { + c.body = 'POST' + }) + + // ... + + _.delete('/path', c => { + c.body = 'DELETE' + }) +})) +``` + +##### Params +The `router` suppors parametrs in the url/path. Parameters will be stored in the `ctx.request.params` object + +###### Example +```javascript +app.use(router(_ => { + _.get('/user/:user/:id/view/:type', (c, n) => { + c.body = c.request.params + }) +})) + +/* +GET /user/foo/123/view/active +=> +{"user":"foo","id":"123","type":"active"} +*/ +``` \ No newline at end of file diff --git a/Router.js b/Router.js new file mode 100644 index 0000000..7c90db4 --- /dev/null +++ b/Router.js @@ -0,0 +1,194 @@ +const + assert = require('assert') + +const + // Regex for the different parts + reg_segment = new RegExp(`([A-z]|[0-9]|-|_|\\.|:)+`), + reg_prefix = new RegExp(`^(\/${reg_segment.source})+$`), + reg_url = new RegExp(`^(\/${reg_segment.source})+`), + + // Allowed methods + METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'ALL'], + + // Default Response + defaultResponse = () => {} + +/** + * Defaults an options object + * @param {*} options + */ +function defaultOptions(options) { + return Object.assign({ + prefix: '', + end: false, + case: false + }, options) +} + +/** + * Creates a regex from a path. + * Options: + * end + * prefix + * case + * @param {String} path + * @param {*} options + */ +function pathToReg(path, options) { + if (path instanceof RegExp) + return path + + assert(typeof path === 'string', 'Path must be a String or RegExp') + + options = defaultOptions(options) + path = options.prefix + path + + // Test Path & Prefix + assert(reg_url.test(path), 'Invalid Path') + assert(options.prefix === '' || reg_prefix.test(options.prefix), 'Invalid Prefix') + + let ret = '^' + for (const seg of path.split('/').slice(1)) + // If segment starts with a ':' make it wildcard + ret += '/' + (seg[0] === ':' ? reg_segment.source : seg) + + if (options.end) + ret += '$' + + return new RegExp(ret, options.case ? '' : 'i') +} + +/** + * Gets the position of each parameter type and returns a map with it + * @param {*} path + */ +function pathToParams(path) { + let params = new Map() + let i = 0 + for (const seg of path.split('/').slice(1)) { + if (seg[0] === ':') + params.set(seg.slice(1), i) + ++i + } + return params +} + +/** + * Takes the builder function and creates a map with all the routes in regex + * @param {*} options + * @param {*} builder provided by user + */ +function mkRouter(options, builder) { + + let routes = new Map() + let routesKeys = new Map() + + // This object will process the builder function + let routesMaker = { + nest: function () { + // Join the lower paths with the current ones + routes = new Map([...routes, ...arguments[0](options.prefix)]) + } + } + + // Add the other methods to the routesMaker object + for (const method of METHODS) + routesMaker[method.toLowerCase()] = function () { + let + key = pathToReg(arguments[0], options), + data = { + fn: arguments[1], + params: pathToParams(arguments[0]) + } + + // If the same regex already exits, grab the object + if (routesKeys.has(key.source)) + key = routesKeys.get(key.source) + else + routesKeys.set(key.source, key) + + // Add the value into the object of GET, POST, etc. + const current = routes.get(key) || {} + let obj = {} + obj[method] = data + routes.set(key, Object.assign(current, obj)) + } + + // Build the routes, including the nested ones + builder(routesMaker) + + return routes +} + +/** + * Chooses the right function for given path and method + * @param {*} routes + * @param {*} path + * @param {*} method + */ +function chooseFn(routes, path, method) { + const + candidates = new Map(), + pathArr = path.split('/').slice(1), + paramObj = {} + + for (const reg of routes.keys()) + if (reg.test(path)) + candidates.set(reg, routes.get(reg)) + + // Choose the route + let route = null + + if (candidates.size === 1) + route = candidates.entries().next().value[1] + else if (candidates.size > 1) + // TODO route chooser + route = candidates.entries().next().value[1] + + if (route === null) + return defaultResponse + + // Choose method or ALL if specific method is not set, but ALL is + let fn = route['ALL'] === undefined ? null : route['ALL'] + if (route[method.toUpperCase()] !== undefined) + fn = route[method.toUpperCase()] + if (fn === null) + return defaultResponse + + // Get the parameters + for (const key of fn.params.keys()) + paramObj[key] = pathArr[fn.params.get(key)] + + return [fn.fn, paramObj] +} + +module.exports = function (options, builder) { + + // If only one argument was given + if (typeof options === 'function' && builder === undefined) { + builder = options + options = {} + } + + if (typeof options === 'string') + options = { + prefix: options + } + assert(options instanceof Object, 'Options can only be a string or object') + options = defaultOptions(options) + + // Build the routes + const routes = mkRouter(options, builder) + + return function (c, n) { + // For building nested routes + if (typeof c === 'string') { + options.prefix = c + options.prefix + return mkRouter(options, builder) + } + + const fn = chooseFn(routes, c.request.url, c.request.method) + c.request.params = fn[1] + fn[0](c, n) + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e7c45b --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "cca-koa-router", + "version": "0.1.0", + "description": "Koa Router", + "main": "Router.js", + "repository": { + "type": "git", + "url": "git+https://github.com/CupCakeArmy/koa-router.git" + }, + "keywords": [ + "Koa", + "Router", + "Middleware", + "Regexp" + ], + "author": "Niccolo Borgioli", + "license": "ISC", + "bugs": { + "url": "https://github.com/CupCakeArmy/koa-router/issues" + }, + "homepage": "https://github.com/CupCakeArmy/koa-router#readme" +} \ No newline at end of file