├── .gitignore ├── .jshintrc ├── .travis.yml ├── samples ├── hello.world.js ├── query.string.js ├── from.callback.js ├── send.file.js ├── post.back.js ├── redirect.js ├── inject.js ├── divide.js ├── cookie.sample.js ├── multiply.js ├── factorial.js ├── check.square.js ├── db.simulation.js └── uncaught.exception.js ├── .jshintignore ├── lib ├── constants.js ├── builtin.when.js ├── producer.js ├── routeDescriptor.js ├── writer.js ├── frhttp.js └── routeExecutor.js ├── all.samples.js ├── package.json ├── LICENSE ├── spec ├── producer.spec.js ├── server.spec.js └── server_runner.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | npm-debug.log -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node" : true //among others, skips the 'use script' warning (http://jshint.com/docs/options/#node) 3 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "1.1.0" 4 | - "0.12" 5 | - "0.11" 6 | - "0.10" 7 | before_script: 8 | - "npm i -g jasmine-node" -------------------------------------------------------------------------------- /samples/hello.world.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | 5 | /** 6 | * basic hello world. 7 | */ 8 | 9 | server.GET('/samples/hello.world').onValue(function (path) { 10 | path.render([], function (writer) { 11 | writer.writeBody('Hello, world!'); 12 | }); 13 | }); 14 | } 15 | 16 | module.exports = createRoute; -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | 3 | ########################################################################################## 4 | # todo: The following to be takne soon, after jshint 5 | # applied to samples and specs. 6 | ########################################################################################## 7 | *sample* 8 | **/*.spec.js 9 | spec/server_runner.js 10 | 11 | -------------------------------------------------------------------------------- /samples/query.string.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | // try navigating to /samples/query.string?a=15&b=22 5 | server.GET('/samples/query.string').onValue(function (route) { 6 | route 7 | .when(server.WHEN.QUERY_STRING) 8 | .render([server.CONSTANTS.QUERY_VARS], function(writer, input) { 9 | writer.writeBody(input[server.CONSTANTS.QUERY_VARS]); 10 | }); 11 | }); 12 | } 13 | 14 | module.exports = createRoute; -------------------------------------------------------------------------------- /samples/from.callback.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var fs = require('fs'); 3 | 4 | function createRoute(server) { 5 | server.GET('/samples/from.callback').onValue(function (route) { 6 | route 7 | .when('stat this file',[],['fstat'], function (producer) { 8 | producer.fromNodeCallback(['fstat'], -1, fs.stat, null, './samples/from.callback.js'); 9 | }) 10 | .render(['fstat'], function(writer, input) { 11 | writer.writeBody(input.fstat); 12 | }); 13 | }); 14 | } 15 | 16 | module.exports = createRoute; -------------------------------------------------------------------------------- /samples/send.file.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | server.GET('/samples/send.file').onValue(function (route) { 5 | route.render([], function(writer) { 6 | writer.writeFile('text/plain', './samples/send.file.js', false); 7 | }); 8 | }); 9 | 10 | server.GET('/samples/send.file/save').onValue(function (route) { 11 | route.render([], function(writer) { 12 | writer.writeFile('text/plain', './samples/send.file.js', 'send.file.js'); 13 | }); 14 | }); 15 | } 16 | 17 | module.exports = createRoute; -------------------------------------------------------------------------------- /samples/post.back.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | /** 5 | * write back whatever is posted. Demonstrates parsing a body and setting status. 6 | */ 7 | 8 | server.POST('/samples/post.back').onValue(function (path) { 9 | path 10 | .when(server.WHEN.BODY) 11 | .render([server.CONSTANTS.REQUEST_BODY], function(writer, input) { 12 | writer.setStatus(200); 13 | writer.writeBody('You sent ' + input[server.CONSTANTS.REQUEST_BODY]); 14 | }); 15 | }); 16 | } 17 | 18 | module.exports = createRoute; -------------------------------------------------------------------------------- /samples/redirect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | 5 | /** 6 | * redirect. 7 | */ 8 | 9 | server.GET('/samples/redirect/target').onValue(function (route) { 10 | route.render([], function (writer) { 11 | writer.writeBody('you were sent here by /samples/redirect'); 12 | }); 13 | }); 14 | 15 | server.GET('/samples/redirect').onValue(function (route) { 16 | route.render([], function (writer) { 17 | writer.redirect('/samples/redirect/target'); 18 | }); 19 | }); 20 | } 21 | 22 | module.exports = createRoute; 23 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | REQUEST_BODY: 'internal:__requestBody', 5 | REQUEST_HEADERS: 'internal:__requestHeaders', 6 | REQUEST_COOKIES: 'internal:__requestCookies', 7 | QUERY_VARS: 'internal:__queryString', 8 | REQUEST: 'request:request', 9 | URL_VARS: 'request:url_vars', 10 | URL_DETAILS : 'request:url', 11 | 12 | URL_VAR_WILDCARD: '*', 13 | 14 | HEADER_CONTENT_TYPE: 'Content-type', 15 | HEADER_CONTENT_LENGTH: 'Content-Length', 16 | HEADER_CONTENT_DISPOSITION: 'Content-Disposition', 17 | HEADER_LAST_MODIFIED: 'Last-Modified', 18 | HEADER_COOKIE: 'Set-Cookie' 19 | }; -------------------------------------------------------------------------------- /all.samples.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var FRHttp = require('./lib/frhttp.js'), 4 | server = FRHttp.createServer(); 5 | 6 | require('./samples/hello.world.js')(server); 7 | require('./samples/multiply.js')(server); 8 | require('./samples/divide.js')(server); 9 | require('./samples/check.square.js')(server); 10 | require('./samples/post.back.js')(server); 11 | require('./samples/factorial.js')(server); 12 | require('./samples/from.callback.js')(server); 13 | require('./samples/inject.js')(server); 14 | require('./samples/db.simulation.js')(server); 15 | require('./samples/uncaught.exception.js')(server); 16 | require('./samples/send.file.js')(server); 17 | require('./samples/query.string.js')(server); 18 | require('./samples/redirect.js')(server); 19 | require('./samples/cookie.sample.js')(server); 20 | 21 | server.listen(8001); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frhttp", 3 | "version": "0.1.12", 4 | "description": "Functional Reactive HTTP server for Node.js. Can be used in place of or along side ExpressJS.", 5 | "main": "./lib/frhttp.js", 6 | "scripts": { 7 | "test": "jasmine-node spec/", 8 | "jshint" : "./node_modules/.bin/jshint . *.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/ayasin/frhttp.git" 13 | }, 14 | "keywords": [ 15 | "frhttp", 16 | "http", 17 | "server" 18 | ], 19 | "author": "ayasin@datatensor.com", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/ayasin/frhttp/issues" 23 | }, 24 | "homepage": "https://github.com/ayasin/frhttp", 25 | "dependencies": { 26 | "baconjs": "~0.7.38", 27 | "immutable": "~3.3.0", 28 | "lodash": "~3.1.0", 29 | "json-stringify-safe": "~5.0.0" 30 | }, 31 | "devDependencies": { 32 | "frisby": "~0.8.5", 33 | "jshint": "~2.5.11" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /samples/inject.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Bacon = require('baconjs'); 3 | 4 | var _ = require('lodash'); 5 | var bus = new Bacon.Bus(); 6 | bus.log(); 7 | 8 | function createRoute(server) { 9 | 10 | /** 11 | * Demonstrates injecting a value or function into a route handler. Also demonstates HOW TO PROPERLY INJECT A BACON 12 | * OBSERVABLE. Due to internal details of how the frhttp server works, you can't just directly inject a bacon observable. 13 | * YOU CAN inject any other kind of object/function. 14 | */ 15 | 16 | server.GET('/samples/inject').onValue(function (route) { 17 | route 18 | .inject({theBus : function () {return bus;}}) 19 | .when('makes another bus', [], ['anotherBus'], function(producer) { 20 | producer.value('anotherBus', new Bacon.Bus()); 21 | return producer.done(); 22 | }) 23 | .render(['theBus'], function (writer, input) { 24 | writer.writeBody('theBus() === bus: ' + (input.theBus() === bus)); 25 | }); 26 | }); 27 | } 28 | 29 | module.exports = createRoute; -------------------------------------------------------------------------------- /samples/divide.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | /** 5 | * Divide 2 numbers. Demonstrates URL_VARS as well producing an error, setting headers and writing a body one part at a time. 6 | */ 7 | 8 | server.GET('/samples/divide/:first/:second').onValue(function (path) { 9 | path 10 | .when('divide',['request:url_vars'], ['div'], function (produce, input) { 11 | var first = input[server.CONSTANTS.URL_VARS].first; 12 | var second = input[server.CONSTANTS.URL_VARS].second; 13 | if (+second === 0) { 14 | produce.error(500, 'Divide by 0'); 15 | return; 16 | } 17 | produce.finalValue('div', first / second); 18 | }). 19 | render(['div'], function(writer, input) { 20 | writer.setHeader(server.CONSTANTS.HEADER_CONTENT_LENGTH, String(input.div).length); 21 | writer.setHeader(server.CONSTANTS.HEADER_CONTENT_TYPE, 'text/html'); 22 | writer.writePartial(input.div); 23 | writer.done(); 24 | }); 25 | }); 26 | } 27 | 28 | module.exports = createRoute; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 ayasin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /samples/cookie.sample.js: -------------------------------------------------------------------------------- 1 | function createRoute(server) { 2 | server.GET('/samples/cookie.sample/write').onValue(function (route) { 3 | route.render([], function (writer) { 4 | writer.setCookie('first_name', 'John'); 5 | writer.setCookie('last_name', 'Doe'); 6 | writer.writePartial('OK. See what was set at /samples/cookie.sample/read'); 7 | writer.done(); 8 | }); 9 | }); 10 | 11 | server.GET('/samples/cookie.sample/read').onValue(function (route) { 12 | route 13 | .when(server.WHEN.COOKIES) 14 | .render([server.CONSTANTS.REQUEST_COOKIES], function (writer, input) { 15 | writer.writeBody(input[server.CONSTANTS.REQUEST_COOKIES]); 16 | }); 17 | }); 18 | 19 | server.GET('/samples/cookie.sample/readHeaders').onValue(function (route) { 20 | route 21 | .when(server.WHEN.COOKIES) 22 | .when(server.WHEN.HEADERS) 23 | .render([server.CONSTANTS.REQUEST_COOKIES, server.CONSTANTS.REQUEST_HEADERS], function (writer, input) { 24 | var allHeaders = input[server.CONSTANTS.REQUEST_HEADERS]; 25 | allHeaders.cookie = input[server.CONSTANTS.REQUEST_COOKIES]; 26 | writer.writeBody(allHeaders); 27 | }); 28 | }); 29 | } 30 | 31 | module.exports = createRoute; -------------------------------------------------------------------------------- /samples/multiply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * creates a route that multiplies 2 numbers. This demonstrates the following features: 5 | * URL_VARS 6 | * enter functions 7 | * 8 | * URL_VARS are the parts of the path with the : in front 9 | * the enter function as demonstrated here allows you to take the input and transform it before it's passed to the 10 | * function. In this case we make the variables a little easier to access. 11 | */ 12 | 13 | function createRoute(server) { 14 | server.GET('/samples/multiply/:first/:second').onValue(function (path) { 15 | path 16 | .inject({factor: 2}) 17 | .when('multiply', ['request:url_vars', 'factor'], ['mul'], function (produce, input) { 18 | produce.value('mul', input.first * input.second); 19 | produce.done(); 20 | }, { 21 | enter: function (input) { 22 | return { 23 | first: input[server.CONSTANTS.URL_VARS].first, 24 | second: input[server.CONSTANTS.URL_VARS].second 25 | }; 26 | } 27 | }) 28 | .render(['mul', 'factor'], function(writer, input) { 29 | writer.writeBody('factoring in (' + input.factor + '): ' + input.mul); 30 | }); 31 | }); 32 | } 33 | 34 | module.exports = createRoute; -------------------------------------------------------------------------------- /samples/factorial.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | /** 5 | * Demonstrates repeatedly calling a function to produce a next value until the final value is produced. Also 6 | * demonstrates filtering/exiting a 'call loop' using the 'enter' function. 7 | */ 8 | 9 | server.GET('/samples/factorial/:number').onValue(function (route) { 10 | route 11 | .when('setup',[server.CONSTANTS.URL_VARS],['max', 'total'], function(produce, input) { 12 | produce.value('max', +input[server.CONSTANTS.URL_VARS].number); 13 | produce.value('total', {count: 1, current: 1}); 14 | produce.done(); 15 | }) 16 | .when('calculate', ['max', 'total'], ['total'], function(produce, input) { 17 | var ret = { 18 | count: input.total.count + 1, 19 | current: input.total.current * input.total.count 20 | }; 21 | produce.value('total', ret); 22 | produce.done(); 23 | }, 24 | { 25 | takeMany: true, 26 | enter: function(input) { 27 | if (input.total.count > input.max) { 28 | return undefined; 29 | } 30 | return input; 31 | } 32 | }) 33 | .render(['max', 'total'], function(writer, input) { 34 | writer.writeBody(String(input.total.current)); 35 | }) 36 | }); 37 | } 38 | 39 | module.exports = createRoute; -------------------------------------------------------------------------------- /samples/check.square.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | /** 5 | * Check if something is a square root (in the worst way possible lol). 6 | * Demonstrates URL_VARS, producing values used by other functions. 7 | * Note that the order of the functions (except render) doesn't matter and you could just 8 | * as easily have 2 independent functions, or 2 independent and 1 dependent function 9 | */ 10 | 11 | server.GET('/samples/check.square/:number/:possibleSqrt').onValue(function (route) { 12 | route 13 | .when('doubleIt',[server.CONSTANTS.URL_VARS],['sqrtToPow2'], function(produce, input) { 14 | var possibleSqrt = +input[server.CONSTANTS.URL_VARS].possibleSqrt; 15 | produce.value('sqrtToPow2', possibleSqrt*possibleSqrt); 16 | produce.done(); 17 | }) 18 | .when('checkIt', [server.CONSTANTS.URL_VARS, 'sqrtToPow2'], ['passed'], function(produce, input) { 19 | var checkNum = +input[server.CONSTANTS.URL_VARS].number; 20 | produce.value('passed', input.sqrtToPow2 === +checkNum); 21 | produce.done(); 22 | }) 23 | .render([server.CONSTANTS.URL_VARS, 'passed'], function(writer, input) { 24 | var num = input[server.CONSTANTS.URL_VARS].number, 25 | possibleSqrt = input[server.CONSTANTS.URL_VARS].possibleSqrt; 26 | if (input.passed) { 27 | writer.writeBody(possibleSqrt + ' is the square root of ' + num); 28 | } 29 | else { 30 | writer.writeBody(possibleSqrt + ' is not the square root of ' + num); 31 | } 32 | }); 33 | }); 34 | } 35 | 36 | module.exports = createRoute; -------------------------------------------------------------------------------- /spec/producer.spec.js: -------------------------------------------------------------------------------- 1 | var producer = require('../lib/producer.js'); 2 | var Bacon = require('baconjs'); 3 | 4 | describe('Producer tests', function () { 5 | var theStream; 6 | 7 | beforeEach(function () { 8 | theStream = new Bacon.Bus(); 9 | }); 10 | 11 | afterEach(function () { 12 | theStream.end(); 13 | }); 14 | 15 | it('should produce a value on the stream', function (done) { 16 | theStream.onValue(function (theVal) { 17 | expect(theVal.name).toBe('valueName'); 18 | expect(theVal.value).toBe('theValue'); 19 | done(); 20 | }); 21 | producer.testFnTap.value(theStream, 'test function', ['valueName'], null, 'valueName', 'theValue'); 22 | }); 23 | 24 | it('should refuse to produce an undeclared value', function (done) { 25 | theStream.onValue(function () { 26 | expect(1).toBe(2); 27 | }); 28 | spyOn(console, 'log'); 29 | producer.testFnTap.value(theStream, 'test function', ['valueName'], null, 'someOtherValueName', 'theValue'); 30 | expect(console.log).toHaveBeenCalled(); 31 | done(); 32 | }); 33 | 34 | it('should finish producing values', function (done) { 35 | theStream.onEnd(function (val) { 36 | done(); 37 | }); 38 | producer.testFnTap.end(theStream); 39 | }); 40 | 41 | it('should not produce a value after end', function (done) {theStream.onValue(function (theVal) { 42 | expect(theVal.name).toBe('valueName'); 43 | expect(theVal.value).toBe('theValue'); 44 | }); 45 | producer.testFnTap.value(theStream, 'test function', ['valueName'], null, 'valueName', 'theValue'); 46 | producer.testFnTap.end(theStream); 47 | producer.testFnTap.value(theStream, 'test function', ['valueName'], null, 'valueName', 'theOtherValue'); 48 | done(); 49 | }); 50 | }); -------------------------------------------------------------------------------- /samples/db.simulation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var _ = require('lodash'); 3 | 4 | function createRoute(server) { 5 | /** 6 | * Demonstrates how to emit a value repeatedly from one part of the route and consume it in another. You could also 7 | * do this within one function (since it's just concating the values together), but we're separating it out here for 8 | * demo purposes. 9 | */ 10 | 11 | server.GET('/samples/db.simulation/:items').onValue(function (route) { 12 | route 13 | /** 14 | * set up the initial values for results and last row. We're using a 'when' instead of 'inject' because 15 | * we want these values to change. 'inject' values are constants. 16 | */ 17 | .when('initially', [], ['result'], function(producer) { 18 | producer.value('result', []); 19 | producer.done(); 20 | }) 21 | /** 22 | * Simulates a database returning 1 row at a time asynchronously (as in next(callback(row))) 23 | */ 24 | .when('simulate', [server.CONSTANTS.URL_VARS], ['row'], function(producer, input) { 25 | var maxIterations = +input[server.CONSTANTS.URL_VARS].items || 10; 26 | for (var i=0; i < maxIterations+1; i++) { 27 | setTimeout(_.partial(function (iteration) { 28 | if (iteration < maxIterations) { 29 | producer.value('row', iteration); 30 | } 31 | else { 32 | producer.done(); 33 | } 34 | }, i), i * 10); 35 | } 36 | }) 37 | /** 38 | * This is the interesting part. This merges all the results in to a single result. 39 | * Note that we avoid getting caught in a loop of 'result updated' by 'triggering on' only row 40 | */ 41 | .when('merge',['row', 'result'],['result'],function(producer, input) { 42 | producer.value('result', input.result.concat([input.row])); 43 | producer.done(); 44 | }, 45 | // we want to run every time only the row changes 46 | { triggerOn: ['row'], takeMany: true }) 47 | .render(['result'], function(writer, input) { 48 | writer.writeBody(input.result); 49 | }); 50 | }); 51 | } 52 | 53 | module.exports = createRoute; -------------------------------------------------------------------------------- /lib/builtin.when.js: -------------------------------------------------------------------------------- 1 | var CONSTANTS = require('./constants.js'); 2 | var Bacon = require('baconjs'); 3 | var qs = require('querystring'); 4 | var _ = require('lodash'); 5 | 6 | var WHEN = {}; 7 | 8 | WHEN.COOKIES = { 9 | params: [CONSTANTS.REQUEST], 10 | produces: [CONSTANTS.REQUEST_COOKIES], 11 | fn: function (producer, input) { 12 | var rawCookies = input[CONSTANTS.REQUEST].headers.cookie; 13 | if (!rawCookies || typeof rawCookies !== 'string') { 14 | producer.value(CONSTANTS.REQUEST_COOKIES, {}); 15 | producer.done(); 16 | return; 17 | } 18 | var cookies = _.reduce(rawCookies.split(';'), function (memo, nextCookie) { 19 | var parts = nextCookie.split('='); 20 | if (parts.length > 1) { 21 | memo[parts.shift().trim()] = parts.join('=').trim(); 22 | } 23 | return memo; 24 | }, {}); 25 | producer.value(CONSTANTS.REQUEST_COOKIES, cookies); 26 | producer.done(); 27 | } 28 | }; 29 | 30 | WHEN.HEADERS = { 31 | params: [CONSTANTS.REQUEST], 32 | produces: [CONSTANTS.REQUEST_HEADERS], 33 | fn: function (producer, input) { 34 | producer.value(CONSTANTS.REQUEST_HEADERS, input[CONSTANTS.REQUEST].headers); 35 | producer.done(); 36 | } 37 | } 38 | 39 | WHEN.BODY = { 40 | params: [CONSTANTS.REQUEST], 41 | produces: [CONSTANTS.REQUEST_BODY], 42 | fn: function (producer, input) { 43 | var partial = new Bacon.Bus(); 44 | partial.fold('', function(memo, chunk) { 45 | return memo + String(chunk); 46 | }).take(1).onValue(function (data) { 47 | producer.value(CONSTANTS.REQUEST_BODY, data); 48 | producer.done(); 49 | }); 50 | 51 | input[CONSTANTS.REQUEST].on('data', function (chunk) { 52 | partial.push(chunk); 53 | }); 54 | 55 | input[CONSTANTS.REQUEST].on('end', function () { 56 | partial.end(); 57 | }); 58 | } 59 | }; 60 | 61 | WHEN.QUERY_STRING = { 62 | params: [CONSTANTS.URL_DETAILS], 63 | produces: [CONSTANTS.QUERY_VARS], 64 | fn : function(producer, input) { 65 | var query = input[CONSTANTS.URL_DETAILS].query; 66 | if (query && query.length) { 67 | producer.value(CONSTANTS.QUERY_VARS, qs.parse(query)); 68 | } 69 | else { 70 | producer.value(CONSTANTS.QUERY_VARS, {}); 71 | } 72 | producer.done(); 73 | } 74 | }; 75 | 76 | module.exports = WHEN; -------------------------------------------------------------------------------- /lib/producer.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | function value(stream, fnName, canMake, post, name, val) { 4 | if (_.indexOf(canMake, name) === -1) { 5 | console.log('ERROR: Function (name: ' + fnName + ') attempted to produce "' + name + '" but only declared ' + JSON.stringify(canMake) + 6 | '. Ignoring.'); 7 | return; 8 | } 9 | var pushObj = { 10 | name: name, 11 | value: val 12 | }; 13 | if (typeof post === 'function') { 14 | stream.push(post(pushObj)); 15 | } 16 | else { 17 | stream.push(pushObj); 18 | } 19 | } 20 | 21 | function finalValue(stream, fnName, canMake, post, name, val) { 22 | value(stream, fnName, canMake, post, name, val); 23 | end(stream); 24 | } 25 | 26 | function error(stream, code, description) { 27 | stream.error({code: code, description: description}); 28 | } 29 | 30 | function end(stream) { 31 | stream.end(); 32 | } 33 | 34 | function fromNodeCallback(producer, produces, cbPos, fn, bindThis) { 35 | if (!_.isArray(produces)) { 36 | produces = [produces]; 37 | } 38 | var fnArgs = Array.prototype.slice.call(arguments, 5); 39 | var callback = function () { 40 | if (!_.isUndefined(arguments[0]) && !_.isNull(arguments[0])) { 41 | return producer.error(500, arguments[0]); 42 | } 43 | var args = Array.prototype.slice.call(arguments, 1); 44 | args.forEach(function (val, i) { 45 | if (produces[i] && produces[i].length) { 46 | producer.value(produces[i], val); 47 | } 48 | }); 49 | producer.done(); 50 | }; 51 | var applyArgs; 52 | if (cbPos < 0 || cbPos >= fnArgs.length) { 53 | applyArgs = fnArgs.concat([callback]); 54 | } 55 | else { 56 | applyArgs = fnArgs.slice(0, cbPos).concat([callback]).concat(fnArgs.slice(cbPos)); 57 | } 58 | fn.apply(bindThis, applyArgs); 59 | } 60 | 61 | function makeProducer(onDef, productionStream) { 62 | var producer = { 63 | value: _.curry(value)(productionStream, onDef.name, onDef.produces, onDef.exit), 64 | finalValue: _.curry(finalValue)(productionStream, onDef.name, onDef.produces, onDef.exit), 65 | error: _.curry(error)(productionStream), 66 | done: _.partial(end, productionStream) 67 | }; 68 | 69 | producer.fromNodeCallback = _.curry(fromNodeCallback)(producer); 70 | return producer; 71 | } 72 | 73 | module.exports = { 74 | makeProducer : makeProducer, 75 | testFnTap : { 76 | value: value, 77 | error : error, 78 | end: end, 79 | fromNodeCallback: fromNodeCallback 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /samples/uncaught.exception.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function createRoute(server) { 4 | /** 5 | * Demonstrates what happens when a function has an uncaught exception. HINT: it doesn't crash the server and 6 | * you get a very helpful message on the console 7 | */ 8 | 9 | server.GET('/samples/uncaught.exception').onValue(function (path) { 10 | path 11 | .inject({demo: 'a value'}) 12 | .when('Crashes On Purpose', ['demo'], [], function(produce, input) { 13 | input.crashNow(); // oops input doesn't have a crashNow function. 14 | produce.done(); 15 | }) 16 | .render([], function (writer) { 17 | writer.writeBody('Never going to get here...!'); 18 | }); 19 | }); 20 | 21 | server.GET('/samples/uncaught.exception/custom.handler').onValue(function (path) { 22 | path 23 | .inject({demo: 'a value'}) 24 | .setCustomErrorHandler(function (writer, code, description) { 25 | writer.setStatus(code); 26 | writer.writeBody(code + ' : An error occurred (' + description + '), but I intercepted it and wrote this to the client'); 27 | }) 28 | .when('Crashes On Purpose', ['demo'], [], function(produce, input) { 29 | input.crashNow(); // oops input doesn't have a crashNow function. 30 | produce.done(); 31 | }) 32 | .render([], function (writer) { 33 | writer.writeBody('Never going to get here...!'); 34 | }); 35 | }); 36 | 37 | server.GET('/samples/uncaught.exception/enter').onValue(function (path) { 38 | path 39 | .inject({demo: 'a value'}) 40 | .when('Crashes On Purpose', ['demo'], [], function(produce) { 41 | produce.done(); 42 | }, 43 | { 44 | enter: function (input) { 45 | input.crashNow(); // oops input doesn't have a crashNow function. 46 | } 47 | }) 48 | .render([], function (writer) { 49 | writer.writeBody('Never going to get here...!'); 50 | }) 51 | }); 52 | 53 | server.GET('/samples/uncaught.exception/exit').onValue(function (path) { 54 | path 55 | .inject({demo: 'a value'}) 56 | .when('Crashes On Purpose', ['demo'], ['a', 'b'], function(produce) { 57 | produce.value('a', 5); 58 | produce.value('b', 5); 59 | produce.done(); 60 | }, { 61 | exit: function (input) { 62 | input.crashNow(); // oops input doesn't have a crashNow function. 63 | } 64 | }) 65 | .render(['a'], function (writer) { 66 | writer.writeBody('Never going to get here...!'); 67 | }) 68 | }); 69 | 70 | 71 | } 72 | 73 | module.exports = createRoute; 74 | -------------------------------------------------------------------------------- /spec/server.spec.js: -------------------------------------------------------------------------------- 1 | var frhttp = require('../lib/frhttp.js'); 2 | var frisby = require('frisby'); 3 | var child = require('child_process'); 4 | 5 | describe('Server Tests', function () { 6 | var server; 7 | 8 | 9 | it('should create a server.', function (done) { 10 | server = frhttp.createServer(); 11 | expect(typeof server).toBe('object'); 12 | expect(typeof server.GET).toBe('function'); 13 | done(); 14 | }); 15 | 16 | var previousRoute; 17 | it('should be create a route', function (done) { 18 | server.GET('/api/tester').onValue(function (route) { 19 | expect(route).not.toBe(null); 20 | expect(route.parts.length).toBe(3); 21 | expect(Object.keys(route.variables).length).toBe(0); 22 | previousRoute = route; 23 | done(); 24 | }) 25 | }); 26 | 27 | it('should be able to find the same route to add configs', function (done) { 28 | server.GET('/api/tester').onValue(function (route) { 29 | expect(previousRoute).toBe(route); 30 | done(); 31 | }) 32 | }); 33 | 34 | var errorOnBuiltInResponder = false; 35 | 36 | it('should be able to create a non-rest route', function (done) { 37 | server.NON_REST('/api/tester/customRender').onValue(function (route) { 38 | route.when('custom render test', ['msg'], ['rmsg'], function (produce, input) { 39 | produce.value('rmsg', input.msg.split('').reverse().join('')); 40 | produce.done(); 41 | }).render(['rmsg'], function (writer, input) { 42 | if (errorOnBuiltInResponder) { 43 | expect(1).toBe(2); 44 | } 45 | writer.checkVal('attached renderer says ' + input.rmsg); 46 | }); 47 | done(); 48 | }); 49 | }); 50 | 51 | it('should be able to execute a non-rest route with a custom renderer and the route responder', function (done) { 52 | var responder = { 53 | error : function () { 54 | expect(1).toBe(0); 55 | }, 56 | checkVal : function (data) { 57 | expect(data).toBe('attached renderer says ti tset'); 58 | done(); 59 | } 60 | }; 61 | server.runRouteWithRender(responder, 'NON_REST', '/api/tester/customRender', {msg: 'test it'}); 62 | }); 63 | 64 | it('should be able to execute a non-rest route with a custom renderer and custom responder', function (done) { 65 | var responder = { 66 | error : function () { 67 | expect(1).toBe(0); 68 | }, 69 | checkVal : function (data) { 70 | expect(data).toBe('custom renderer says ti tset'); 71 | done(); 72 | } 73 | }; 74 | server.runRouteWithRender(responder, 'NON_REST', '/api/tester/customRender', {msg: 'test it'}, ['rmsg'], function (writer, input) { 75 | writer.checkVal('custom renderer says ' + input.rmsg); 76 | }); 77 | }); 78 | 79 | it('should be able to execute a non-rest route without a responder', function (done) { 80 | errorOnBuiltInResponder = true; 81 | server.runRouteWithRender(null, 'NON_REST', '/api/tester/customRender', {msg: 'test it'}); 82 | done(); 83 | }); 84 | }); 85 | 86 | describe('Route descriptor and executor tests', function() { 87 | var runningServer; 88 | 89 | it('should spawn a server', function (done) { 90 | runningServer = child.spawn('node', ['./spec/server_runner.js']); 91 | setTimeout(function () {done();}, 1000); 92 | }); 93 | 94 | frisby.create('basic hello') 95 | .get('http://localhost:8008/test/hello').expectStatus(200).expectBodyContains('hello').toss(); 96 | 97 | frisby.create('wildcard in route') 98 | .get('http://localhost:8008/test/wild/*/next/part/of/path').expectStatus(200).expectBodyContains('next/part/of/path').toss(); 99 | 100 | frisby.create('wildcard override') 101 | .get('http://localhost:8008/test/wild/override').expectStatus(200).expectBodyContains('no wildcard in route').toss(); 102 | 103 | frisby.create('wildcard override correctly 404s on no match after override') 104 | .get('http://localhost:8008/test/wild/override/bingo').expectStatus(404).toss(); 105 | 106 | frisby.create('url variables') 107 | .get('http://localhost:8008/test/divide/4/2').expectStatus(200).expectBodyContains('2').toss(); 108 | 109 | frisby.create('error production') 110 | .get('http://localhost:8008/test/divide/4/0').expectStatus(500).expectBodyContains('Divide by 0').toss(); 111 | 112 | frisby.create('inject constant') 113 | .get('http://localhost:8008/test/multiply/2/2').expectStatus(200).expectBodyContains('factoring in (2): 8').toss(); 114 | 115 | frisby.create('call blocks in order of production') 116 | .get('http://localhost:8008/test/processOrder').expectStatus(200).expectBodyContains('callFirst callNext callLast').toss(); 117 | 118 | frisby.create('skip a block if the values can\'t be produced') 119 | .get('http://localhost:8008/test/skipNotProduced').expectStatus(200).expectBodyContains('callFirst callLast').toss(); 120 | 121 | frisby.create('post') 122 | .post('http://localhost:8008/test/replay', {a: 15}, {json: true}) 123 | .expectStatus(200).expectBodyContains('You sent {"a":15}').toss(); 124 | 125 | frisby.create('parse a query string correctly') 126 | .get('http://localhost:8008/test/query.parse?a=10&bob=alice').expectStatus(200).expectJSON({a: '10', 'bob' : 'alice'}).toss(); 127 | 128 | frisby.create('enter filter and recursive production') 129 | .get('http://localhost:8008/test/factorial/4').expectStatus(200).expectBodyContains('24').toss(); 130 | 131 | frisby.create('exit filter') 132 | .get('http://localhost:8008/test/makeFactorialNegative/4').expectStatus(200).expectBodyContains('-24').toss(); 133 | 134 | it('should kill the server', function (done) { 135 | runningServer.kill('SIGTERM'); 136 | done(); 137 | }) 138 | }); 139 | -------------------------------------------------------------------------------- /lib/routeDescriptor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Bacon = require('baconjs'); 3 | var Immutable = require('immutable'); 4 | var _ = require('lodash'); 5 | var CONSTANTS = require('./constants.js'); 6 | 7 | var defaultWhenDefinition = { 8 | name: 'unnamed function', 9 | params: [], 10 | produces:[], 11 | triggerOn: [], 12 | enter: null, 13 | exit: null, 14 | takeMany: false 15 | }; 16 | 17 | function whenGrouper(memo, next) { 18 | //for efficiency we're mutating the memo rather than creating a new one and returning it 19 | var allFields = next.params.concat(next.produces || []).concat(next.triggerOn || []); 20 | var list = Immutable.fromJS(allFields); 21 | if (!memo.allBuses.count() && allFields.length) { 22 | memo.allBuses = Immutable.Set(allFields).asMutable(); 23 | } 24 | else { 25 | memo.allBuses.merge(list); 26 | } 27 | memo.mapDefinitions.push(next); 28 | return memo; 29 | } 30 | 31 | function flatPush(stream, messages) { 32 | if (!_.isArray(messages)) { 33 | return stream.push(_.defaults(messages, defaultWhenDefinition)); 34 | } 35 | Bacon.fromArray(_.flattenDeep(messages)).onValue( function (stream, message) { 36 | stream.push(_.defaults(message, defaultWhenDefinition)); 37 | }, stream); 38 | } 39 | 40 | function makeRouteDescriptor(pathParts) { 41 | var vars = {}; 42 | var wildCardAt = undefined; 43 | for (var i=0; i input.max) { 185 | return undefined; 186 | } 187 | return input; 188 | }, 189 | fn: function(produce, input) { 190 | var ret = { 191 | count: input.total.count + 1, 192 | current: input.total.current * input.total.count 193 | }; 194 | produce.value('total', ret); 195 | produce.done(); 196 | } 197 | }).render( 198 | { 199 | params: ['max', 'total'], 200 | fn: function(writer, input) { 201 | writer.writeBody(String(input.total.current)); 202 | } 203 | } 204 | ); 205 | }); 206 | 207 | server.GET('/test/makeFactorialNegative/:number').onValue(function (route) { 208 | route.when({ 209 | name: 'setup', 210 | params: [server.CONSTANTS.URL_VARS], 211 | produces: ['max', 'total'], 212 | fn: function(produce, input) { 213 | produce.value('max', +input[server.CONSTANTS.URL_VARS].number); 214 | produce.value('total', {count: 1, current: 1}); 215 | produce.done(); 216 | } 217 | }).when({ 218 | name: 'calculate', 219 | params: ['max', 'total'], 220 | produces: ['total'], 221 | takeMany: true, 222 | enter: function(input) { 223 | if (input.total.count > input.max) { 224 | return undefined; 225 | } 226 | return input; 227 | }, 228 | exit: function (obj) { 229 | if (obj.value.current > 0) { 230 | obj.value.current = -obj.value.current; 231 | } 232 | return obj; 233 | }, 234 | fn: function(produce, input) { 235 | var ret = { 236 | count: input.total.count + 1, 237 | current: input.total.current * input.total.count 238 | }; 239 | produce.value('total', ret); 240 | produce.done(); 241 | } 242 | }).render( 243 | { 244 | params: ['max', 'total'], 245 | fn: function(writer, input) { 246 | writer.writeBody(String(input.total.current)); 247 | } 248 | } 249 | ); 250 | }); 251 | 252 | server.GET('/test/query.parse').onValue(function (route) { 253 | route.when(server.WHEN.QUERY_STRING) 254 | .render({ 255 | params: [server.CONSTANTS.QUERY_VARS], 256 | fn: function(writer, input) { 257 | writer.writeBody(input[server.CONSTANTS.QUERY_VARS]); 258 | } 259 | }); 260 | }); 261 | 262 | server.listen(8008); 263 | -------------------------------------------------------------------------------- /lib/routeExecutor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Bacon = require('baconjs'); 3 | var _ = require('lodash'); 4 | var safeJSONStringify = require('json-stringify-safe'); 5 | var Producer = require('./producer.js'); 6 | var Writer = require('./writer.js'); 7 | 8 | var CONSTANTS = require('./constants.js'); 9 | 10 | var READY_STREAM = 'internal:__ready'; 11 | var PROCESS_FINISHED_SIGNAL = 'internal:__process_finished_signal'; 12 | 13 | // process phase 14 | function resolvePathVariables(routeHandler, pathParts) { 15 | var resolvedVars = {}; 16 | var keys = Object.keys(routeHandler.variables); 17 | for (var i = 0; i < keys.length; i++) { 18 | resolvedVars[routeHandler.variables[keys[i]]] = pathParts[keys[i]]; 19 | } 20 | if (routeHandler.hasWildcard) { 21 | resolvedVars[CONSTANTS.URL_VAR_WILDCARD] = pathParts.slice(routeHandler.wildCardAt).join('/'); 22 | } 23 | return resolvedVars; 24 | } 25 | 26 | function makeBuses(injected, allRequiredVars) { 27 | var initial = {}; 28 | initial[READY_STREAM] = new Bacon.Bus(); 29 | initial[CONSTANTS.DATA_ARRIVED] = new Bacon.Bus(); 30 | initial[CONSTANTS.DATA_FINISHED] = new Bacon.Bus(); 31 | 32 | return _.reduce(allRequiredVars, _.curry(function (inject, memo, next) { 33 | if (inject[next]) { 34 | memo[next] = Bacon.constant(inject[next]); 35 | } 36 | else { 37 | memo[next] = new Bacon.Bus(); 38 | } 39 | return memo; 40 | })(injected),initial); 41 | } 42 | 43 | function convertToTemplate(buses, runner, mainProducer, onDef) { 44 | //var producerFns = Producer.makeProducer(onDef, mainProducer, runner); 45 | var initial = {}; 46 | initial[READY_STREAM] = buses[READY_STREAM]; 47 | var sampler = undefined; 48 | if (onDef.triggerOn && onDef.triggerOn.length) { 49 | var sampleTemplateDef = _.reduce(onDef.triggerOn, function (memo, next) { 50 | memo[next] = buses[next]; 51 | return memo; 52 | }, {}); 53 | sampler = Bacon.combineTemplate(sampleTemplateDef); 54 | } 55 | var template = _.reduce(onDef.params, function (memo, next) { 56 | if (sampler) { 57 | memo[next] = buses[next].sampledBy(sampler); 58 | } 59 | else { 60 | memo[next] = buses[next]; 61 | } 62 | return memo; 63 | }, initial); 64 | var baconTemplate = Bacon.combineTemplate(template).doAction( 65 | function (runNoif) { 66 | runNoif.push(1); 67 | }, runner 68 | ).endOnError(); 69 | baconTemplate.map(function (runCounter, mainProd, val) { 70 | var subProducer = new Bacon.Bus(); 71 | mainProd.plug(subProducer); 72 | subProducer.onEnd(function() { 73 | runCounter.push(-1); 74 | }); 75 | subProducer.onError(function(err) { 76 | mainProducer.error(err); 77 | runCounter.push(-1); 78 | subProducer.end(); 79 | }); 80 | return { 81 | val: val, 82 | prodObj: Producer.makeProducer(onDef, subProducer) 83 | }; 84 | }, runner, mainProducer).onValue(function (fn, name, enter, takeMany, template) { 85 | var prodObj = template.prodObj; 86 | var val = template.val; 87 | runner = function () { 88 | try { 89 | if (fn) { 90 | fn(prodObj, val); 91 | } 92 | else { 93 | console.log(name + ' is missing an execution function. You probably forgot to add fn to the object or ' + 94 | 'you didn\'t use the correct signature for the when(name, in, out, fn, opts) function'); 95 | throw('Missing fn to execute'); 96 | } 97 | } 98 | catch (err) { 99 | console.log('ERROR: function (' + name + ') threw an exception (' + err + ') on input: \r\n' + safeJSONStringify(val, null, 2)); 100 | console.log('Stack Trace: ' + err.stack); 101 | prodObj.error(500, err); 102 | } 103 | }; 104 | try { 105 | if (typeof onDef.enter === 'function') { 106 | val = onDef.enter(val); 107 | } 108 | if (val) { 109 | process.nextTick(function () { 110 | runner(); 111 | }); 112 | } 113 | else { 114 | prodObj.done(); 115 | } 116 | } 117 | catch (err) { 118 | console.log('ERROR: enter transform for function (' + name + 119 | ') threw an exception (' + err + ') on input: \r\n' + 120 | safeJSONStringify(val, null, 2)); 121 | prodObj.error(500, err); 122 | console.log('Stack Trace: ' + err.stack); 123 | } 124 | if (!takeMany) { 125 | return Bacon.noMore; 126 | } 127 | }, onDef.fn, onDef.name, onDef.enter, onDef.takeMany); 128 | } 129 | 130 | function makeProcessTemplate(processBlock, buses) { 131 | var running = new Bacon.Bus(); 132 | var produce = new Bacon.Bus(); 133 | _.forEach(processBlock.mapDefinitions.toJS(), _.curry(convertToTemplate)(buses, running, produce)); 134 | produce.onError(function (endBuses, error){ 135 | _.forEach(Object.keys(endBuses), function (key) { 136 | if (typeof endBuses[key].error === "function") { 137 | endBuses[key].end();//.error(error); 138 | } 139 | }); 140 | running.error(error); 141 | }, buses); 142 | produce.endOnError().onValue(function (val) { 143 | if (typeof val === 'object') { 144 | buses[val.name].push(val.value); 145 | } 146 | }); 147 | produce.endOnError().onEnd(function (endBuses) { 148 | _.forEach(Object.keys(endBuses), function (key) { 149 | if (typeof endBuses[key].end === "function") { 150 | endBuses[key].end(); 151 | } 152 | }); 153 | running.end(); 154 | }, buses); 155 | 156 | var busesAsProps = _.reduce(Object.keys(buses), function (memo, key) { 157 | memo[key] = buses[key].toProperty().startWith(null); 158 | return memo; 159 | }, {}); 160 | 161 | busesAsProps[PROCESS_FINISHED_SIGNAL] = buses[READY_STREAM].sampledBy(running.endOnError().scan(0, function (memo, action) { 162 | return memo + action; 163 | }).skip(1).filter(function (done) { 164 | return done === 0; 165 | }).doAction(function () { 166 | produce.end(); 167 | }).toProperty()); 168 | 169 | return Bacon.combineTemplate(busesAsProps); 170 | } 171 | 172 | // render phase 173 | function executeRender(renderBlock, responder, renderVars) { 174 | 175 | var initial = {}; 176 | var template = _.reduce(renderBlock.params, function (memo, next) { 177 | memo[next] = renderVars[next]; 178 | return memo; 179 | }, initial); 180 | Bacon.combineTemplate(template).onValue(function (fn, respObj, val) { 181 | fn(respObj, val); 182 | }, renderBlock.fn, responder); 183 | } 184 | 185 | function customRenderRouteExecutor(handler, phases, res, path, inject, params, fn) { 186 | phases.onValue(function (phaseDefinitions) { 187 | var resolvedPathVars = resolvePathVariables(handler, path.split('/')); 188 | inject = _.defaults(inject || {}, phaseDefinitions.inject || {}); 189 | //var injected = _.cloneDeep(inject); 190 | var injected = inject; 191 | injected[CONSTANTS.URL_VARS] = resolvedPathVars; 192 | var processBuses = makeBuses(injected, phaseDefinitions.process.allBuses.toJS()); 193 | 194 | var processTemplate = makeProcessTemplate(phaseDefinitions.process, processBuses); 195 | var innerRender = phaseDefinitions.render ? phaseDefinitions.render.mapDefinitions.toJS()[0] : {}; 196 | var render = { 197 | params: params || innerRender.params, 198 | fn: fn || innerRender.fn 199 | }; 200 | 201 | processTemplate.onValue(function (renderDef, responder, processed) { 202 | if (responder) { 203 | executeRender(renderDef, responder, processed); 204 | } 205 | 206 | }, render, res); 207 | 208 | processTemplate.onError(function (responder, error) { 209 | if (responder) { 210 | responder.error(error); 211 | } 212 | else { 213 | console.log('No responder was specificed but an error occured on route ' + path); 214 | console.log(safeJSONStringify(error, null, 2)); 215 | } 216 | }, res); 217 | 218 | processBuses[READY_STREAM].push(1); 219 | }); 220 | } 221 | 222 | function routeExecutor(handler, phases, path, req, res, inject) { 223 | phases.onValue(function (phaseDefinitions) { 224 | var resolvedPathVars = resolvePathVariables(handler, path.pathname.split('/')); 225 | inject = _.defaults(inject || {}, phaseDefinitions.inject || {}); 226 | //var injected = _.cloneDeep(inject); 227 | var injected = inject; 228 | injected[CONSTANTS.URL_VARS] = resolvedPathVars; 229 | injected[CONSTANTS.URL_DETAILS] = path; 230 | injected[CONSTANTS.REQUEST] = req; 231 | var processBuses = makeBuses(injected, phaseDefinitions.process.allBuses.toJS()); 232 | 233 | var processTemplate = makeProcessTemplate(phaseDefinitions.process, processBuses); 234 | var render = phaseDefinitions.render.mapDefinitions.toJS()[0]; 235 | 236 | 237 | processTemplate.onValue(function (renderDef, responder, processed) { 238 | executeRender(renderDef, Writer.makeWriter(responder), processed); 239 | 240 | }, render, res); 241 | 242 | processTemplate.onError(function (h, responder, error) { 243 | if (typeof h.customError === 'function') { 244 | h.customError(Writer.makeWriter(responder), error.code, error.description); 245 | } 246 | else { 247 | responder.writeHead(error.code); 248 | responder.write('Error ' + error.code + ': ' + error.description); 249 | responder.end(); 250 | } 251 | }, handler, res); 252 | 253 | processBuses[READY_STREAM].push(1); 254 | }); 255 | } 256 | 257 | module.exports = { 258 | httpExecutor: routeExecutor, 259 | customRenderExecutor: customRenderRouteExecutor 260 | }; 261 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | FRHTTP [![Build Status](https://travis-ci.org/ayasin/frhttp.svg?branch=master)](https://travis-ci.org/ayasin/frhttp) 2 | ========= 3 | 4 | [![Join the chat at https://gitter.im/ayasin/frhttp](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/ayasin/frhttp?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 5 | 6 | > Simplicity is prerequisite for reliability. 7 | > 8 | > -Edsger W. Dijkstra 9 | 10 | FRHTTP is a backend web framework designed to facilitate the development of functional reactive web services/sites. Incidentally, FRHTTP stands for **F**unctional **R**eactive **H**yper**t**ext **T**ransfer **P**rotocol. 11 | 12 | ## Key benefits: ## 13 | * eliminates callback hell 14 | * easy to reason about 15 | * easier to test (since functions can be tested in isolation) 16 | * FAR better error messages 17 | * facilitates code reuse (potentially even with your frontend) 18 | * can be used either standalone or as part of your existing Express project 19 | 20 | ## Install ## 21 | 22 | ``` 23 | npm install frhttp 24 | ``` 25 | 26 | ## Quick Start ## 27 | * Initialize your node project folder. 28 | * Install FRHTTP (please see above). 29 | * Copy the code below into your main javascript file, e.g. server.js. 30 | * Run the app, e.g. `node server.js` 31 | * In your browser, open up the URL, http://localhost:8000/hello 32 | * Enjoy your first FRHTTP application! 33 | ```js 34 | var FRHTTP = require('frhttp'); 35 | 36 | var server = FRHTTP.createServer(); 37 | 38 | server.GET('/hello').onValue(function (route) { 39 | route.when({ 40 | name: 'hello_world', 41 | params: [], 42 | produces: ['message'], 43 | fn: function (produce, input) { 44 | produce.value('message', 'hello, world'); 45 | produce.done(); 46 | } 47 | }).render({ 48 | params: ['message'], 49 | fn: function (writer, input) { 50 | writer.writeBody(input.message); 51 | } 52 | }); 53 | }); 54 | 55 | server.listen(8000); 56 | ``` 57 | 58 | 59 | ## Detailed Docs ## 60 | 61 | [Detailed docs can be found in the wiki](https://github.com/ayasin/frhttp/wiki) 62 | 63 | ## User Guide ## 64 | 65 | *Note: this info is being migrated to the wiki along with additional details. For now, if you're new, it's worth reading both* 66 | 67 | ### Creating a server ### 68 | 69 | To create a server, import the FRHTTP library and call createServer. The call should look something like this: 70 | 71 | ```js 72 | var server = require('frhttp').createServer(); 73 | ``` 74 | 75 | ### Defining a route ### 76 | 77 | Defining routes in FRHTTP is relatively easy. Routes are separated by HTTP verbs (GET, POST, PUT, DELETE) and can be created or retrieved via similarly named methods on the server object. The method calls return a stream (in usage here, conceptually similar to a promise) with an onValue method. The onValue method takes a function which will receive the route. Once you have the route you can set up the `when` functions as well as the render function. Lets look at some code: 78 | 79 | ```js 80 | server.GET('/api/isSquareRoot/:number/:possibleSqrt').onValue(function (route) { 81 | route.when({ 82 | name: 'doubleIt', 83 | params: [server.CONSTANTS.URL_VARS], 84 | produces: ['sqrtToPow2'], 85 | fn: function(produce, input) { 86 | var possibleSqrt = +input[server.CONSTANTS.URL_VARS].possibleSqrt; 87 | produce.value('sqrtToPow2', possibleSqrt*possibleSqrt); 88 | produce.done(); 89 | } 90 | }).when({ 91 | name: 'checkIt', 92 | params: [server.CONSTANTS.URL_VARS, 'sqrtToPow2'], 93 | produces: ['passed'], 94 | fn: function(produce, input) { 95 | var checkNum = +input[server.CONSTANTS.URL_VARS].number; 96 | produce.value('passed', input.sqrtToPow2 === +checkNum); 97 | produce.done(); 98 | } 99 | }).render({ 100 | params: [server.CONSTANTS.URL_VARS, 'passed'], 101 | fn: function(writer, input) { 102 | var num = input[server.CONSTANTS.URL_VARS].number, 103 | possibleSqrt = input[server.CONSTANTS.URL_VARS].possibleSqrt; 104 | if (input.passed) { 105 | writer.writeBody(possibleSqrt + ' is the square root of ' + num); 106 | } 107 | else { 108 | writer.writeBody(possibleSqrt + ' is not the square root of ' + num); 109 | } 110 | } 111 | }); 112 | }); 113 | ``` 114 | 115 | A couple of things to note before we get started on analyzing what's going on here. First, the + in front of a variable such as on this line ```var possibleSqrt = +input[server.CONSTANTS.URL_VARS].possibleSqrt;``` converts whatever that field is to a number so we can do math operations on it. Second, for illustrative purposes, we've intentionally taken a very long (horribly non-optimal) approach to figuring out if a number is the sqrt of another. 116 | 117 | Lets analyze this code from the top. First we see that we're configuring a GET route at `/api/isSquareRoot/:number/:possibleSqrt`. The parts of the URL here with the `:` in front are "url variables". This means that in our real URL, they'll be something else, but the frhttp executor will extract them for us into a field from which we can get a nice mapping from what we have here to what actually appeared in the URL. We'll see this a few lines down. 118 | 119 | Recall from earlier we mentioned that the GET (and in fact any server function other than listen) returns a stream. We call the onValue method of that stream to get our actual route for configuration. 120 | 121 | Once we have our route we can start configuring it. A route has 2 phases, `process` and `render`. In the process phase, we set up functions to be called when data is ready. To do this, we use the `when` method of the route. Note that `when` is chainable so we don't need to keep calling `route`. `when` takes a function definition object. Here's what that object contains: 122 | 123 | field | required | description 124 | ------|----------|--------------- 125 | name | No | the name of the function, used for debugging and error reporting purposes. While this is optional it's highly recommended. 126 | params | No | the parameters you require. These will be passed to you as an object to the second parameter to your function. If you don't require any parameters you can omit this field. 127 | produces | No | the parameters your function produces. 128 | fn | Yes | the function to execute when all the parameters are ready. 129 | enter exit, inject, takeMany | No | parameters for advanced usage described in the API section below 130 | 131 | Our first when definition expects the url variables, and produces a field called `sqrtToPow2`, our second when definition (the order doesn't matter here, it would work just fine to make this the first function) takes the url variables and `sqrtToPow2` as inputs and produces a field called `passed`. Finally our render function takes the url variables and `passed` as inputs to write out the result. 132 | 133 | The function in the when definition takes 2 parameters. First is the producer. This has 4 methods: 134 | ```js 135 | { 136 | value: function (name, value), 137 | done: function (), 138 | error: function(httpErrorCode, description) 139 | fromNodeCallback: function(produces, cbPosition, functionToWrap, thisBinding, functionArgsMinusCallback...) 140 | } 141 | ``` 142 | You may call value to produce any values you have declared. You may produce the same value multiple times (such as accepting an array and then producing each element as an individual value), and you may do so synchronously or asynchronously but you *MUST* call done once you've produced all the values you are going to produce unless you use fromNodeCallback. You may *NOT* produce values you have not declared. [You can find more details on the wiki](https://github.com/ayasin/frhttp/wiki/When#fn) 143 | 144 | The render definition is quite similar to the process definition but for 2 factors. First, there's only ever 1 render function, so there's no `when` method. Second the first parameter to the render function is a writer not a producer. The render function will always be called once no more producers can run unless there was an error. Any parameters which aren't available but are requested by the render function will be present but null. The writer has the following signature: 145 | ```js 146 | { 147 | writeBody: function(body), 148 | writePartial: function(chunk), 149 | writeFile: function(type, filename, shouldDownloadFileName), 150 | setHeader: function(name, value), 151 | setCookie: function(name, value), 152 | setStatus: function(statusCode), 153 | done: function() 154 | } 155 | ``` 156 | 157 | If you just want to write a JSON, HTML or text payload, writeBody does all the work necessary. If you need to write something more complex (transmit a binary file for example), then you can use writePartial. If you do *NOT* use `writeBody` you *MUST* set your own headers (Content-Length, etc) and you *MUST* call done. If your render function is called, the status defaults to 200. If you would like to send an alternative status (such as for a redirect), you should call setStatus before calling any write function. 158 | 159 | For a detailed explination of the writer [check the wiki](https://github.com/ayasin/frhttp/wiki/Rendering#the-writer-object). 160 | 161 | When this route executes, the system will run any functions that can run with available data. In this case, that's the first `when` function because the url variables are ready. The second function can't run because even though the url variables are ready, `sqrtToPow2` is not. Once the first function runs, it produces `sqrtToPow2`. This allows the second function to run. The run will proceed in this fashion until no more functions can be called based on the available data. At this point the system will call the render function and produce output. 162 | 163 | ### Starting the server in standalone mode ### 164 | 165 | Starting is standalone mode is quite simple. Just run the listen method on the server with a port number like so: 166 | ```js 167 | server.listen(8000); //8000 can be replaced with any valid and available port number 168 | ``` 169 | 170 | ### Using as part of an Express/Connect/Etc app ### 171 | 172 | Using a FRHTTP route in an Express/Connect app is only slightly more work than standalone mode. Lets assume that your Express app is in the variable `app` and your FRHTTP server is in a variable called `server`. The following code would execute a route on a get call: 173 | ```js 174 | app.get('/api/doSomething', function(req, res) { 175 | server.TAP_GET('/api/doSomething').onValue(function (executor) { 176 | var url = require('url').parse(req.url); 177 | executor.execute(url, req, res, executor.inject); 178 | }); 179 | }); 180 | ``` 181 | 182 | ## API Guide ## 183 | 184 | ### createServer() ### 185 | 186 | Returns a server object. You can either use this directly or as part of an Express app as described below. 187 | 188 | ### Server Object ### 189 | 190 | The server object exposes a number of methods related to registering and finding routes as well as several constants under the CONSTANTS field. The server supports hanging routes off 5 REST verbs: 191 | ```js 192 | GET(path) 193 | POST(path) 194 | PUT(path) 195 | DELETE(path) 196 | OPTIONS(path) 197 | ``` 198 | To achieve this, one would create a server like so: 199 | ```js 200 | server = require('frhttp').createServer(); 201 | ``` 202 | 203 | Then call the verb on the server like so: 204 | ```js 205 | server.GET(/* your path here such as /api/client */).onValue(function (route) { 206 | // define your route here as explained below 207 | }); 208 | ``` 209 | 210 | The server object also supports finding existing routes for execution (in case you're adding this to an existing Express app). These can be found at TAP_{VERB}: 211 | ```js 212 | TAP_GET(path) 213 | TAP_POST(path) 214 | TAP_PUT(path) 215 | TAP_DELETE(path) 216 | TAP_OPTIONS(path) 217 | ``` 218 | You can look up a route like so: 219 | ```js 220 | server.TAP_GET(/* some url */).onValue(function (executor) { 221 | //execute the route here as explained below 222 | }) 223 | ``` 224 | The last method on the Server object is the `listen` method. 225 | ```js 226 | listen(portNumber) 227 | ``` 228 | The single parameter to this method is a port number to bind to. If you plan to use FRHTTP along side Express (to handle some of the routes), you do not need to call listen. 229 | 230 | ### Route definition ### 231 | 232 | A route can be any valid URL path. 2 special characters exist in the route definition which cause the route to behave differently from a static route. These are `:` and `*`. `:` is a variable marker and `*` is a wildcard marker. Any number of variables are allowed in a route, but only 0 or 1 wildcard markers should appear. Also wildcard markers should be the last element in the route. 233 | 234 | Example routes: 235 | ``` 236 | /a/path -- static route 237 | /users/:userId/bin/:binId -- a path with 2 variables 238 | /users/:userId/fileDir/* - a path with a variable and a wildcard 239 | ``` 240 | 241 | When a request arrives, the server parses the URL into it's constituent parts. Variables are decoded into the CONSTANTS.URL_VARS object. The variables are attached to this object using the keys specified in the path. For example, in the 2 variable path in the example above the variables would be attached to the object at userId and binId. 242 | 243 | A special variable exists for paths containing wildcards. This variable is attached to URL_VARS at URL_VAR_WILDCARD and contains the remainder of the path. In the example above, if we received a request at `/users/10/fileDir/public/profileImage.png` the URL_VAR_WILDCARD would be `public/profileImage.png`, and userId would be `10`. 244 | 245 | ### Route configuration object ### 246 | 247 | The route configuration object is passed to the onValue function for every configuration function on the Server object (GET, POST, PUT, DELETE). The object exposes the `process` property and a `render` config function. The `process` property exposes the following properties and methods: 248 | 249 | Field | Description 250 | ------|------------ 251 | when(def) | Connects a function to the route via the def object (described below). Returns the process object so you can chain calls 252 | inject(obj) | Allows you to preset fields needed by functions. The obj should be a POJO (plain old javascript object). Returns the process object so you can chain calls 253 | render(def) | Defines the render function. The def object is described below. Returns undefined. This should be the last method you call in setting up a chain and should only be called once. Multiple calls to this method will replace the previous definition with the one in the latest call. 254 | WHEN | A series of built in common when blocks. You would use this like so `route.when(server.WHEN.BODY).when(...)...` 255 | 256 | Built in `WHEN` blocks 257 | 258 | Name | Description | Requires | Produces 259 | -----|-------------|----------|--------- 260 | BODY | Read the body of the request (mostly applies to POST and PUT requests). | CONSTANTS.REQUEST | CONSTANTS.REQUEST_BODY 261 | 262 | `when` definition object 263 | 264 | field | required | description 265 | ------|----------|--------------- 266 | name | No | the name of the function, used for debugging and error reporting purposes. While this is optional it's highly recommended. 267 | params | No | the parameters you require. These will be passed to you as an object to the second parameter to your function. If you don't require any parameters you can omit this field. 268 | produces | No | the parameters your function produces. 269 | fn | Yes | the function to execute when all the parameters are ready. 270 | triggerOn | No | an array of fields you wish to monitor. See [the wiki](https://github.com/ayasin/frhttp/wiki/When#triggeron) for more details. 271 | enter | No | a function that will be called with the parameter object prior to calling fn. The value returned from the enter function is passed to fn. To prevent fn from being called return undefined from the enter function (allowing enter to be used as a filter function). 272 | exit | No | a function called after each value produced by fn. The value returned by exit will be published instead of the value produced by fn. 273 | takeMany | No | false by default. If set to true, fn can be called each time params are available, otherwise fn will only be called the first time params are available. 274 | 275 | One way to consider enter, fn and exit are: 276 | ```js 277 | stream.map(enter).flatMap(fn).map(exit) 278 | ``` 279 | 280 | `fn` signature: 281 | ```js 282 | function fn(produce, input) 283 | ``` 284 | 285 | `produce` object: 286 | ```js 287 | { 288 | value: function (name, value), // name: name of field to produce, value: value 289 | done: function (), 290 | error: function(httpErrorCode, description) 291 | fromNodeCallback: function(produces, cbPosition, functionToWrap, thisBinding, functionArgsMinusCallback...) 292 | } 293 | ``` 294 | 295 | `render` definition object 296 | 297 | field | required | description 298 | ------|----------|--------------- 299 | params | Yes | the parameters you require. These will be passed to you as an object to the second parameter to your function. Any value not produced during the process phase will be set to null in the second parameter to fn. 300 | fn | Yes | the function to execute when all the parameters are ready. 301 | 302 | `fn` signature: 303 | ```js 304 | function fn(writer, input) 305 | ``` 306 | 307 | `writer` object: 308 | ```js 309 | { 310 | writeBody: function(body), 311 | writeFile: function(type, fileToSend, downloadOnClient), 312 | writePartial: function(chunk), 313 | setHeader: function(name, value), 314 | setCookie: function(name, value), 315 | setStatus: function(statusCode), 316 | done: function() 317 | } 318 | ``` 319 | 320 | See the [wiki page](https://github.com/ayasin/frhttp/wiki/Rendering#the-writer-object) for details 321 | 322 | ### Route executor object ### 323 | 324 | If you plan to use frhttp via the listen method and not as part of an Express app, you do not need to worry about the route executor object. 325 | 326 | ## Roadmap ## 327 | 328 | * More docs/guides 329 | * Demonstrate how to integrate with PassportJS 330 | * Websocket support 331 | --------------------------------------------------------------------------------