Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

initial commit

  • Loading branch information...
commit affb8dce10c7c1bc0cb0fe8adc54b2ba76e30fcd 0 parents
@basti1302 authored
5 .gitignore
@@ -0,0 +1,5 @@
+node_modules
+
+# vim tmp files
+*.swp
+*.un~
31 .jshintrc
@@ -0,0 +1,31 @@
+{
+ "asi": true,
+ "bitwise": true,
+ "camelcase": true,
+ "eqeqeq": true,
+ "eqnull": true,
+ "forin": true,
+ "immed": true,
+ "indent": 2,
+ "latedef": true,
+ "maxcomplexity": 7,
+ "maxdepth": 4,
+ "maxlen": 80,
+ "maxparams": 5,
+ "maxstatements": 300,
+ "newcap": true,
+ "noarg": true,
+ "node": true,
+ "noempty": true,
+ "nonew": true,
+ "quotmark": true,
+ "strict": true,
+ "trailing": true,
+ "undef": true,
+ "globals": {
+ "describe": true,
+ "it": true,
+ "beforeEach": true,
+ "afterEach": true
+ }
+}
7 .travis.yml
@@ -0,0 +1,7 @@
+---
+language: node_js
+node_js:
+ - '0.10'
+
+before_script:
+ - npm install -g grunt-cli
35 Gruntfile.js
@@ -0,0 +1,35 @@
+'use strict';
+
+/* jshint -W106 */
+module.exports = function(grunt) {
+
+ grunt.initConfig({
+ pkg: grunt.file.readJSON('package.json'),
+
+ jshint: {
+ files: ['**/*.js', '.jshintrc', '!node_modules/**/*'],
+ options: {
+ jshintrc: '.jshintrc'
+ }
+ },
+ mochaTest: {
+ test: {
+ options: {
+ reporter: 'spec'
+ },
+ src: ['test/**/*.js']
+ }
+ },
+ watch: {
+ files: ['<%= jshint.files %>'],
+ tasks: ['default']
+ },
+ })
+
+ grunt.loadNpmTasks('grunt-contrib-jshint')
+ grunt.loadNpmTasks('grunt-mocha-test')
+ grunt.loadNpmTasks('grunt-contrib-watch')
+
+ grunt.registerTask('default', ['jshint', 'mochaTest'])
+}
+/* jshint +W106 */
7 README.md
@@ -0,0 +1,7 @@
+JSON Hypermedia API Consumer
+============================
+[![Build Status](https://travis-ci.org/basti1302/json-hypermedia-api-consumer)](https://travis-ci.org/basti1302/json-hypermedia-api-consumer)
+
+Santa's little helper for consuming JSON hypermedia APIs with ease.
+
+TODO: Explain what this module does :-)
115 lib/link_walker.js
@@ -0,0 +1,115 @@
+'use strict';
+
+/* jshint -W061 */
+// wtf jshint? eval can be harmful? But that is not eval, it's JSONPath#eval
+var jsonpath = require('JSONPath').eval;
+/* jshint +W061 */
+var uriTemplate = require('uri-template')
+
+function Walker() {
+
+ var self = this
+
+ /*
+ * Fetches the document from the startUri and then
+ * 1) Uses the next element from the path array as the property key.
+ * If the next element starts with $. or $[ it is assumed that it is a
+ * JSONPath expression, otherwise it is assumed to be a simple property
+ * key.
+ * 2) Looks for the property key in the fetched document or evaluates the
+ * JSONPath expression. In the latter case, there must be a non-ambigious
+ * match, otherwise an error is passed to the callback.
+ * 3) If the result of step 2 is an URI template, it is evaluated with the
+ * given templateParams.
+ * 4) Passes the resulting URI to fetch callback to acquire the next document.
+ * 5) Goes back to step 1) with the next element from path, if any. If path
+ * array is exhausted, the last resulting document is passed to the
+ * callback.
+ *
+ * The last resulting document (when the path array has been consumed
+ * completely by the above procedure) is passed to the result callback.
+ *
+ * Parameters:
+ * startUri is the first URI to fetch
+ * path is an array of property keys/JSONPath expressions.
+ * templateParams is an array of objects, containaing the template parameters
+ * for each element in the path array. Can be null. Also, individual
+ * elements can be null.
+ * callback will be called if this method is done, parameters (error, result),
+ * of which only one will be non-null.
+ */
+ this.walk = function(startUri, path, templateParams, callback) {
+ templateParams = templateParams || []
+ var uri = startUri
+ var index = 0;
+ (function walk() {
+ self.fetch(uri, function(err, doc) {
+ if (err) { return callback(err) }
+ if (index < path.length) {
+ var link = path[index++]
+ if (self.testJSONPath(link)) {
+ uri = self.resolveJSONPath(link, doc, callback)
+ if (!uri) {
+ // JSONPath resolving failed, callback already called with error.
+ return false
+ }
+ } else {
+ uri = doc[link]
+ }
+ if (!uri) {
+ return callback(new Error('Could not find property ' + link +
+ ' in document:\n' + doc))
+ }
+ uri = self.resolveUriTemplate(uri, templateParams[index - 1])
+ walk()
+ } else {
+ callback(null, doc)
+ }
+ })
+ })()
+ }
+
+ this.fetch = function(uri, callback) {
+ throw new Error('not implemented')
+ }
+
+ this.testJSONPath = function(link) {
+ return link.indexOf('$.') === 0 || link.indexOf('$[') === 0
+ }
+
+ this.resolveJSONPath = function(link, doc, callback) {
+ var matches = jsonpath(doc, link)
+ if (matches.length === 1) {
+ var uri = matches[0]
+ if (!uri) {
+ callback(new Error('JSONPath expression ' + link +
+ ' was resolved but the result was null, undefined or an empty' +
+ ' string in document:\n' + JSON.stringify(doc)))
+ return false
+ }
+ return uri
+ } else if (matches.length > 1) {
+ // ambigious match
+ callback(new Error('JSONPath expression ' + link +
+ ' returned more than one match in document:\n' +
+ JSON.stringify(doc)))
+ return false
+ } else {
+ // no match at all
+ callback(new Error('JSONPath expression ' + link +
+ ' returned no match in document:\n' + JSON.stringify(doc)))
+ return false
+ }
+ }
+
+ this.resolveUriTemplate = function(uri, templateParam) {
+ if (templateParam) {
+ var template = uriTemplate.parse(uri)
+ return template.expand(templateParam)
+ } else {
+ return uri
+ }
+ }
+}
+
+module.exports = Walker
34 package.json
@@ -0,0 +1,34 @@
+{
+ "name": "json-hypermedia-api-consumer",
+ "version": "0.0.1",
+ "author": "Bastian Krol",
+ "description": "consume JSON based hypermedia APIs, follow links in JSON responses automatically",
+ "repository": "https://github.com/basti1302/json-hypermedia-api-consumer.git",
+ "contributors": [],
+ "scripts": {
+ "test": "grunt"
+ },
+ "keywords": [
+ "JSON",
+ "hypermedia",
+ "REST",
+ "API"
+ ],
+ "install globally": {
+ "grunt-cli": "latest"
+ },
+ "dependencies": {
+ "JSONPath": "~0.9.1",
+ "uri-template": "~0.4.1"
+ },
+ "devDependencies": {
+ "chai": "~1.7.2",
+ "grunt": "~0.4.1",
+ "grunt-contrib-jshint": "~0.6.4",
+ "grunt-contrib-watch": "~0.5.3",
+ "grunt-mocha-test": "~0.7.0",
+ "mocha": "~1.12.1",
+ "request": "~2.27.0",
+ "sinon-chai": "~2.4.0"
+ }
+}
257 test/follow_path.js
@@ -0,0 +1,257 @@
+'use strict';
+
+var chai = require('chai')
+chai.should()
+var assert = chai.assert
+var expect = chai.expect
+var sinon = require('sinon')
+var sinonChai = require('sinon-chai')
+chai.use(sinonChai)
+
+var linkWalker = new (require('../lib/link_walker'))()
+
+describe('The link walker', function() {
+
+ /*
+ * TEST/FEATURE TODOs
+ * - cache final links for path
+ * - linkWalker.disableJSONPath()
+ * - linkWalker.disableUriTemplates()
+ * Dissect into several *public* sub-functions that can be overridden
+ * or disabled (fetching, URI template resolving, JSONPath resolving,
+ * caching, ...)
+ * - [alternative formats to JSON? html? xml? hal? ... probably better
+ * separate libs]
+ */
+
+ var fetch
+ var callback
+ var rootUri = 'http://api.io'
+
+ beforeEach(function() {
+ linkWalker.fetch = fetch = sinon.stub()
+ callback = sinon.spy()
+ })
+
+ it('should access root URI', function() {
+ linkWalker.walk(rootUri, [], null, callback)
+ fetch.should.have.been.calledWith(rootUri, sinon.match.func)
+ })
+
+ it('should call callback with root doc', function(done) {
+ var rootDoc = {root: 'doc'}
+ fetch.callsArgWithAsync(1, null, rootDoc)
+ linkWalker.walk(rootUri, [], null, callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(null, rootDoc)
+ done()
+ }
+ )
+ })
+
+ it('should call callback with err', function(done) {
+ var err = new Error('test error')
+ fetch.callsArgWithAsync(1, err)
+ linkWalker.walk(rootUri, [], null, callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(err)
+ done()
+ }
+ )
+ })
+
+ it('should walk a single element path', function(done) {
+ var rootDoc = {
+ irrelevant: { stuff: 'to be ignored' },
+ link: rootUri + '/link/to/thing',
+ more: { stuff: { that: 'we do not care about' } }
+ }
+ var resultDoc = { foo: 'bar' }
+ fetch.withArgs(rootUri, sinon.match.func).callsArgWithAsync(
+ 1, null, rootDoc)
+ fetch.withArgs(rootUri + '/link/to/thing',
+ sinon.match.func).callsArgWithAsync(1, null, resultDoc)
+ linkWalker.walk(rootUri, ['link'], null, callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(null, resultDoc)
+ done()
+ }
+ )
+ })
+
+ it('should call callback with err if link is not found', function(done) {
+ var rootDoc = { nothing: 'in here'}
+ fetch.withArgs(rootUri, sinon.match.func).callsArgWithAsync(
+ 1, null, rootDoc)
+ linkWalker.walk(rootUri, ['non-existing-link'], null, callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ assert(callback.calledOnce)
+ callback.should.have.been.calledWith(sinon.match.instanceOf(Error))
+ callback.args[0][0].message.should.contain('Could not find property ' +
+ 'non-existing-link')
+ done()
+ }
+ )
+ })
+
+ it('should call callback with err inside recursion', function(done) {
+ var err = new Error('test error')
+ fetch.withArgs(rootUri, sinon.match.func).callsArgWithAsync(
+ 1, null, { firstLink: rootUri + '/first' })
+ fetch.withArgs(rootUri + '/first', sinon.match.func).callsArgWithAsync(
+ 1, err)
+ linkWalker.walk(rootUri, ['firstLink'], null, callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(err)
+ done()
+ }
+ )
+ })
+
+ it('should walk to a link via JSONPath expression', function(done) {
+ var uri = rootUri + '/path/to/resource'
+ var rootDoc = {
+ deeply: { nested: { link: uri } }
+ }
+ var resultDoc = { the: 'result' }
+ fetch.withArgs(rootUri, sinon.match.func).callsArgWithAsync(
+ 1, null, rootDoc)
+ fetch.withArgs(uri, sinon.match.func).callsArgWithAsync(1, null, resultDoc)
+ linkWalker.walk(rootUri, ['$.deeply.nested.link'], null, callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(null, resultDoc)
+ done()
+ }
+ )
+ })
+
+ it('should call callback with err if JSONPath has no match', function(done) {
+ var uri = rootUri + '/path/to/resource'
+ var rootDoc = {
+ deeply: { nested: { link: uri } }
+ }
+ fetch.withArgs(rootUri, sinon.match.func).callsArgWithAsync(
+ 1, null, rootDoc)
+ linkWalker.walk(rootUri, ['$.deeply.nested.blink'], null, callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(sinon.match.instanceOf(Error))
+ callback.args[0][0].message.should.contain('JSONPath expression ' +
+ '$.deeply.nested.blink returned no match')
+ done()
+ }
+ )
+ })
+
+ it('should call callback with err if JSONPath has multiple matches',
+ function(done) {
+ var uri = rootUri + '/path/to/resource'
+ var rootDoc = {
+ arr: [ { foo: 'bar' }, { foo: 'baz' } ]
+ }
+ fetch.withArgs(rootUri, sinon.match.func).callsArgWithAsync(
+ 1, null, rootDoc)
+ linkWalker.walk(rootUri, ['$.arr[*].foo'], null, callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(sinon.match.instanceOf(Error))
+ callback.args[0][0].message.should.contain('JSONPath expression ' +
+ '$.arr[*].foo returned more than one match')
+ done()
+ }
+ )
+ })
+
+ it('should evaluate URI templates', function(done) {
+ var rootDoc = {
+ template: rootUri + '/users/{user}/things{/thing}'
+ }
+ var resultDoc = { we: 'can haz use uri templates!' }
+ fetch.withArgs(rootUri, sinon.match.func).callsArgWithAsync(
+ 1, null, rootDoc)
+ fetch.withArgs(rootUri + '/users/basti1302/things/4711',
+ sinon.match.func).callsArgWithAsync(1, null, resultDoc)
+ linkWalker.walk(rootUri,
+ ['template'],
+ [{user: 'basti1302', thing: 4711}],
+ callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(null, resultDoc)
+ done()
+ }
+ )
+ })
+
+ it('should walk a multi element path', function(done) {
+ var path1 = rootUri + '/path'
+ var template2 = rootUri + '/path/to/resource{/param}'
+ var path2 = rootUri + '/path/to/resource/gizmo'
+ var path3 = rootUri + '/path/to/another/resource'
+ var path4 = rootUri + '/path/to/the/last/resource'
+
+ var rootDoc = { link1: path1 }
+ var doc2 = { link2: path2 }
+ var doc3 = {
+ nested: {
+ array: [
+ { foo: 'bar' },
+ { link: path3 },
+ { bar: 'baz' }
+ ]
+ }
+ }
+ var doc4 = { link4: path4 }
+ var resultDoc = { gizmo: 'hell, yeah!' }
+
+ fetch.withArgs(rootUri, sinon.match.func).callsArgWithAsync(
+ 1, null, rootDoc)
+ fetch.withArgs(path1, sinon.match.func).callsArgWithAsync(
+ 1, null, doc2)
+ fetch.withArgs(path2, sinon.match.func).callsArgWithAsync(
+ 1, null, doc3)
+ fetch.withArgs(path3, sinon.match.func).callsArgWithAsync(
+ 1, null, doc4)
+ fetch.withArgs(path4, sinon.match.func).callsArgWithAsync(
+ 1, null, resultDoc)
+ linkWalker.walk(rootUri,
+ ['link1', 'link2', '$[nested][array][1].link', 'link4'],
+ [null, { param: 'gizmo' }, null, null],
+ callback)
+ waitFor(
+ function() { return callback.called },
+ function() {
+ callback.should.have.been.calledWith(null, resultDoc)
+ done()
+ }
+ )
+ })
+
+ // test helper
+ function waitFor(test, onSuccess, polling) {
+ if (polling === null || polling === undefined) {
+ polling = 10
+ }
+ var handle = setInterval(function() {
+ if (test()) {
+ clearInterval(handle)
+ onSuccess()
+ }
+ }, polling)
+ }
+})
Please sign in to comment.
Something went wrong with that request. Please try again.