diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..496ee2c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.DS_Store
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..35c5dee
--- /dev/null
+++ b/README.md
@@ -0,0 +1,78 @@
+
+
+Providing good recommendations can get greater user engagement and provide an opportunity to add value that would otherwise not exist. The main reason why many applications don't provide recommendations is the perceived difficulty in either implementing a custom engine or using an off-the-shelf engine.
+
+Good Enough Recommendations (**GER**) is an attempt to reduce this difficulty by providing a recommendation engine that is scalable, easily usable and easy to integrate. HapiGER is an HTTP wrapper around GER implemented using the Hapi.js framework.
+
+Posts about (or related to) GER:
+
+1. Demo Movie Recommendations Site: [Yeah, Nah](http://yeahnah.maori.geek.nz/)
+1. Overall description and motivation of GER: [Good Enough Recommendations with GER](http://maori.geek.nz/post/good_enough_recomendations_with_ger)
+2. How GER works [GER's Anatomy: How to Generate Good Enough Recommendations](http://www.maori.geek.nz/post/how_ger_generates_recommendations_the_anatomy_of_a_recommendations_engine)
+2. Testing frameworks being used to test GER: [Testing Javascript with Mocha, Chai, and Sinon](http://www.maori.geek.nz/post/introduction_to_testing_node_js_with_mocha_chai_and_sinon)
+3. Bootstrap function for dumping data into GER: [Streaming directly into Postgres with Hapi.js and pg-copy-stream](http://www.maori.geek.nz/post/streaming_directly_into_postgres_with_hapi_js_and_pg_copy_stream)
+4. [Postgres Upsert (Update or Insert) in GER using Knex.js](http://www.maori.geek.nz/post/postgres_upsert_update_or_insert_in_ger_using_knex_js)
+
+#Quick Start Guide
+
+To install hapiger
+
+```
+npm install -g hapiger
+```
+
+To start hapiger
+
+```
+hapiger
+```
+
+To create an event:
+
+```
+curl -X POST 'http://localhost:7890/default/event' -d '{person: "p1", action: "likes", thing: "x-men"}'
+```
+
+The `default` namespace is initialized on startup
+
+To get recommendations for a user
+
+```
+curl -X GET 'http://localhost:7890/default/recommendations?person=p1&action=likes'
+```
+
+To compact the database
+
+```
+curl -X POST 'http://localhost:7890/default/compact'
+```
+
+#Namespace
+
+Namespaces are exclusive places to store events and query for recommendations
+
+To create a custom namespace
+
+```
+curl -X POST 'http://localhost:7890/namespace/movies'
+```
+
+
+Then you can add events
+
+```
+curl -X POST 'http://localhost:7890/movies/event' -d '{person: "p1", action: "likes", thing: "x-men"}'
+```
+
+
+A namespace also has an individual GER configuration which can be set by passing a payload, e.g.
+
+```
+curl -X POST 'http://localhost:7890/namespace' -d '{name: "movies", options: {crowd_weight: 1}}'
+```
+
+Delete a namespace (and all its events) with
+
+```
+curl -X DELETE 'http://localhost:7890/namespace/movies'
+```
diff --git a/assets/hapiger.svg b/assets/hapiger.svg
new file mode 100644
index 0000000..dfa389b
--- /dev/null
+++ b/assets/hapiger.svg
@@ -0,0 +1,201 @@
+
+
+
+
diff --git a/assets/hapiger300x200.png b/assets/hapiger300x200.png
new file mode 100644
index 0000000..23ecc23
Binary files /dev/null and b/assets/hapiger300x200.png differ
diff --git a/assets/hapiger70x70.png b/assets/hapiger70x70.png
new file mode 100644
index 0000000..ad100e0
Binary files /dev/null and b/assets/hapiger70x70.png differ
diff --git a/bin/hapiger b/bin/hapiger
new file mode 100644
index 0000000..41de4b4
--- /dev/null
+++ b/bin/hapiger
@@ -0,0 +1,2 @@
+#!/usr/bin/env node
+
diff --git a/config/environment.coffee b/config/environment.coffee
new file mode 100644
index 0000000..c5f74ab
--- /dev/null
+++ b/config/environment.coffee
@@ -0,0 +1,27 @@
+bb = require 'bluebird'
+
+environment = {}
+environment.logging_options = {
+ reporters: [{
+ reporter: require('good-console'),
+ args: [{ log: '*', response: '*' }]
+ }]
+}
+process.env.PORT=4567
+
+console.log "ENV", process.env.NODE_ENV
+switch process.env.NODE_ENV
+ when "test"
+
+ process.env.PORT = 3000
+ bb.Promise.longStackTraces()
+ when "production"
+ else
+ console.log "ENV", "forcing development"
+ bb.Promise.longStackTraces()
+#AMD
+if (typeof define != 'undefined' && define.amd)
+ define([], -> return environment)
+#Node
+else if (typeof module != 'undefined' && module.exports)
+ module.exports = environment;
diff --git a/index.coffee b/index.coffee
new file mode 100644
index 0000000..195f51e
--- /dev/null
+++ b/index.coffee
@@ -0,0 +1,120 @@
+#Setup the environment variables
+environment = require './config/environment'
+
+#PROMISES LIBRARY
+bb = require 'bluebird'
+
+# HAPI STACK
+Hapi = require 'hapi'
+Joi = require 'joi'
+
+# GER
+g = require 'ger'
+knex = g.knex # postgres client
+r = g.r #rethink client
+
+#ESMs
+PsqlESM = g.PsqlESM
+MemESM = g.MemESM
+RethinkDBESM = g.RethinkDBESM
+
+Utils = {}
+
+Utils.handle_error = (logger, err, reply) ->
+ if err.isBoom
+ logger.log(['error'], err)
+ reply(err)
+ else
+ console.log "Unhandled Error", err, err.stack
+ logger.log(['error'], {error: "#{err}", stack: err.stack})
+ reply({error: "An unexpected error occurred"}).code(500)
+
+
+class HapiGER
+
+ initialize: () ->
+ bb.try( => @init_server())
+ .then( => @setup_server())
+ .then( => @add_server_methods())
+ .then( => @add_server_routes())
+
+ init_server: (esm = 'mem') ->
+ #SETUP SERVER
+ @_server = new Hapi.Server()
+ @_server.connection({ port: process.env.PORT });
+ @_esm = MemESM
+ @info = @_server.info
+ (new @_esm('default')).initialize() #add the default namespace
+
+ create_namespace: (namespace) ->
+ (new @_esm(namespace)).initialize()
+
+ setup_server: ->
+ @load_server_plugin('good', environment.logging_options)
+
+ add_server_routes: ->
+ @load_server_plugin('./lib/the_hapi_ger', {ESM : @_esm, ESM_OPTIONS : {}})
+
+ add_server_methods: ->
+
+
+ server_method: (method, args = []) ->
+ d = bb.defer()
+ @_server.methods[method].apply(@, args.concat((err, result) ->
+ if (err)
+ d.reject(err)
+ else
+ d.resolve(result)
+ ))
+ d.promise
+
+
+ start: ->
+ @start_server()
+
+ stop: ->
+ @stop_server()
+
+
+
+ load_server_plugin: (plugin, options = {}) ->
+ d = bb.defer()
+ @_server.register({register: require(plugin), options: options}, (err) ->
+ if (err)
+ d.reject(err)
+ else
+ d.resolve()
+ )
+ d.promise
+
+ start_server: ->
+ d = bb.defer()
+ @_server.start( =>
+ d.resolve(@)
+ )
+ d.promise
+
+ stop_server: ->
+ d = bb.defer()
+ @_server.stop( ->
+ d.resolve()
+ )
+ d.promise
+
+
+
+
+
+
+#AMD
+if (typeof define != 'undefined' && define.amd)
+ define([], -> return HapiGER)
+#Node
+else if (typeof module != 'undefined' && module.exports)
+ module.exports = HapiGER;
+else
+ #not being required
+ hapiger = new HapiGER()
+ hapiger.initialize()
+ .then( -> hapiger.start())
+ .catch((e) -> console.log "ERROR"; console.log e.stack)
\ No newline at end of file
diff --git a/lib/namespace.coffee b/lib/namespace.coffee
new file mode 100644
index 0000000..e86d376
--- /dev/null
+++ b/lib/namespace.coffee
@@ -0,0 +1,16 @@
+bb = require 'bluebird'
+
+class NS
+ constructor: (@name, @options = {}) ->
+
+NS.find = (name) ->
+ #returns the object with GER options
+ options = {}
+ bb.try( => new NS(name, options))
+
+#AMD
+if (typeof define != 'undefined' && define.amd)
+ define([], -> return NS)
+#Node
+else if (typeof module != 'undefined' && module.exports)
+ module.exports = NS;
diff --git a/lib/the_hapi_ger.coffee b/lib/the_hapi_ger.coffee
new file mode 100644
index 0000000..63678dc
--- /dev/null
+++ b/lib/the_hapi_ger.coffee
@@ -0,0 +1,216 @@
+bb = require 'bluebird'
+
+Joi = require 'joi'
+Boom = require 'boom'
+
+# GER
+g = require 'ger'
+
+GER = g.GER
+
+Utils = require './utils'
+
+NS = require './namespace'
+
+GERAPI =
+ register: (plugin, options, next) ->
+ ESM = options.ESM
+ ESM_OPTIONS = options.ESM_OPTIONS
+
+ get_namespace_ger = (name) ->
+ NS.find(name)
+ .then( (ns) ->
+ ger = new GER(new ESM(name, ESM_OPTIONS), ns.options)
+ ger
+ )
+
+ ########### EVENTS RESOURCE ################
+ #POST create event
+ plugin.route(
+ method: 'POST',
+ path: '/{namespace}/events',
+ config:
+ validate:
+ payload: Joi.object().keys(
+ person: Joi.any().required()
+ action: Joi.any().required()
+ thing: Joi.any().required()
+ )
+
+ handler: (request, reply) =>
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ ger.event(request.payload.person, request.payload.action, request.payload.thing)
+ )
+ .then( (event) ->
+ reply({event: event})
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply) )
+ )
+
+ #GET event information
+ plugin.route(
+ method: 'GET',
+ path: '/{namespace}/events',
+ config:
+ validate:
+ query:
+ person: Joi.any().required()
+ action: Joi.any().required()
+ thing: Joi.any().required()
+ handler: (request, reply) =>
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ ger.find_event(request.query.person, request.query.action, request.query.thing)
+ )
+ .then( (event) ->
+ throw Boom.notFound('event not found') if not event
+ reply(event)
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply) )
+ )
+
+ #GET event information
+ plugin.route(
+ method: 'GET',
+ path: '/{namespace}/events/stats',
+ handler: (request, reply) =>
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ ger.count_events()
+ )
+ .then( (count) ->
+ reply({count: count})
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply) )
+ )
+
+
+ #POST bootstrap, upload csv for
+ plugin.route(
+ method: 'POST',
+ path: '/{namespace}/events/bootstrap',
+ config:
+ payload:
+ maxBytes: 209715200
+ output:'stream'
+ parse: true
+
+ handler: (request, reply) =>
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ stream = request.payload["events"]
+ ger.bootstrap(stream)
+ )
+ .then((added_count) ->
+ reply({added_events: added_count})
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply))
+ )
+
+ ########### ACTIONS RESOURCE ################
+
+ #PUT update action
+ plugin.route(
+ method: 'PUT',
+ path: '/{namespace}/actions/{action}',
+ handler: (request, reply) =>
+ action = request.params.action
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ ger.action(action, request.payload.weight)
+ )
+ .then( (action_weight) ->
+ reply({action: action_weight.action, weight: action_weight.weight})
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply) )
+ )
+
+ #GET update action
+ plugin.route(
+ method: 'GET',
+ path: '/{namespace}/actions/{action}',
+ handler: (request, reply) =>
+ action = request.params.action
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ ger.get_action(action)
+ )
+ .then( (act) ->
+ throw Boom.notFound('action not found') if not act
+ reply(act)
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply) )
+ )
+
+
+ ########### RECOMMENDATIONS RESOURCE ################
+ #GET recommendations
+ plugin.route(
+ method: 'GET',
+ path: '/{namespace}/recommendations',
+ handler: (request, reply) =>
+ #TODO change type of recommendation based on parameters, e.g. for person action if they are included
+
+ person = request.query.person
+ action = request.query.action
+ explain = !!request.query.explain
+
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ ger.recommendations_for_person(person, action, {explain: explain})
+ )
+ .spread( (recommendations) ->
+ reply(recommendations)
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply))
+ )
+
+ #MAINTENANCE ROUTES
+ plugin.route(
+ method: 'POST',
+ path: '/{namespace}/compact_async',
+ handler: (request, reply) =>
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ ger.compact_database().then( ->
+ plugin.log(['log'], {message: "COMPACT COMPLETED FOR NS #{request.params.namespace}"})
+ )
+ reply({message: "Doing"})
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply) )
+ )
+
+ plugin.route(
+ method: 'POST',
+ path: '/{namespace}/compact',
+ handler: (request, reply) =>
+ get_namespace_ger(request.params.namespace)
+ .then( (ger) ->
+ ger.estimate_event_count()
+ .then( (init_count) ->
+ bb.all( [init_count, ger.compact_database()] )
+ )
+ .spread((init_count) ->
+ bb.all( [ init_count, ger.estimate_event_count()] )
+ )
+ .spread((init_count, end_count) ->
+ reply({ init_count: init_count, end_count: end_count, compression: "#{(1 - (end_count/init_count)) * 100}%" })
+ )
+ )
+ .catch((err) -> Utils.handle_error(request, err, reply) )
+ )
+
+ next()
+
+
+GERAPI.register.attributes =
+ name: 'the_hapi_ger'
+ version: '0.0.1'
+
+#AMD
+if (typeof define != 'undefined' && define.amd)
+ define([], -> return GERAPI)
+#Node
+else if (typeof module != 'undefined' && module.exports)
+ module.exports = GERAPI;
\ No newline at end of file
diff --git a/lib/utils.coffee b/lib/utils.coffee
new file mode 100644
index 0000000..7a87829
--- /dev/null
+++ b/lib/utils.coffee
@@ -0,0 +1,27 @@
+Utils = {}
+
+Utils.handle_error = (logger, err, reply) ->
+ if err.isBoom
+ logger.log(['error'], err)
+ reply(err)
+ else
+ console.log "Unhandled Error", err, err.stack
+ logger.log(['error'], {error: "#{err}", stack: err.stack})
+ reply({error: "An unexpected error occurred"}).code(500)
+
+Utils.server_method = (method, args = []) ->
+ d = bb.defer()
+ @_server.methods[method].apply(@, args.concat((err, result) ->
+ if (err)
+ d.reject(err)
+ else
+ d.resolve(result)
+ ))
+ d.promise
+
+#AMD
+if (typeof define != 'undefined' && define.amd)
+ define([], -> return Utils)
+#Node
+else if (typeof module != 'undefined' && module.exports)
+ module.exports = Utils;
\ No newline at end of file
diff --git a/node_modules/.bin/_mocha b/node_modules/.bin/_mocha
new file mode 120000
index 0000000..f2a54ff
--- /dev/null
+++ b/node_modules/.bin/_mocha
@@ -0,0 +1 @@
+../mocha/bin/_mocha
\ No newline at end of file
diff --git a/node_modules/.bin/cake b/node_modules/.bin/cake
new file mode 120000
index 0000000..d95f32a
--- /dev/null
+++ b/node_modules/.bin/cake
@@ -0,0 +1 @@
+../coffee-script/bin/cake
\ No newline at end of file
diff --git a/node_modules/.bin/coffee b/node_modules/.bin/coffee
new file mode 120000
index 0000000..b57f275
--- /dev/null
+++ b/node_modules/.bin/coffee
@@ -0,0 +1 @@
+../coffee-script/bin/coffee
\ No newline at end of file
diff --git a/node_modules/.bin/mocha b/node_modules/.bin/mocha
new file mode 120000
index 0000000..43c668d
--- /dev/null
+++ b/node_modules/.bin/mocha
@@ -0,0 +1 @@
+../mocha/bin/mocha
\ No newline at end of file
diff --git a/node_modules/bluebird/LICENSE b/node_modules/bluebird/LICENSE
new file mode 100644
index 0000000..a3966cf
--- /dev/null
+++ b/node_modules/bluebird/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2014 Petka Antonov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
Feature(s) | +Command line identifier | +
---|---|
.any and Promise.any | any |
.race and Promise.race | race |
.call and .get | call_get |
.filter and Promise.filter | filter |
.map and Promise.map | map |
.reduce and Promise.reduce | reduce |
.props and Promise.props | props |
.settle and Promise.settle | settle |
.some and Promise.some | some |
.nodeify | nodeify |
Promise.coroutine and Promise.spawn | generators |
Progression | progress |
Promisification | promisify |
Cancellation | cancel |
Timers | timers |
Resource management | using |
ExpressoInsanely fast TDD framework for node featuring code coverage reporting. | |
expresso | bin/expresso |
+!/usr/bin/env node+ |
+
+
+ |
+
+ Module dependencies. + + |
+
+
+ |
+
+ Expresso version. + + |
+
+
+ |
+
+ Failure count. + + |
+
+
+ |
+
+ Number of tests executed. + + |
+
+
+ |
+
+ Whitelist of tests to run. + + |
+
+
+ |
+
+ Boring output. + + |
+
+
+ |
+
+ Growl notifications. + + |
+
+
+ |
+
+ Server port. + + |
+
+
+ |
+
+ Watch mode. + + |
+
+
+ |
+
+ Execute serially. + + |
+
+
+ |
+
+ Default timeout. + + |
+
+
+ |
+
+ Usage documentation. + + |
+
+
+ |
+
+ Colorized sys.error(). + + + +
|
+
+
+ |
+
+ Colorize the given string using ansi-escape sequences. +Disabled when --boring is set. + + + +
|
+
+
+ |
+
+ Assert that
|
+
+
+ |
+
+ Assert that
|
+
+
+ |
+
+ Assert that
|
+
+
+ |
+
+ Assert that
|
+
+
+ |
+
+ Assert that
|
+
+
+ |
+
+ Assert that
|
+
+
+ |
+
+ Assert that Examples+ +assert.includes('foobar', 'bar'); + assert.includes(['foo', 'bar'], 'foo'); + + + +
|
+
+
+ |
+
+ Assert length of
|
+
+
+ |
+
+ Assert response from
|
+
+
+ |
+
+ Pad the given string to the maximum width provided. + + + +
|
+
+
+ |
+
+ Pad the given string to the maximum width provided. + + + +
|
+
+
+ |
+
+ Report test coverage. + + + +
|
+
+
+ |
+
+ Populate code coverage data. + + + +
|
+
+
+ |
+
+ Total coverage for the given file data. + + + +
|
+
+
+ |
+
+ Run the given test
|
+
+
+ |
+
+ Show the cursor when
|
+
+
+ |
+
+ Run the given test
|
+
+
+ |
+
+ Run tests for the given
|
+
+
+ |
+
+ Clear the module cache for the given
|
+
+
+ |
+
+ Watch the given
|
+
+
+ |
+
+ Report
|
+
+
+ |
+
+ Run the given tests, callback
|
+
+
+ |
+
+ Report exceptions. + + |
+
+
+ |
+
+ Growl notify the given
|
+
+
+ |
+