From 45dbe4b998a00d7e11003de16d5857e948ad8e8b Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Thu, 2 Mar 2017 13:39:28 +0100 Subject: [PATCH 01/21] Reorganize helpers and utilities --- README.md | 26 ++++++- src/client.js | 13 ++++ src/extract.js | 74 +++++++++++++++++++ src/helpers/filter-user-segments.js | 8 +- src/helpers/handle-extract.js | 39 ---------- src/helpers/index.js | 10 +-- src/helpers/request-extract.js | 33 +-------- src/middleware/client.js | 2 +- ...-available-properties.js => properties.js} | 5 +- .../update-settings.js => settings.js} | 17 +---- src/utils/batch-handler.js | 8 +- src/{helpers => utils}/get-settings.js | 0 src/utils/index.js | 1 - tests/client-tests.js | 25 +++++++ tests/client.js | 13 ---- tests/extract-tests.js | 33 +++++++++ tests/helpers/filter-user.js | 2 +- tests/support/hull-stub.js | 1 + 18 files changed, 189 insertions(+), 121 deletions(-) create mode 100644 src/extract.js delete mode 100644 src/helpers/handle-extract.js rename src/{helpers/get-available-properties.js => properties.js} (89%) rename src/{helpers/update-settings.js => settings.js} (53%) rename src/{helpers => utils}/get-settings.js (100%) create mode 100644 tests/client-tests.js delete mode 100644 tests/client.js create mode 100644 tests/extract-tests.js diff --git a/README.md b/README.md index d601017..dbe3089 100644 --- a/README.md +++ b/README.md @@ -249,9 +249,33 @@ This utility can be also used in following way: ```js const hull = new Hull({ config }); -const userGroupedTraits = hull.utils.groupTraits(user_report); +const userGroupedTraits = hull.utils.traits.group(user_report); ``` +### extract.request({ hostname, segment = null, format = "json", path = "batch", fields = [] }) +Performs a `hull.post("extract/user_reports", {})` call building all needed properties. It takes following arguments: + +- **hostname** - a hostname where the extract should be sent +- **path** - a path of the endpoint which will handle the extract (*batch*) +- **fields** - an array of users attributes to extract +- **format** - prefered format +- **segment** - extract only users matching selected segment, this needs to be an object with `id` at least, `segment.query` is optional + +### extract.handle({ body, batchSize, handler }) +The utility to download and parse the incoming extract. + +- **body** - json of incoming extract message - must contain `url` and `format` +- **batchSize** - number of users to be passed to each handler call +- **handler** - the callback function which would be called with batches of users + +### settings.update({ newSettings }) +A helper utility which simplify `hull.put("app", { private_settings })` calls. Using raw API you need to merge existing settings with those you want to update. +This utility does it for you. + +### properties.get() +A wrapper over `hull.get("search/user_reports/bootstrap")` call which unpacks the list of properties. + + ### Logging Methods: Hull.logger.debug(), Hull.logger.info() ... diff --git a/src/client.js b/src/client.js index 6b327f7..51124db 100644 --- a/src/client.js +++ b/src/client.js @@ -5,6 +5,9 @@ import restAPI from "./rest-api"; import crypto from "./lib/crypto"; import currentUserMiddleware from "./middleware/current-user"; import trait from "./trait"; +import * as extract from "./extract"; +import * as settings from "./settings"; +import * as propertiesUtils from "./properties"; import FirehoseBatcher from "./firehose-batcher"; const PUBLIC_METHODS = ["get", "post", "del", "put"]; @@ -53,6 +56,16 @@ const Client = function Client(config = {}) { this.utils = { groupTraits: trait.group, + properties: { + get: propertiesUtils.get.bind(this), + }, + settings: { + update: settings.update.bind(this), + }, + extract: { + request: extract.request.bind(this), + handle: extract.handle.bind(this), + } }; const ctxe = _.pick((this.configuration() || {}), ["organization", "id"]); diff --git a/src/extract.js b/src/extract.js new file mode 100644 index 0000000..eaa8030 --- /dev/null +++ b/src/extract.js @@ -0,0 +1,74 @@ +import Promise from "bluebird"; +import CSVStream from "csv-stream"; +import JSONStream from "JSONStream"; +import requestClient from "request"; +import ps from "promise-streams"; +import BatchStream from "batch-stream"; +import URI from "urijs"; + + +/** + * @param {Object} body Request Body Object + * @param {Object} batchSize + * @param {Function} callback returning a Promise + * @return {Promise} + * + * return handleExtract(req, 100, (users) => Promise.resolve()) + */ +export function handle({ body, batchSize, handler }) { + const { logger } = this; + const { url, format } = body; + if (!url) return Promise.reject(new Error("Missing URL")); + const decoder = format === "csv" ? CSVStream.createStream({ escapeChar: "\"", enclosedChar: "\"" }) : JSONStream.parse(); + + const batch = new BatchStream({ size: batchSize }); + + return requestClient({ url }) + .pipe(decoder) + .pipe(batch) + .pipe(ps.map({ concurrent: 2 }, (...args) => { + try { + return handler(...args); + } catch (e) { + logger.error("ExtractAgent.handleExtract.error", e.stack || e); + return Promise.reject(e); + } + })) + .promise() + .then(() => true); +} + +/** + * Start an extract job and be notified with the url when complete. + * @param {Object} options + * @return {Promise} + */ +export function request({ hostname, segment = null, format = "json", path = "batch", fields = [] } = {}) { + const client = this; + const search = client.configuration(); + if (segment) { + search.segment_id = segment.id; + } + const url = URI(`https://${hostname}`) + .path(path) + .search(search) + .toString(); + + return (() => { + if (segment == null) { + return Promise.resolve({ + query: {} + }); + } + + if (segment.query) { + return Promise.resolve(segment); + } + return client.get(segment.id); + })() + .then(({ query }) => { + const params = { query, format, url, fields }; + client.logger.info("requestExtract", params); + return client.post("extract/user_reports", params); + }); +} diff --git a/src/helpers/filter-user-segments.js b/src/helpers/filter-user-segments.js index e4d9027..286461d 100644 --- a/src/helpers/filter-user-segments.js +++ b/src/helpers/filter-user-segments.js @@ -1,6 +1,5 @@ import _ from "lodash"; -const fieldPath = "ship.private_settings.synchronized_segments"; /** * Returns information if the users should be sent in outgoing sync. @@ -8,12 +7,13 @@ const fieldPath = "ship.private_settings.synchronized_segments"; * setting is empty * @param {Object} ctx The Context Object * @param {Object} user Hull user object + * @param {String} fieldName the name of settings name * @return {Boolean} */ -export default function filterUserSegments(ctx, user) { - if (!_.has(ctx, fieldPath)) { +export default function filterUserSegments(ctx, user, fieldName = "synchronized_segments") { + if (!_.has(ctx.ship.private_settings, fieldName)) { return true; } - const filterSegmentIds = _.get(ctx, fieldPath, []); + const filterSegmentIds = _.get(ctx.ship.private_settings, fieldName, []); return _.intersection(filterSegmentIds, user.segment_ids).length > 0; } diff --git a/src/helpers/handle-extract.js b/src/helpers/handle-extract.js deleted file mode 100644 index 64097eb..0000000 --- a/src/helpers/handle-extract.js +++ /dev/null @@ -1,39 +0,0 @@ -import Promise from "bluebird"; -import CSVStream from "csv-stream"; -import JSONStream from "JSONStream"; -import request from "request"; -import ps from "promise-streams"; -import BatchStream from "batch-stream"; - - -/** - * @param {Object} ctx The Context Object - * @param {Object} body Request Body Object - * @param {Object} batchSize - * @param {Function} callback returning a Promise - * @return {Promise} - * - * return handleExtract(req, 100, (users) => Promise.resolve()) - */ -export default function handleExtract(ctx, { body, batchSize, handler }) { - const { logger } = ctx.client; - const { url, format } = body; - if (!url) return Promise.reject(new Error("Missing URL")); - const decoder = format === "csv" ? CSVStream.createStream({ escapeChar: "\"", enclosedChar: "\"" }) : JSONStream.parse(); - - const batch = new BatchStream({ size: batchSize }); - - return request({ url }) - .pipe(decoder) - .pipe(batch) - .pipe(ps.map({ concurrent: 2 }, (...args) => { - try { - return handler(...args); - } catch (e) { - logger.error("ExtractAgent.handleExtract.error", e.stack || e); - return Promise.reject(e); - } - })) - .promise() - .then(() => true); -} diff --git a/src/helpers/index.js b/src/helpers/index.js index f347fd1..fc6602f 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -1,11 +1,3 @@ +export filterUserSegments from "./filter-user-segments"; export requestExtract from "./request-extract"; -export handleExtract from "./handle-extract"; - -export getSettings from "./get-settings"; -export updateSettings from "./update-settings"; - -export getAvailableProperties from "./get-available-properties"; - export setUserSegments from "./set-user-segments"; -export filterUserSegments from "./filter-user-segments"; - diff --git a/src/helpers/request-extract.js b/src/helpers/request-extract.js index 5485808..4d6fc9a 100644 --- a/src/helpers/request-extract.js +++ b/src/helpers/request-extract.js @@ -1,38 +1,9 @@ -import Promise from "bluebird"; -import URI from "urijs"; - /** * Start an extract job and be notified with the url when complete. - * @param {Object} ctx The Context Object * @param {Object} options * @return {Promise} */ -export default function requestExtract(ctx, { segment = null, format = "json", path = "batch", fields = [] }) { +export default function requestExtract(ctx, { segment = null, fields = [] } = {}) { const { client, hostname } = ctx; - const search = client.configuration(); - if (segment) { - search.segment_id = segment.id; - } - const url = URI(`https://${hostname}`) - .path(path) - .search(search) - .toString(); - - return (() => { - if (segment == null) { - return Promise.resolve({ - query: {} - }); - } - - if (segment.query) { - return Promise.resolve(segment); - } - return client.get(segment.id); - })() - .then(({ query }) => { - const params = { query, format, url, fields }; - client.logger.info("requestExtract", params); - return client.post("extract/user_reports", params); - }); + return client.utils.extract.request({ hostname, segment, fields }); } diff --git a/src/middleware/client.js b/src/middleware/client.js index 876d91b..2a0a967 100644 --- a/src/middleware/client.js +++ b/src/middleware/client.js @@ -66,7 +66,7 @@ module.exports = function hullClientMiddlewareFactory(Client, { hostSecret, clie // Promise return getCurrentShip(id, req.hull.client, req.hull.cache, bust).then((ship = {}) => { req.hull.ship = ship; - _.merge(req.hull.client, _.mapValues(helpers, func => func.bind(null, req.hull))); + req.hull.helpers = _.mapValues(helpers, func => func.bind(null, req.hull)); req.hull.hostname = req.hostname; return next(); }, (err) => { diff --git a/src/helpers/get-available-properties.js b/src/properties.js similarity index 89% rename from src/helpers/get-available-properties.js rename to src/properties.js index df98cb2..f08470f 100644 --- a/src/helpers/get-available-properties.js +++ b/src/properties.js @@ -30,11 +30,10 @@ function getProperties(raw, path, id_path) { /** * gets all existing Properties in the organization along with their metadata - * @param {Object} ctx The Context Object * @return {Promise} */ -export default function getAvailableProperties(ctx) { - return ctx.client +export function get() { // eslint-disable-line import/prefer-default-export + return this .get("search/user_reports/bootstrap") .then(({ tree }) => getProperties(tree).properties); } diff --git a/src/helpers/update-settings.js b/src/settings.js similarity index 53% rename from src/helpers/update-settings.js rename to src/settings.js index c2c382f..6ebc98c 100644 --- a/src/helpers/update-settings.js +++ b/src/settings.js @@ -6,22 +6,11 @@ * @param {Object} newSettings settings to update * @return {Promise} */ -export default function updateSettings(ctx, newSettings) { - const { client, cache } = ctx; - return client.get(ctx.ship.id) +export function update(newSettings) { // eslint-disable-line import/prefer-default-export + return this.get("app") .then((ship) => { const private_settings = { ...ship.private_settings, ...newSettings }; ship.private_settings = private_settings; - return client.put(ship.id, { private_settings }); - }) - .then((ship) => { - ctx.ship = ship; - if (!cache) { - return ship; - } - return cache.del(ship.id) - .then(() => { - return ship; - }); + return this.put(ship.id, { private_settings }); }); } diff --git a/src/utils/batch-handler.js b/src/utils/batch-handler.js index 5f937c3..30acff8 100644 --- a/src/utils/batch-handler.js +++ b/src/utils/batch-handler.js @@ -8,15 +8,15 @@ export default function batchHandler(handler, { batchSize = 100, groupTraits = f const router = Router(); router.use(requireHullMiddleware()); router.post("/", (req, res, next) => { - const { client } = req.hull; + const { client, helpers } = req.hull; - return client.handleExtract({ + return client.utils.extract.handle({ body: req.body, batchSize, handler: (users) => { const segmentId = req.query.segment_id || null; - users = users.map(u => client.setUserSegments({ add_segment_ids: [segmentId] }, u)); - users = users.filter(u => client.filterUserSegments(u)); + users = users.map(u => helpers.setUserSegments({ add_segment_ids: [segmentId] }, u)); + users = users.filter(u => helpers.filterUserSegments(u)); if (groupTraits) { users = users.map(u => group(u)); } diff --git a/src/helpers/get-settings.js b/src/utils/get-settings.js similarity index 100% rename from src/helpers/get-settings.js rename to src/utils/get-settings.js diff --git a/src/utils/index.js b/src/utils/index.js index b1017e1..8b5d52b 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -13,4 +13,3 @@ export responseMiddleware from "./response-middleware"; export serviceMiddleware from "./service-middleware"; export notifMiddleware from "./notif-middleware"; export segmentsMiddleware from "./segments-middleware"; - diff --git a/tests/client-tests.js b/tests/client-tests.js new file mode 100644 index 0000000..0aa91f4 --- /dev/null +++ b/tests/client-tests.js @@ -0,0 +1,25 @@ +/* global describe, it */ +import { expect } from "chai"; +import sinon from "sinon"; + +import Hull from "../src"; + +describe("Hull", () => { + it("should expose bound Connector", () => { + const connector = new Hull.Connector({ hostSecret: 1234 }); + expect(connector).to.be.object; + expect(connector.hostSecret).to.be.eql(1234); + }); + + it("should expose helper functions", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + expect(hull.utils.extract).to.has.property("request") + .that.is.an("function"); + expect(hull.utils.extract).to.has.property("handle") + .that.is.an("function"); + expect(hull.utils.properties).to.has.property("get") + .that.is.an("function"); + expect(hull.utils.settings).to.has.property("update") + .that.is.an("function"); + }); +}); diff --git a/tests/client.js b/tests/client.js deleted file mode 100644 index 2e5c7ce..0000000 --- a/tests/client.js +++ /dev/null @@ -1,13 +0,0 @@ -/* global describe, it */ -import { expect } from "chai"; -import sinon from "sinon"; - -import Hull from "../src"; - -describe("Hull", () => { - it("should expose bound Connector", () => { - const connector = new Hull.Connector({ hostSecret: 1234 }); - expect(connector).to.be.object; - expect(connector.hostSecret).to.be.eql(1234); - }); -}); diff --git a/tests/extract-tests.js b/tests/extract-tests.js new file mode 100644 index 0000000..a6ce8c4 --- /dev/null +++ b/tests/extract-tests.js @@ -0,0 +1,33 @@ +/* global describe, it */ +/* eslint-disable no-unused-expressions */ +import { expect } from "chai"; +import sinon from "sinon"; + +import HullStub from "./support/hull-stub"; + +import { request } from "../src/extract"; + +describe("extract.request", () => { + beforeEach(function beforeEachHandler() { + this.postStub = sinon.stub(HullStub.prototype, "post"); + }); + + afterEach(function afterEachHandler() { + this.postStub.restore(); + }); + + it("should allow to perform `extract/user_reports` call", function test1(done) { + const stub = new HullStub; + request.call(stub, { hostname: "localhost" }) + .then(() => { + expect(this.postStub.calledOnce).to.be.true; + expect(this.postStub.calledWith("extract/user_reports", { + url: `https://localhost/batch?id=${stub.configuration().id}&secret=shutt&organization=xxx.hulltest.rocks`, + query: {}, + format: "json", + fields: [] + })).to.be.true; + done(); + }); + }); +}); diff --git a/tests/helpers/filter-user.js b/tests/helpers/filter-user.js index 7abacb0..dc5a130 100644 --- a/tests/helpers/filter-user.js +++ b/tests/helpers/filter-user.js @@ -2,7 +2,7 @@ /* eslint-disable no-unused-expressions */ import { expect } from "chai"; -import filterUserSegments from "../../src/helpers/filter-user-segments"; +import { filterUserSegments } from "../../src/helpers"; import mockSettings from "../support/mock-settings"; diff --git a/tests/support/hull-stub.js b/tests/support/hull-stub.js index d54b96b..19048bf 100644 --- a/tests/support/hull-stub.js +++ b/tests/support/hull-stub.js @@ -12,6 +12,7 @@ export default class HullStub { get(id) { return Promise.resolve({ id }); } put(id) { return Promise.resolve({ id }); } + post(id) { return Promise.resolve({ id }); } configuration() { return { id: this.id , secret: "shutt", organization: "xxx.hulltest.rocks" }; From 5cc35e6a57214db839aff16c14fe41e258a447ed Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Thu, 2 Mar 2017 14:43:43 +0100 Subject: [PATCH 02/21] Adds updateSettings helper --- src/helpers/index.js | 1 + src/helpers/update-settings.js | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/helpers/update-settings.js diff --git a/src/helpers/index.js b/src/helpers/index.js index fc6602f..2ab9389 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -1,3 +1,4 @@ export filterUserSegments from "./filter-user-segments"; export requestExtract from "./request-extract"; export setUserSegments from "./set-user-segments"; +export updateSettings from "./update-settings"; diff --git a/src/helpers/update-settings.js b/src/helpers/update-settings.js new file mode 100644 index 0000000..624e78b --- /dev/null +++ b/src/helpers/update-settings.js @@ -0,0 +1,20 @@ +/** + * Updates `private_settings`, touching only provided settings. + * Also clears the `shipCache`. + * `hullClient.put` will emit `ship:update` notify event. + * @param {Object} ctx The Context Object + * @param {Object} newSettings settings to update + * @return {Promise} + */ +export default function updateSettings(ctx, newSettings) { + const { client, cache } = ctx; + return client.settings.update(newSettings) + .then((ship) => { + ctx.ship = ship; + if (!cache) { + return ship; + } + return cache.del(ship.id) + .then(() => ship); + }); +} From 65529d133a96b5d86da725cf7c37dc75524f6b36 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Thu, 2 Mar 2017 15:08:13 +0100 Subject: [PATCH 03/21] Remove old docs and fix option default value --- README.md | 37 ------------------------------------- src/utils/notif-handler.js | 2 +- 2 files changed, 1 insertion(+), 38 deletions(-) diff --git a/README.md b/README.md index dbe3089..dd5222e 100644 --- a/README.md +++ b/README.md @@ -297,40 +297,3 @@ Hull.logger.add(winstonSlacker, { ... }); Uses [Winston](https://github.com/winstonjs/winston) The Logger comes in two flavors, `Hull.logger.xxx` and `hull.logger.xxx` - The first one is a generic logger, the second one injects the current instance of `Hull` so you can retreive ship name, id and organization for more precision. - - -## Helpers - -Helpers is a set of functions being attached by `Hull.Middleware` to the `req.hull.client` -with the current context being applied as a first argument. -The functions could be also used one by one. - -```js -import { requestExtract } from "hull/lib/helpers"; - -app.post("/request", (req, res) => { - requestExtract(req.hull, { fields }); - // or: - req.hull.client.requestExtract({ fields }); -}); -``` - -### requestExtract({ segment = null, format = "json", path = "batch", fields = [] }) -Sends a request to Hull platform to trigger a extract of users. - -### handleExtract({ body, chunkSize, handler }) -Handles the incoming extract notification, downloads the extract payload and process it in a stream - -### getSettings() -A shortcut to get to data from `req.hull.ship.private_settings`. - -### updateSettings({ newSettings }) -Allows to update selected settings of the ship `private_settings` object. New settings will be merged with existing ones. - -### getAvailableProperties() -Returns information about all attributes available in the current organization - -### filterUserSegments({ user }) -Returns `true/false` based on if the user belongs to any of the segments selected in the settings segment filter. If there are no segments defined it will return `false` for all users. - -### setUserSegments({ add_segment_ids = [], remove_segment_ids = [] }, user) diff --git a/src/utils/notif-handler.js b/src/utils/notif-handler.js index 5e1f0a8..f23adb3 100644 --- a/src/utils/notif-handler.js +++ b/src/utils/notif-handler.js @@ -57,7 +57,7 @@ function processHandlersFactory(handlers, userHandlerOptions) { return Batcher.getHandler(`${ns}-${eventName}-${i}`, { ctx: context, options: { - maxSize: userHandlerOptions.maxSize || 1000, + maxSize: userHandlerOptions.maxSize || 100, maxTime: userHandlerOptions.maxTime || 10000 } }) From 8d8541717bbb39f30f1c27918c7480979c68d154 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Fri, 3 Mar 2017 00:43:23 +0100 Subject: [PATCH 04/21] Allow passing additional claims to user jwt --- src/client.js | 10 ++++------ src/configuration.js | 10 +++++++--- src/lib/crypto.js | 5 ++++- tests/client-tests.js | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/client.js b/src/client.js index 51124db..1364d8e 100644 --- a/src/client.js +++ b/src/client.js @@ -119,13 +119,11 @@ const Client = function Client(config = {}) { }); }; } else { - this.as = (userId) => { - // Sudo allows to be a user yet have admin rights... Use with care. - if (!userId) { - throw new Error("User Id was not defined when calling hull.as()"); + this.as = (userClaims, userClaimsOptions) => { + if (!userClaims) { + throw new Error("User Claims was not defined when calling hull.as()"); } - // const scopedClientConfig = _.omit(config, "secret"); - return new Client({ ...config, userId }); + return new Client({ ...config, userClaims, userClaimsOptions }); }; } }; diff --git a/src/configuration.js b/src/configuration.js index 772164d..9495714 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -13,6 +13,9 @@ const VALID = { boolean(val) { return (val === true || val === false); }, + object(val) { + return _.isObject(val); + }, objectId(str) { return VALID_OBJECT_ID.test(str); }, @@ -35,7 +38,8 @@ const VALID_PROPS = { prefix: VALID.string, domain: VALID.string, protocol: VALID.string, - userId: VALID.string, + userClaims: VALID.object, + userClaimsOptions: VALID.object, accessToken: VALID.string, hostSecret: VALID.string, flushAt: VALID.number, @@ -49,8 +53,8 @@ class Configuration { throw new Error("Configuration is invalid, it should be a non-empty object"); } - if (config.userId) { - const accessToken = crypto.lookupToken(config, config.userId); + if (config.userClaims) { + const accessToken = crypto.lookupToken(config, config.userClaims, config.userClaimsOptions); config = { ...config, accessToken }; } diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 5311d0e..493e463 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -69,11 +69,14 @@ module.exports = { * @param {Object} additionnal claims * @returns {String} The jwt token to identity the user. */ - lookupToken(config, user = {}, claims = {}) { + lookupToken(config, user = {}, claimsOptions = {}) { checkConfig(config); + const claims = _.clone(claimsOptions); if (_.isString(user)) { if (!user) { throw new Error("Missing user ID"); } claims.sub = user; + } else if (user.id) { + claims.sub = user.id; } else { if (!_.isObject(user) || (!user.email && !user.external_id && !user.guest_id)) { throw new Error("you need to pass a User hash with an `email` or `external_id` or `guest_id` field"); diff --git a/tests/client-tests.js b/tests/client-tests.js index 0aa91f4..73a3c34 100644 --- a/tests/client-tests.js +++ b/tests/client-tests.js @@ -1,6 +1,7 @@ /* global describe, it */ import { expect } from "chai"; import sinon from "sinon"; +import jwt from "jwt-simple"; import Hull from "../src"; @@ -22,4 +23,42 @@ describe("Hull", () => { expect(hull.utils.settings).to.has.property("update") .that.is.an("function"); }); + + describe("as", () => { + it("should allow to pass create option", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + + const scoped = hull.as({ email: "foo@bar.com" }, { create: false }); + const scopedConfig = scoped.configuration(); + const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); + expect(scopedJwtClaims) + .to.have.property("create") + .that.eql(false); + expect(scopedJwtClaims) + .to.have.property("io.hull.as") + .that.eql({ email: "foo@bar.com" }); + }); + + it("should allow to pass user id as a string", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + + const scoped = hull.as("123456"); + const scopedConfig = scoped.configuration(); + const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); + expect(scopedJwtClaims) + .to.have.property("sub") + .that.eql("123456"); + }); + + it("should allow to pass user id as an object property", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + + const scoped = hull.as({ id: "123456" }); + const scopedConfig = scoped.configuration(); + const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); + expect(scopedJwtClaims) + .to.have.property("sub") + .that.eql("123456"); + }); + }); }); From f55f3c2e6c5c7055f8c939fd17d4d93ed56c50e5 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Fri, 3 Mar 2017 01:04:49 +0100 Subject: [PATCH 05/21] Add response middleware tests and small adjustment --- src/utils/response-middleware.js | 14 +++++-- tests/utils/response-middleware-tests.js | 47 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/utils/response-middleware-tests.js diff --git a/src/utils/response-middleware.js b/src/utils/response-middleware.js index e2aec54..ac30ff2 100644 --- a/src/utils/response-middleware.js +++ b/src/utils/response-middleware.js @@ -1,9 +1,10 @@ import _ from "lodash"; /** - * @param {Object} req - * @param {Object} res - * @param {Function} next + * @example + * app.get("/", (req, res, next) => { + * promiseBasedFn.then(next, next); + * }, responseMiddleware()) */ export default function responseMiddlewareFactory() { return function responseMiddleware(result, req, res, next) { @@ -17,7 +18,12 @@ export default function responseMiddlewareFactory() { } else { res.status(200); } - result = (_.isString(result)) ? result : "ok"; + if (_.isError(result)) { + result = result.message || result; + } else { + result = (_.isString(result)) ? result : "ok"; + } + res.end(result); next(); }; diff --git a/tests/utils/response-middleware-tests.js b/tests/utils/response-middleware-tests.js new file mode 100644 index 0000000..069330b --- /dev/null +++ b/tests/utils/response-middleware-tests.js @@ -0,0 +1,47 @@ +/* global describe, it */ +import { expect, should } from "chai"; +import sinon from "sinon"; + +import responseMiddleware from "../../src/utils/response-middleware"; + +const resMock = { + status: () => {}, + end: () => {}, +} + +describe("responseMiddleware", () => { + it("should respond 200 ok for empty result", () => { + [null, undefined, 0].map(result => { + const mock = sinon.mock(resMock) + mock.expects("status").once().withArgs(200); + mock.expects("end").once().withArgs("ok"); + + const instance = responseMiddleware(); + instance(result, {}, resMock, () => {}); + mock.verify(); + }); + }); + + it("should respond 200 and string for body", () => { + ["some message", ""].map(result => { + const mock = sinon.mock(resMock) + mock.expects("status").once().withArgs(200); + mock.expects("end").once().withArgs(result); + + const instance = responseMiddleware(); + instance(result, {}, resMock, () => {}); + mock.verify(); + }); + }); + + it("should respond with 500 and error message", () => { + const mock = sinon.mock(resMock) + mock.expects("status").once().withArgs(500); + mock.expects("end").once().withArgs("some message"); + + const instance = responseMiddleware(); + instance(new Error("some message"), {}, resMock, () => {}); + + mock.verify(); + }); +}); From 0a39cbd4705dd12c3b9a0159dfde5a5a3f083a42 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Fri, 3 Mar 2017 17:23:33 +0100 Subject: [PATCH 06/21] Add error handler and update raven API --- src/infra/instrumentation/instrumentation-agent.js | 12 +++++++----- tests/infra/instrumentation-tests.js | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 tests/infra/instrumentation-tests.js diff --git a/src/infra/instrumentation/instrumentation-agent.js b/src/infra/instrumentation/instrumentation-agent.js index feef161..d1ce93b 100644 --- a/src/infra/instrumentation/instrumentation-agent.js +++ b/src/infra/instrumentation/instrumentation-agent.js @@ -1,5 +1,5 @@ import util from "util"; -import raven from "raven"; +import Raven from "raven"; import metrics from "datadog-metrics"; import dogapi from "dogapi"; @@ -32,10 +32,12 @@ export default class InstrumentationAgent { if (process.env.SENTRY_URL) { console.log("starting raven"); - this.raven = new raven.Client(process.env.SENTRY_URL, { + this.raven = Raven.config(process.env.SENTRY_URL, { release: this.manifest.version + }).install((logged, err) => { + console.error(logged, err.stack || err); + process.exit(1); }); - this.raven.patchGlobal(); } this.contextMiddleware = this.contextMiddleware.bind(this); @@ -70,7 +72,7 @@ export default class InstrumentationAgent { startMiddleware() { if (this.raven) { - return raven.middleware.express.requestHandler(this.raven); + return Raven.requestHandler(); } return (req, res, next) => { next(); @@ -79,7 +81,7 @@ export default class InstrumentationAgent { stopMiddleware() { if (this.raven) { - return raven.middleware.express.errorHandler(this.raven); + return Raven.errorHandler(); } return (req, res, next) => { next(); diff --git a/tests/infra/instrumentation-tests.js b/tests/infra/instrumentation-tests.js new file mode 100644 index 0000000..a24117e --- /dev/null +++ b/tests/infra/instrumentation-tests.js @@ -0,0 +1,12 @@ +/* global describe, it */ +import Promise from "bluebird"; +import { expect } from "chai"; + +import Instrumentation from "../../src/infra/instrumentation"; + +describe("Instrumentation", () => { + it.only("should start raven", () => { + process.env.SENTRY_URL = "https://user:pass@sentry.io/138436"; + const instumentation = new Instrumentation(); + }); +}); From f1b4225543ee1e5fb570b86bd60635d3589cc9fd Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Fri, 3 Mar 2017 17:24:06 +0100 Subject: [PATCH 07/21] captureUnhandledRejections: true --- src/infra/instrumentation/instrumentation-agent.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/infra/instrumentation/instrumentation-agent.js b/src/infra/instrumentation/instrumentation-agent.js index d1ce93b..dc77cf1 100644 --- a/src/infra/instrumentation/instrumentation-agent.js +++ b/src/infra/instrumentation/instrumentation-agent.js @@ -33,7 +33,8 @@ export default class InstrumentationAgent { if (process.env.SENTRY_URL) { console.log("starting raven"); this.raven = Raven.config(process.env.SENTRY_URL, { - release: this.manifest.version + release: this.manifest.version, + captureUnhandledRejections: true }).install((logged, err) => { console.error(logged, err.stack || err); process.exit(1); From 692ff1365d97027e762259fe052eca43ea8d28cb Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Mon, 6 Mar 2017 12:33:10 +0100 Subject: [PATCH 08/21] Change hull.as create claim and add instrumentation tests --- src/lib/crypto.js | 6 +++++- tests/client-tests.js | 5 +++-- tests/infra/instrumentation-tests.js | 5 +++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 493e463..84af867 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -71,7 +71,7 @@ module.exports = { */ lookupToken(config, user = {}, claimsOptions = {}) { checkConfig(config); - const claims = _.clone(claimsOptions); + const claims = {}; if (_.isString(user)) { if (!user) { throw new Error("Missing user ID"); } claims.sub = user; @@ -83,6 +83,10 @@ module.exports = { } claims["io.hull.as"] = user; } + + if (_.has(claimsOptions, "create")) { + claims["io.hull.create"] = claimsOptions.create; + } return buildToken(config, claims); }, diff --git a/tests/client-tests.js b/tests/client-tests.js index 73a3c34..647d346 100644 --- a/tests/client-tests.js +++ b/tests/client-tests.js @@ -25,14 +25,15 @@ describe("Hull", () => { }); describe("as", () => { - it("should allow to pass create option", () => { + it.only("should allow to pass create option", () => { const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); const scoped = hull.as({ email: "foo@bar.com" }, { create: false }); const scopedConfig = scoped.configuration(); const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); + console.log(scopedJwtClaims); expect(scopedJwtClaims) - .to.have.property("create") + .to.have.property("io.hull.create") .that.eql(false); expect(scopedJwtClaims) .to.have.property("io.hull.as") diff --git a/tests/infra/instrumentation-tests.js b/tests/infra/instrumentation-tests.js index a24117e..3eadd8a 100644 --- a/tests/infra/instrumentation-tests.js +++ b/tests/infra/instrumentation-tests.js @@ -5,8 +5,9 @@ import { expect } from "chai"; import Instrumentation from "../../src/infra/instrumentation"; describe("Instrumentation", () => { - it.only("should start raven", () => { + it("should start raven", () => { process.env.SENTRY_URL = "https://user:pass@sentry.io/138436"; - const instumentation = new Instrumentation(); + const instrumentation = new Instrumentation(); + expect(instrumentation).to.be.an("object"); }); }); From b62d672f118960274db3e3460299dae59e78bef8 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Mon, 6 Mar 2017 12:50:18 +0100 Subject: [PATCH 09/21] Enable all unit tests --- tests/client-tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client-tests.js b/tests/client-tests.js index 647d346..82ebc1d 100644 --- a/tests/client-tests.js +++ b/tests/client-tests.js @@ -25,7 +25,7 @@ describe("Hull", () => { }); describe("as", () => { - it.only("should allow to pass create option", () => { + it("should allow to pass create option", () => { const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); const scoped = hull.as({ email: "foo@bar.com" }, { create: false }); From 9ed567b98ca051d5d5339521cafa0bf85012d337 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Mon, 6 Mar 2017 16:27:58 +0100 Subject: [PATCH 10/21] Make notifHandler handling extracts --- src/extract.js | 7 ++++++- src/utils/notif-handler.js | 37 +++++++++++++++++++++++++++++++++++- tests/client-tests.js | 1 - tests/extract-tests.js | 2 +- tests/utils/notif-handler.js | 32 +++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/extract.js b/src/extract.js index eaa8030..167d8d5 100644 --- a/src/extract.js +++ b/src/extract.js @@ -45,7 +45,12 @@ export function handle({ body, batchSize, handler }) { */ export function request({ hostname, segment = null, format = "json", path = "batch", fields = [] } = {}) { const client = this; - const search = client.configuration(); + const conf = client.configuration(); + const search = { + ship: conf.id, + secret: conf.secret, + organization: conf.organization + }; if (segment) { search.segment_id = segment.id; } diff --git a/src/utils/notif-handler.js b/src/utils/notif-handler.js index f23adb3..f9a1726 100644 --- a/src/utils/notif-handler.js +++ b/src/utils/notif-handler.js @@ -89,8 +89,42 @@ function processHandlersFactory(handlers, userHandlerOptions) { }; } +function handleExtractFactory({ handlers, userHandlerOptions }) { + return function handleExtract(req, res, next) { + if (!req.body || !req.body.url || !req.body.format || !handlers["user:update"]) { + return next(); + } + + const { client, helpers } = req.hull; + return client.utils.extract.handle({ + body: req.body, + batchSize: userHandlerOptions.maxSize || 100, + handler: (users) => { + const segmentId = req.query.segment_id || null; + users = users.map(u => helpers.setUserSegments({ add_segment_ids: [segmentId] }, u)); + users = users.filter(u => helpers.filterUserSegments(u)); + if (userHandlerOptions.groupTraits) { + users = users.map(u => group(u)); + } + const messages = users.map((user) => { + return { + user, + segments: user.segment_ids.map(id => _.find(req.hull.segments, { id })) + }; + }); + return handlers["user:update"](req.hull, messages); + } + }).then(() => { + res.end("ok"); + }, (err) => { + res.end("err"); + client.logger.error("notifHandler.batch.err", err.stack || err); + }); + }; +} + -module.exports = function NotifHandler({ handlers = [], onSubscribe, userHandlerOptions = {} }) { +module.exports = function notifHandler({ handlers = {}, onSubscribe, userHandlerOptions = {} }) { const _handlers = {}; const app = express.Router(); @@ -110,6 +144,7 @@ module.exports = function NotifHandler({ handlers = [], onSubscribe, userHandler addEventHandlers(handlers); } + app.use(handleExtractFactory({ handlers, userHandlerOptions })); app.use((req, res, next) => { if (!req.hull.message) { const e = new Error("Empty Message"); diff --git a/tests/client-tests.js b/tests/client-tests.js index 82ebc1d..36a56b4 100644 --- a/tests/client-tests.js +++ b/tests/client-tests.js @@ -31,7 +31,6 @@ describe("Hull", () => { const scoped = hull.as({ email: "foo@bar.com" }, { create: false }); const scopedConfig = scoped.configuration(); const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); - console.log(scopedJwtClaims); expect(scopedJwtClaims) .to.have.property("io.hull.create") .that.eql(false); diff --git a/tests/extract-tests.js b/tests/extract-tests.js index a6ce8c4..77d5aee 100644 --- a/tests/extract-tests.js +++ b/tests/extract-tests.js @@ -22,7 +22,7 @@ describe("extract.request", () => { .then(() => { expect(this.postStub.calledOnce).to.be.true; expect(this.postStub.calledWith("extract/user_reports", { - url: `https://localhost/batch?id=${stub.configuration().id}&secret=shutt&organization=xxx.hulltest.rocks`, + url: `https://localhost/batch?ship=${stub.configuration().id}&secret=shutt&organization=xxx.hulltest.rocks`, query: {}, format: "json", fields: [] diff --git a/tests/utils/notif-handler.js b/tests/utils/notif-handler.js index 05b91e8..fcd6ceb 100644 --- a/tests/utils/notif-handler.js +++ b/tests/utils/notif-handler.js @@ -94,4 +94,36 @@ describe("NotifHandler", () => { }); }); }); + + it("should handle a batch extract", (done) => { + const handler = sinon.spy(); + const extractHandler = sinon.spy(); + const app = express(); + const body = { url: "http://localhost:9000/extract.json", format: "json" }; + + app.use(notifMiddleware()); + app.use(mockHullMiddleware); + app.use((req, res, next) => { + req.hull.client.utils = { + extract: { + handle: extractHandler + } + }; + next(); + }); + app.use("/notify", notifHandler({ + handlers: { + "user:update": handler + } + })); + const server = app.listen(() => { + const port = server.address().port; + post({ port, body }) + .then(() => { + expect(extractHandler.calledOnce).to.be.ok; + expect(extractHandler.getCall(0).args[0].body).to.eql(body); + done(); + }); + }); + }); }); From bd8de66dfb95aa90582189ac076f57a99ebf5956 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Mon, 6 Mar 2017 16:33:43 +0100 Subject: [PATCH 11/21] Fill in docs example --- README.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dd5222e..a338541 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Performs a `hull.post("extract/user_reports", {})` call building all needed prop - **hostname** - a hostname where the extract should be sent - **path** - a path of the endpoint which will handle the extract (*batch*) - **fields** - an array of users attributes to extract -- **format** - prefered format +- **format** - prefered format (*jsonx*) - **segment** - extract only users matching selected segment, this needs to be an object with `id` at least, `segment.query` is optional ### extract.handle({ body, batchSize, handler }) @@ -275,6 +275,36 @@ This utility does it for you. ### properties.get() A wrapper over `hull.get("search/user_reports/bootstrap")` call which unpacks the list of properties. +```json +{ + "id": { + "id": "id", + "text": "Hull ID", + "type": "string", + "id_path": [ + "User" + ], + "path": [ + "User" + ], + "title": "Hull ID", + "key": "id" + }, + "created_at": { + "id": "created_at", + "text": "Signup on", + "type": "date", + "id_path": [ + "User" + ], + "path": [ + "User" + ], + "title": "Signup on", + "key": "created_at" + } +} +``` ### Logging Methods: Hull.logger.debug(), Hull.logger.info() ... From e19817003f4f22a862787021997d633b03954f0b Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Mon, 6 Mar 2017 17:13:48 +0100 Subject: [PATCH 12/21] Adds active claim to `as` method --- src/lib/crypto.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 84af867..896b47a 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -87,6 +87,11 @@ module.exports = { if (_.has(claimsOptions, "create")) { claims["io.hull.create"] = claimsOptions.create; } + + if (_.has(claimsOptions, "active")) { + claims["io.hull.active"] = claimsOptions.active; + } + return buildToken(config, claims); }, From 2b48a07d5f334b6b4348eaa4a4a8e8062938aaeb Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Mon, 13 Mar 2017 15:53:51 +0100 Subject: [PATCH 13/21] Fix filtering on batch and notify handlers --- src/utils/batch-handler.js | 4 +- src/utils/notif-handler.js | 40 +++++++++++++------- tests/fixtures/sns-messages/user-report.json | 4 +- tests/utils/notif-handler.js | 29 ++++++++++++++ 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/utils/batch-handler.js b/src/utils/batch-handler.js index 30acff8..4f0cb24 100644 --- a/src/utils/batch-handler.js +++ b/src/utils/batch-handler.js @@ -4,6 +4,9 @@ import { group } from "../trait"; import responseMiddleware from "./response-middleware"; import requireHullMiddleware from "./require-hull-middleware"; +/** + * @deprecated Use `notifyHandler` instead. + */ export default function batchHandler(handler, { batchSize = 100, groupTraits = false } = {}) { const router = Router(); router.use(requireHullMiddleware()); @@ -16,7 +19,6 @@ export default function batchHandler(handler, { batchSize = 100, groupTraits = f handler: (users) => { const segmentId = req.query.segment_id || null; users = users.map(u => helpers.setUserSegments({ add_segment_ids: [segmentId] }, u)); - users = users.filter(u => helpers.filterUserSegments(u)); if (groupTraits) { users = users.map(u => group(u)); } diff --git a/src/utils/notif-handler.js b/src/utils/notif-handler.js index f9a1726..f8db47c 100644 --- a/src/utils/notif-handler.js +++ b/src/utils/notif-handler.js @@ -50,22 +50,34 @@ function processHandlersFactory(handlers, userHandlerOptions) { if (messageHandlers && messageHandlers.length > 0) { if (message.Subject === "user_report:update") { + // set `segment_ids` and `remove_segment_ids` on the user + const { left = [] } = _.get(notification, "message.changes.segments", {}); + notification.message.user = context.helpers.setUserSegments({ + add_segment_ids: notification.message.segments.map(s => s.id), + remove_segment_ids: left.map(s => s.id) + }, notification.message.user); + + // optionally group user traits if (notification.message && notification.message.user && userHandlerOptions.groupTraits) { notification.message.user = group(notification.message.user); } - processing.push(Promise.all(messageHandlers.map((handler, i) => { - return Batcher.getHandler(`${ns}-${eventName}-${i}`, { - ctx: context, - options: { - maxSize: userHandlerOptions.maxSize || 100, - maxTime: userHandlerOptions.maxTime || 10000 - } - }) - .setCallback((messages) => { - return handler(context, messages); - }) - .addMessage(notification.message); - }))); + + // if the user matches the filter segments + if (context.helpers.filterUserSegments(notification.message.user)) { + processing.push(Promise.all(messageHandlers.map((handler, i) => { + return Batcher.getHandler(`${ns}-${eventName}-${i}`, { + ctx: context, + options: { + maxSize: userHandlerOptions.maxSize || 100, + maxTime: userHandlerOptions.maxTime || 10000 + } + }) + .setCallback((messages) => { + return handler(context, messages); + }) + .addMessage(notification.message); + }))); + } } else { processing.push(Promise.all(messageHandlers.map((handler) => { return handler(context, notification.message); @@ -84,6 +96,7 @@ function processHandlersFactory(handlers, userHandlerOptions) { return next(); } catch (err) { err.status = 400; + console.error(err.stack || err); return next(err); } }; @@ -102,7 +115,6 @@ function handleExtractFactory({ handlers, userHandlerOptions }) { handler: (users) => { const segmentId = req.query.segment_id || null; users = users.map(u => helpers.setUserSegments({ add_segment_ids: [segmentId] }, u)); - users = users.filter(u => helpers.filterUserSegments(u)); if (userHandlerOptions.groupTraits) { users = users.map(u => group(u)); } diff --git a/tests/fixtures/sns-messages/user-report.json b/tests/fixtures/sns-messages/user-report.json index 2dfd9e4..bb7d1a8 100644 --- a/tests/fixtures/sns-messages/user-report.json +++ b/tests/fixtures/sns-messages/user-report.json @@ -1,5 +1,5 @@ { "Type": "Notification", - "Subject": "user:update", - "Message": "{}" + "Subject": "user_report:update", + "Message": "{\"user\":{\"id\":\"5874cf3273d64d280b000ffb\"},\"segments\":[{\"id\":\"579677ff03777d1769000042\",\"name\":\"Not in mailchimp\",\"type\":\"users_segment\",\"created_at\":\"2016-07-25T20:35:11Z\",\"updated_at\":\"2017-01-02T08:59:30Z\"}],\"events\":[],\"changes\":{\"user\":{\"is_approved\":[true,false]},\"segments\":{\"left\":[{\"id\":\"578feb3d44d74b8a4f000054\",\"name\":\"Approved users\",\"type\":\"users_segment\",\"created_at\":\"2016-07-20T21:21:01Z\",\"updated_at\":\"2016-07-20T21:21:01Z\"}]},\"is_new\":false}}" } diff --git a/tests/utils/notif-handler.js b/tests/utils/notif-handler.js index fcd6ceb..9656318 100644 --- a/tests/utils/notif-handler.js +++ b/tests/utils/notif-handler.js @@ -126,4 +126,33 @@ describe("NotifHandler", () => { }); }); }); + + it("should add segment infromation to the user", (done) => { + const handler = sinon.spy(); + const setUserSegments = sinon.spy(); + const filterUserSegments = sinon.spy(); + const body = userUpdate; + const app = express(); + + app.use(notifMiddleware()); + app.use(mockHullMiddleware); + app.use((req, res, next) => { + req.hull.helpers = { setUserSegments, filterUserSegments }; + next(); + }); + app.use("/notify", notifHandler({ + handlers: { + "user:update": handler + } + })); + const server = app.listen(() => { + const port = server.address().port; + post({ port, body }) + .then(() => { + expect(setUserSegments.calledOnce).to.be.true; + expect(filterUserSegments.calledOnce).to.be.true; + done(); + }); + }); + }); }); From 70da8f71bb1058c076ca952038e1d38b20ae298d Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Tue, 14 Mar 2017 15:52:33 +0100 Subject: [PATCH 14/21] Update CHANGELOG --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c03097..5ae83dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 0.11.0-beta.2 +* Reorganize the utils/helpers +* Introduce hull.as() create option +* Upgrade raven API and add default exit handler +* Combine notifHandler and batchHandler +* Automatically filter out users using segment filter on user:update and NOT on batch actions + # 0.11.0-beta.1 * Adds `/app` with `Hull.App`, `Server` and `Worker` From 6938c4df0d84901a7d15a4ff3bd73aefeb4fa079 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Wed, 15 Mar 2017 10:23:56 +0100 Subject: [PATCH 15/21] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a338541..2021ca7 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Performs a `hull.post("extract/user_reports", {})` call building all needed prop - **hostname** - a hostname where the extract should be sent - **path** - a path of the endpoint which will handle the extract (*batch*) - **fields** - an array of users attributes to extract -- **format** - prefered format (*jsonx*) +- **format** - prefered format (*json*) - **segment** - extract only users matching selected segment, this needs to be an object with `id` at least, `segment.query` is optional ### extract.handle({ body, batchSize, handler }) From 3374ad9b8bd805a68bef1637e2860579474521b0 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Wed, 15 Mar 2017 10:25:53 +0100 Subject: [PATCH 16/21] Version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1e5bfaa..9109d0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hull", - "version": "0.11.0-beta.1", + "version": "0.11.0-beta.2", "description": "A Node.js client for hull.io", "main": "lib", "repository": { From f85d34566c6c81ee2632390273a61251874b2f85 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Wed, 15 Mar 2017 13:30:45 +0100 Subject: [PATCH 17/21] Moves the helper initialization out of Hull.Middleware --- README.md | 170 +++++++++++++++++++++----------- src/middleware/client.js | 2 - src/utils/helpers-middleware.js | 10 ++ 3 files changed, 121 insertions(+), 61 deletions(-) create mode 100644 src/utils/helpers-middleware.js diff --git a/README.md b/README.md index 2021ca7..ac65e17 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This library makes it easy to interact with the Hull API, send tracking and properties and handle Server-side Events we send to installed Ships. +Creating a new Hull client is pretty straightforward: + ```js import Hull from 'hull'; @@ -12,26 +14,23 @@ const hull = new Hull({ }); ``` -Creating a new Hull client is pretty straightforward. -In Ship Events, we create and scope one for you to abstract the lifecycle - -[More detailed documentation about Hull Client and about Connector development is available at gitbook.](https://hull.gitbooks.io/docs/) +[Detailed documentation about Hull Client and about Connector development is available at gitbook.](https://hull.gitbooks.io/docs/) ## Calling the API +Once you have instantiated a client, you can use one of the `get`, `post`, +`put`or `delete` methods to perform actions of our APIs. + ```js -//hull.api.get works too. -const params = {} -hull.get(path, params).then(function(data){ +// hull.api.get works too. +const params = {}; +hull.get(path, params).then(function(data) { console.log(response); -},function(err, response){ +}, function(err, response) { console.log(err); }); ``` -> Once you have instantiated a client, you can use one of the `get`, `post`, -`put`or `delete` methods to perform actions of our APIs. - The first parameter is the route, the second is the set of parameters you want to send with the request. They all return Promises so you can use the `.then()` syntax if you're more inclined. @@ -39,12 +38,25 @@ to send with the request. They all return Promises so you can use the `.then()` ## hull.configuration() -Returns the global configuration +Returns the global configuration object. + +```js +hull.configuration(); +// returns: +{ prefix: '/api/v1', + domain: 'hullapp.io', + protocol: 'https', + id: '58765f7de3aa14001999', + secret: '12347asc855041674dc961af50fc1', + organization: 'fa4321.hullbeta.io', + version: '0.7.4' } +``` + ## hull.userToken() ```js -hull.userToken({email:'xxx@example.com',name:'FooBar'}, claims) +hull.userToken({ email:'xxx@example.com', name:'FooBar' }, claims); ``` Used for [Bring your own users](http://hull.io/docs/users/byou). @@ -66,91 +78,92 @@ const app = express(); // a middleware with no mount path; gets executed for every request to the app app.use(hull.currentUserMiddleware); -app.use(function(req,res,next){ - console.log(req.hull.userId) // Should exist if there is a user logged in; +app.use(function(req,res,next) { + console.log(req.hull.userId); // Should exist if there is a user logged in; }) ``` Reverse of Bring your own Users. When using Hull's Identity management, tells you who the current user is. Generates a middleware to add to your Connect/Express apps. -# Impersonating a User +## Impersonating a User - hull.as() + +One of the more frequent use case is to perform API calls with the identity of a given user. We provide several methods to do so. ```js -//If you only have an anonymous ID, use the `anonymous_id` field -var user = hull.as({ anonymous_id: '123456789' }); +// if you have a user id from your database, use the `external_id` field +const user = hull.as({ external_id: "dkjf565wd654e" }); -//if you have a user id from your database, use the `external_id` field -var user = hull.as({ external_id: 'dkjf565wd654e' }); +// if you have a Hull Internal User Id: +const user = hull.as({ id: "5718b59b7a85ebf20e000169" }); +// or just as a string: +const user = hull.as("5718b59b7a85ebf20e000169"); -//if you retrieved a Hull Internal User Id: -//second argument is optional and specifies wether we get the user's right or admin rights. -var user = hull.as('5718b59b7a85ebf20e000169', false); +// you can optionally pass additional user resolution options as a second argument: +const user = hull.as({ id: "5718b59b7a85ebf20e000169" }, { create: false }); -//user is an instance of Hull, scoped to a specific user. -//Default is false: "get user rights". -user.get('/me').then(function(me){ - console.log(me) +// Constant `user` is an instance of Hull, scoped to a specific user. +user.get("/me").then(function(me) { + console.log(me); }); user.userToken(); -//It will act as if the user performed the action if the second parameter is falsy ``` -One of the more frequent use case is to perform API calls with the identity of a given user. We provide several methods to do so. - -You can use an internal Hull `id`, an Anonymous ID from that we call a `anonymous_id`, an ID from your database that we call `external_id`, or even the ID from a supported social service such as Instagram; +You can use an internal Hull `id`, an ID from your database that we call `external_id`, an `email` address or `guest_id`. Assigning the `user` variable doesn't make an API call, it scopes the calls to another instance of `hull` client. This means `user` is an instance of the `hull` client scoped to this user. -The second parameter lets you define whether the calls are perform with Admin rights or the User's rights. +The second parameter lets you define additional options passed to the user resolution script: + +* **create** - *boolean* - marks if the user should be lazily created if not found (default: *true*) > Return a hull `client` scoped to the user identified by it's Hull ID. Not lazily created. Needs an existing User ```js -hull.as(userId) +hull.as(userId); ``` > Return a hull `client` scoped to the user identified by it's Social network ID. Lazily created if [Guest Users](http://www.hull.io/docs/users/guest_users) are enabled ```js -hull.as('instagram|facebook|google:userId', sudo) +hull.as('instagram|facebook|google:userId'); ``` > Return a hull `client` scoped to the user identified by it's External ID (from your dashboard). Lazily created if [Guest Users](http://www.hull.io/docs/users/guest_users) are enabled ```js -hull.as({external_id:'externalId'}, sudo) +hull.as({ external_id: 'externalId' }); ``` > Return a hull `client` scoped to the user identified by it's External ID (from your dashboard). Lazily created if [Guest Users](http://www.hull.io/docs/users/guest_users) are enabled ```js -hull.as({anonymous_id:'anonymousId'}, sudo) +hull.as({ anonymous_id: 'anonymousId' }); ``` > Return a hull `client` scoped to the user identified by only by an anonymousId. Lets you start tracking and storing properties from a user before you have a UserID ready for him. Lazily created if [Guest Users](http://www.hull.io/docs/users/guest_users) are enabled > When you have a UserId, just pass both to link them. ```js -hull.as({email:'user@email.com'}, sudo) +hull.as({ email: "user@email.com" }); ``` -# Methods for user-scoped instances +# Methods for user-scoped instance ```js -const sudo = true; -const userId = '5718b59b7a85ebf20e000169'; -const externalId = 'dkjf565wd654e'; -const anonymousId = '44564-EJVWE-1CE56SE-SDVE879VW8D4'; +const userId = "5718b59b7a85ebf20e000169"; +const externalId = "dkjf565wd654e"; +const anonymousId = "44564-EJVWE-1CE56SE-SDVE879VW8D4"; -const user = hull.as({external_id: externalId, anonymous_id: anonymousId}) +const user = hull.as({ external_id: externalId, anonymous_id: anonymousId }); ``` When you do this, you get a new client that has a different behaviour. It's now behaving as a User would. It means it does API calls as a user and has new methods to track and store properties ## user.track(event, props, context) +Stores a new event. ```js user.track('new support ticket', { messages: 3, @@ -165,8 +178,6 @@ user.track('new support ticket', { messages: 3, }); ``` -Stores a new event. - The `context` object lets you define event meta-data. Everything is optional - `source`: Defines a namespace, such as `zendesk`, `mailchimp`, `stripe` @@ -179,19 +190,18 @@ The `context` object lets you define event meta-data. Everything is optional ## user.traits(properties, context) +Stores Attributes on the user: + ```js user.traits({ opened_tickets: 12 }, { source: 'zendesk' }); // 'source' is optional. Will store the traits grouped under the source name. // Alternatively, you can send properties for multiple groups with the flat syntax: -// user.traits({ "zendesk/opened_tickets": 12, "clearbit/name": "toto"}); +user.traits({ "zendesk/opened_tickets": 12, "clearbit/name": "foo" }); ``` -Stores Properties on the user. - -If you need to be sure the properties are set immediately on the user, you can use the context param `{ sync: true }`. - +By default the `traits` calls are grouped in background and send to the Hull API in batches, that will cause some small delay. If you need to be sure the properties are set immediately on the user, you can use the context param `{ sync: true }`. ```js user.traits({ @@ -204,12 +214,11 @@ user.traits({ ## Utils -The Hull API returns traits in a "flat" format, with '/' delimiters in the key. -The Events handler Returns a grouped version of the traits in the flat user report we return from the API. -> The NotifHandler already does this by default. - ### traits.group(user_report) +The Hull API returns traits in a "flat" format, with '/' delimiters in the key. +`hull.utils.traits.group(user_report)` can be used to group those traits into subobjects: + ```js import { group: groupTraits } from "hull/trait"; @@ -253,17 +262,40 @@ const userGroupedTraits = hull.utils.traits.group(user_report); ``` ### extract.request({ hostname, segment = null, format = "json", path = "batch", fields = [] }) -Performs a `hull.post("extract/user_reports", {})` call building all needed properties. It takes following arguments: + +```js +hull.utils.extract.request({ + hostname: "https://some-public-url.com", + fields: ["first_name", "last_name"], + segment: { + id: "54321" + } +}); +``` + +Performs a `hull.post("extract/user_reports", {})` call, building all needed properties for the action. It takes following arguments: - **hostname** - a hostname where the extract should be sent -- **path** - a path of the endpoint which will handle the extract (*batch*) -- **fields** - an array of users attributes to extract -- **format** - prefered format (*json*) +- **path** - a path of the endpoint which will handle the extract (default: *batch*) +- **fields** - an array of users attributes to extract (default: *[]*) +- **format** - prefered format (default: *json*) - **segment** - extract only users matching selected segment, this needs to be an object with `id` at least, `segment.query` is optional ### extract.handle({ body, batchSize, handler }) + The utility to download and parse the incoming extract. +```js +hull.utils.extract.handle({ + body: req.body, + batchSize: 50, // get 50 users at once + handler: (users) => { + assert(users.length <= 50); + } +}); + +``` + - **body** - json of incoming extract message - must contain `url` and `format` - **batchSize** - number of users to be passed to each handler call - **handler** - the callback function which would be called with batches of users @@ -272,10 +304,30 @@ The utility to download and parse the incoming extract. A helper utility which simplify `hull.put("app", { private_settings })` calls. Using raw API you need to merge existing settings with those you want to update. This utility does it for you. +```js +const hull = new Hull({ config }); + +hull.get("app") + .then(ship => { + assert.equal(ship.private_settings.existing_property, "foo"); + return hull.utils.settings.update({ new_property: "bar"}); + }) + .then(() => hull.get("app")) + .then(ship => { + assert.equal(ship.private_settings.existing_property, "foo"); + assert.equal(ship.private_settings.new_property, "bar"); + }); +``` + ### properties.get() A wrapper over `hull.get("search/user_reports/bootstrap")` call which unpacks the list of properties. -```json +```js +hull.utils.properties.get() + .then(properties => { + console.log(properties); // see result below + }); + { "id": { "id": "id", diff --git a/src/middleware/client.js b/src/middleware/client.js index 2a0a967..3ff7a16 100644 --- a/src/middleware/client.js +++ b/src/middleware/client.js @@ -1,6 +1,5 @@ import _ from "lodash"; import jwt from "jwt-simple"; -import * as helpers from "../helpers"; function parseQueryString(query) { return ["organization", "ship", "secret"].reduce((cfg, k) => { @@ -66,7 +65,6 @@ module.exports = function hullClientMiddlewareFactory(Client, { hostSecret, clie // Promise return getCurrentShip(id, req.hull.client, req.hull.cache, bust).then((ship = {}) => { req.hull.ship = ship; - req.hull.helpers = _.mapValues(helpers, func => func.bind(null, req.hull)); req.hull.hostname = req.hostname; return next(); }, (err) => { diff --git a/src/utils/helpers-middleware.js b/src/utils/helpers-middleware.js new file mode 100644 index 0000000..4d2e59e --- /dev/null +++ b/src/utils/helpers-middleware.js @@ -0,0 +1,10 @@ +import _ from "lodash"; +import * as helpers from "../helpers"; + +export default function helpersMiddlewareFactory() { + return function helpersMiddleware(req, res, next) { + req.hull = req.hull || {}; + req.hull.helpers = _.mapValues(helpers, func => func.bind(null, req.hull)); + next(); + }; +} From f7a681f6c53219105b12f596e279a465a7bbd815 Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Wed, 15 Mar 2017 15:35:06 +0100 Subject: [PATCH 18/21] Move helpers out of hull middleware --- src/connector/hull-connector.js | 3 ++- src/utils/index.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/connector/hull-connector.js b/src/connector/hull-connector.js index e5b0451..1489cc4 100644 --- a/src/connector/hull-connector.js +++ b/src/connector/hull-connector.js @@ -3,7 +3,7 @@ import Promise from "bluebird"; import setupApp from "./setup-app"; import Worker from "./worker"; import { Instrumentation, Cache, Queue, Batcher } from "../infra"; -import { exitHandler, serviceMiddleware, notifMiddleware, segmentsMiddleware, requireHullMiddleware } from "../utils"; +import { exitHandler, serviceMiddleware, notifMiddleware, segmentsMiddleware, requireHullMiddleware, helpersMiddleware } from "../utils"; export default class HullConnector { @@ -37,6 +37,7 @@ export default class HullConnector { queue: this.queue }); app.use(this.clientMiddleware()); + app.use(helpersMiddleware()); app.use(segmentsMiddleware()); app.use(serviceMiddleware(this.service)); return app; diff --git a/src/utils/index.js b/src/utils/index.js index 8b5d52b..2db54d9 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -13,3 +13,4 @@ export responseMiddleware from "./response-middleware"; export serviceMiddleware from "./service-middleware"; export notifMiddleware from "./notif-middleware"; export segmentsMiddleware from "./segments-middleware"; +export helpersMiddleware from "./helpers-middleware"; From 29d712732ed9d372308b834367d9dab4d661830b Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Wed, 15 Mar 2017 16:23:18 +0100 Subject: [PATCH 19/21] Unify token methods --- README.md | 23 +++++++++++------------ src/client.js | 4 ---- src/lib/crypto.js | 23 ----------------------- 3 files changed, 11 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index ac65e17..86afc38 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ hull.configuration(); ## hull.userToken() ```js -hull.userToken({ email:'xxx@example.com', name:'FooBar' }, claims); +hull.userToken({ email:'xxx@example.com', name:'FooBar' }, optionalClaims); ``` Used for [Bring your own users](http://hull.io/docs/users/byou). @@ -78,9 +78,9 @@ const app = express(); // a middleware with no mount path; gets executed for every request to the app app.use(hull.currentUserMiddleware); -app.use(function(req,res,next) { +app.use(function(req, res, next) { console.log(req.hull.userId); // Should exist if there is a user logged in; -}) +}); ``` Reverse of Bring your own Users. When using Hull's Identity management, tells you who the current user is. Generates a middleware to add to your Connect/Express apps. @@ -112,11 +112,11 @@ You can use an internal Hull `id`, an ID from your database that we call `extern Assigning the `user` variable doesn't make an API call, it scopes the calls to another instance of `hull` client. This means `user` is an instance of the `hull` client scoped to this user. -The second parameter lets you define additional options passed to the user resolution script: +The second parameter lets you define additional options (JWT claims) passed to the user resolution script: * **create** - *boolean* - marks if the user should be lazily created if not found (default: *true*) - +### Possible usage > Return a hull `client` scoped to the user identified by it's Hull ID. Not lazily created. Needs an existing User ```js @@ -152,7 +152,6 @@ hull.as({ email: "user@email.com" }); # Methods for user-scoped instance ```js -const userId = "5718b59b7a85ebf20e000169"; const externalId = "dkjf565wd654e"; const anonymousId = "44564-EJVWE-1CE56SE-SDVE879VW8D4"; @@ -180,12 +179,12 @@ user.track('new support ticket', { messages: 3, The `context` object lets you define event meta-data. Everything is optional -- `source`: Defines a namespace, such as `zendesk`, `mailchimp`, `stripe` -- `type`: Define a event type, such as `mail`, `ticket`, `payment` -- `created_at`: Define an event date. defaults to `now()` -- `event_id`: Define a way to de-duplicate events. If you pass events with the same unique `event_id`, they will overwrite the previous one. -- `ip`: Define the Event's IP. Set to `null` if you're storing a server call, otherwise, geoIP will locate this event. -- `referer`: Define the Referer. `null` for server calls. +- **source**: Defines a namespace, such as `zendesk`, `mailchimp`, `stripe` +- **type**: Define a event type, such as `mail`, `ticket`, `payment` +- **created_at**: Define an event date. defaults to `now()` +- **event_id**: Define a way to de-duplicate events. If you pass events with the same unique `event_id`, they will overwrite the previous one. +- **ip**: Define the Event's IP. Set to `null` if you're storing a server call, otherwise, geoIP will locate this event. +- **referer**: Define the Referer. `null` for server calls. ## user.traits(properties, context) diff --git a/src/client.js b/src/client.js index 1364d8e..62790dd 100644 --- a/src/client.js +++ b/src/client.js @@ -45,10 +45,6 @@ const Client = function Client(config = {}) { }); this.userToken = function userToken(data = clientConfig.get("userId"), claims) { - return crypto.userToken(clientConfig.get(), data, claims); - }; - - this.lookupToken = function userToken(data = clientConfig.get("userId"), claims) { return crypto.lookupToken(clientConfig.get(), data, claims); }; diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 896b47a..32a6c71 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -39,27 +39,6 @@ module.exports = { checkConfig(config); return sign(config, data); }, - /** - * Calculates the hash for a user so an external userbase can be linked to hull.io services - io.hull.user - * - * @param {Object} config object - * @param {Object} user object or user ID as string - * @param {Object} additionnal claims - * @returns {String} The jwt token to identity the user. - */ - userToken(config, user = {}, claims = {}) { - checkConfig(config); - if (_.isString(user)) { - if (!user) { throw new Error("Missing user ID"); } - claims.sub = user; - } else { - if (!_.isObject(user) || (!user.email && !user.external_id && !user.guest_id)) { - throw new Error("you need to pass a User hash with an `email` or `external_id` or `guest_id` field"); - } - claims["io.hull.user"] = user; - } - return buildToken(config, claims); - }, /** * Calculates the hash for a user lookup - io.hull.as @@ -109,6 +88,4 @@ module.exports = { const data = [time, userId].join("-"); return sign(config, data) === signature; } - - }; From 366931677aba12a9c587b925cd81ef1df030041e Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Fri, 17 Mar 2017 13:31:21 +0100 Subject: [PATCH 20/21] Fix response middleware usage and add more tests --- package.json | 1 + src/utils/action-handler.js | 2 +- src/utils/batch-handler.js | 2 +- src/utils/batcher-handler.js | 2 +- src/utils/require-hull-middleware.js | 4 +- tests/utils/action-handler-tests.js | 92 ++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/utils/action-handler-tests.js diff --git a/package.json b/package.json index 9109d0b..b9357dd 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "isparta": "^4.0.0", "mkdirp": "^0.5.1", "mocha": "^3.0.0", + "node-mocks-http": "^1.6.1", "rimraf": "^2.6.0", "sinon": "^1.17.3", "sinon-chai": "^2.8.0", diff --git a/src/utils/action-handler.js b/src/utils/action-handler.js index 943df30..0644571 100644 --- a/src/utils/action-handler.js +++ b/src/utils/action-handler.js @@ -11,7 +11,7 @@ export default function actionHandler(handler) { router.post("/", (req, res, next) => { return Promise.resolve(handler(req.hull, _.pick(req, ["body", "query"]))).then(next, next); }); - router.use(responseMiddleware); + router.use(responseMiddleware()); return router; } diff --git a/src/utils/batch-handler.js b/src/utils/batch-handler.js index 4f0cb24..34bba8a 100644 --- a/src/utils/batch-handler.js +++ b/src/utils/batch-handler.js @@ -26,7 +26,7 @@ export default function batchHandler(handler, { batchSize = 100, groupTraits = f } }).then(next, next); }); - router.use(responseMiddleware); + router.use(responseMiddleware()); return router; } diff --git a/src/utils/batcher-handler.js b/src/utils/batcher-handler.js index bcfc1fb..29c46a8 100644 --- a/src/utils/batcher-handler.js +++ b/src/utils/batcher-handler.js @@ -23,7 +23,7 @@ export default function batcherHandler(handler, { maxSize = 100, maxTime = 10000 .addMessage({ body: req.body, query: req.query }) .then(next, next); }); - router.use(responseMiddleware); + router.use(responseMiddleware()); return router; } diff --git a/src/utils/require-hull-middleware.js b/src/utils/require-hull-middleware.js index d56f194..9294844 100644 --- a/src/utils/require-hull-middleware.js +++ b/src/utils/require-hull-middleware.js @@ -1,7 +1,7 @@ export default function requireHullMiddlewareFactory() { return function requireHullMiddleware(req, res, next) { - if (!req.hull.client) { - return res.status(403).send("Missing credentials. Set one of token or hullToken or set of id, organization, secret"); + if (!req.hull || !req.hull.client) { + return res.status(403).send("Missing credentials. Set one of token or hullToken or set of ship, organization, secret"); } return next(); }; diff --git a/tests/utils/action-handler-tests.js b/tests/utils/action-handler-tests.js new file mode 100644 index 0000000..a31263e --- /dev/null +++ b/tests/utils/action-handler-tests.js @@ -0,0 +1,92 @@ +/* global describe, it */ +import { expect, should } from "chai"; +import sinon from "sinon"; +import httpMocks from "node-mocks-http"; +import hullStub from "../support/hull-stub"; +import Promise from "bluebird"; + +import actionHandler from "../../src/utils/action-handler"; + + + +describe("actionHandler", () => { + it("should support plain truthy return values", (done) => { + const request = httpMocks.createRequest({ + method: 'POST', + url: '/' + }); + request.hull = { + client: new hullStub + }; + const response = httpMocks.createResponse(); + actionHandler(() => { + return "done"; + }).handle(request, response, (err) => { + expect(response.statusCode).to.equal(200); + expect(response._isEndCalled()).to.be.ok; + expect(response._getData()).to.equal("done"); + done(); + }); + }); + + it("should support plain error return values", (done) => { + const request = httpMocks.createRequest({ + method: 'POST', + url: '/' + }); + request.hull = { + client: new hullStub + }; + const response = httpMocks.createResponse(); + actionHandler(() => { + return new Error("Something went bad"); + }).handle(request, response, (err) => { + expect(response.statusCode).to.equal(500); + expect(response._isEndCalled()).to.be.ok; + expect(response._getData()).to.equal("Something went bad"); + done(); + }); + }); + + it("should support resolving promises", (done) => { + const request = httpMocks.createRequest({ + method: 'POST', + url: '/' + }); + request.hull = { + client: new hullStub + }; + const response = httpMocks.createResponse(); + actionHandler(() => { + return new Promise((resolve, reject) => { + resolve("test"); + }); + }).handle(request, response, (err) => { + expect(response.statusCode).to.equal(200); + expect(response._isEndCalled()).to.be.ok; + expect(response._getData()).to.equal("test"); + done(); + }); + }); + + it("should support promises rejected with an error", (done) => { + const request = httpMocks.createRequest({ + method: 'POST', + url: '/' + }); + request.hull = { + client: new hullStub + }; + const response = httpMocks.createResponse(); + actionHandler(() => { + return new Promise((resolve, reject) => { + reject(new Error("test")); + }); + }).handle(request, response, (err) => { + expect(response.statusCode).to.equal(500); + expect(response._isEndCalled()).to.be.ok; + expect(response._getData()).to.equal("test"); + done(); + }); + }); +}); From 402a69971aedbd92a93cee92ff9629f58fac703e Mon Sep 17 00:00:00 2001 From: Michal Raczka Date: Fri, 17 Mar 2017 14:46:12 +0100 Subject: [PATCH 21/21] Adds initial account support --- src/client.js | 17 ++++++++++++----- src/configuration.js | 14 ++++++++++---- src/lib/crypto.js | 44 ++++++++++++++++++++++++++++--------------- tests/client-tests.js | 21 ++++++++++++++++----- 4 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/client.js b/src/client.js index 62790dd..ccf088b 100644 --- a/src/client.js +++ b/src/client.js @@ -45,7 +45,7 @@ const Client = function Client(config = {}) { }); this.userToken = function userToken(data = clientConfig.get("userId"), claims) { - return crypto.lookupToken(clientConfig.get(), data, claims); + return crypto.lookupToken(clientConfig.get(), "user", data, claims); }; this.currentUserMiddleware = currentUserMiddleware.bind(this, clientConfig.get()); @@ -115,11 +115,18 @@ const Client = function Client(config = {}) { }); }; } else { - this.as = (userClaims, userClaimsOptions) => { - if (!userClaims) { - throw new Error("User Claims was not defined when calling hull.as()"); + this.asUser = (userClaim, additionalClaims) => { + if (!userClaim) { + throw new Error("User Claims was not defined when calling hull.asUser()"); } - return new Client({ ...config, userClaims, userClaimsOptions }); + return new Client({ ...config, userClaim, additionalClaims }); + }; + + this.asAccount = (accountClaim, additionalClaims) => { + if (!accountClaim) { + throw new Error("Account Claims was not defined when calling hull.asAccount()"); + } + return new Client({ ...config, accountClaim, additionalClaims }); }; } }; diff --git a/src/configuration.js b/src/configuration.js index 9495714..d90a6be 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -38,8 +38,9 @@ const VALID_PROPS = { prefix: VALID.string, domain: VALID.string, protocol: VALID.string, - userClaims: VALID.object, - userClaimsOptions: VALID.object, + userClaim: VALID.object, + accountClaim: VALID.object, + additionalClaims: VALID.object, accessToken: VALID.string, hostSecret: VALID.string, flushAt: VALID.number, @@ -53,8 +54,13 @@ class Configuration { throw new Error("Configuration is invalid, it should be a non-empty object"); } - if (config.userClaims) { - const accessToken = crypto.lookupToken(config, config.userClaims, config.userClaimsOptions); + if (config.userClaim) { + const accessToken = crypto.lookupToken(config, "user", config.userClaim, config.additionalClaims); + config = { ...config, accessToken }; + } + + if (config.accountClaim) { + const accessToken = crypto.lookupToken(config, "account", config.accountClaim, config.additionalClaims); config = { ...config, accessToken }; } diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 32a6c71..899b9e5 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -43,32 +43,46 @@ module.exports = { /** * Calculates the hash for a user lookup - io.hull.as * + * This is a wrapper over `buildToken` method. + * If the identClaim is a string or has id property, it's considered as an object id, + * and its value is set as a token subject. + * Otherwise it verifies if required ident properties are set + * and saves them as a custom ident claim. + * * @param {Object} config object - * @param {Object} user object or user ID as string - * @param {Object} additionnal claims + * @param {String} type - "user" or "account" + * @param {String|Object} identClaim main idenditiy claim - object or string + * @param {Object} additionalClaims * @returns {String} The jwt token to identity the user. */ - lookupToken(config, user = {}, claimsOptions = {}) { + lookupToken(config, type, identClaim, additionalClaims = {}) { + type = _.toLower(type); + if (!_.includes(["user", "account"], type)) { + throw new Error("Lookup token supports only `user` and `account` types"); + } + checkConfig(config); const claims = {}; - if (_.isString(user)) { - if (!user) { throw new Error("Missing user ID"); } - claims.sub = user; - } else if (user.id) { - claims.sub = user.id; + if (_.isString(identClaim)) { + if (!identClaim) { throw new Error(`Missing ${type} ID`); } + claims.sub = identClaim; + } else if (identClaim.id) { + claims.sub = identClaim.id; } else { - if (!_.isObject(user) || (!user.email && !user.external_id && !user.guest_id)) { - throw new Error("you need to pass a User hash with an `email` or `external_id` or `guest_id` field"); + if (type === "user" + && (!_.isObject(identClaim) || (!identClaim.email && !identClaim.external_id && !identClaim.anonymous_id))) { + throw new Error("You need to pass a user hash with an `email` or `external_id` or `anonymous_id` field"); } - claims["io.hull.as"] = user; + + claims[`io.hull.as${_.upperFirst(type)}`] = identClaim; } - if (_.has(claimsOptions, "create")) { - claims["io.hull.create"] = claimsOptions.create; + if (_.has(additionalClaims, "create")) { + claims["io.hull.create"] = additionalClaims.create; } - if (_.has(claimsOptions, "active")) { - claims["io.hull.active"] = claimsOptions.active; + if (_.has(additionalClaims, "active")) { + claims["io.hull.active"] = additionalClaims.active; } return buildToken(config, claims); diff --git a/tests/client-tests.js b/tests/client-tests.js index 36a56b4..f075a6b 100644 --- a/tests/client-tests.js +++ b/tests/client-tests.js @@ -28,21 +28,21 @@ describe("Hull", () => { it("should allow to pass create option", () => { const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); - const scoped = hull.as({ email: "foo@bar.com" }, { create: false }); + const scoped = hull.asUser({ email: "foo@bar.com" }, { create: false }); const scopedConfig = scoped.configuration(); const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); expect(scopedJwtClaims) .to.have.property("io.hull.create") .that.eql(false); expect(scopedJwtClaims) - .to.have.property("io.hull.as") + .to.have.property("io.hull.asUser") .that.eql({ email: "foo@bar.com" }); }); it("should allow to pass user id as a string", () => { const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); - const scoped = hull.as("123456"); + const scoped = hull.asUser("123456"); const scopedConfig = scoped.configuration(); const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); expect(scopedJwtClaims) @@ -50,15 +50,26 @@ describe("Hull", () => { .that.eql("123456"); }); - it("should allow to pass user id as an object property", () => { + it("should allow to pass account id as an object property", () => { const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); - const scoped = hull.as({ id: "123456" }); + const scoped = hull.asAccount({ id: "123456" }); const scopedConfig = scoped.configuration(); const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); expect(scopedJwtClaims) .to.have.property("sub") .that.eql("123456"); }); + + it("should allow to pass account name as an object property", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + + const scoped = hull.asAccount({ name: "Hull" }); + const scopedConfig = scoped.configuration(); + const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); + expect(scopedJwtClaims) + .to.have.property("io.hull.asAccount") + .that.eql({ name: "Hull" }); + }); }); });