From 33822a665b036941a1adc5d3257860850b5187d1 Mon Sep 17 00:00:00 2001 From: Alex Robson Date: Thu, 23 Jun 2016 14:20:03 -0500 Subject: [PATCH] Support changes to URL and authorize across versions --- CHANGELOG.md | 7 ++ README.md | 2 + package.json | 2 +- spec/ah/board/resource.js | 1 + spec/ah/secure/resource.js | 29 +++++++ spec/behavior/actions.spec.js | 19 ++++ spec/behavior/hyperResource.spec.js | 33 ++++++- spec/behavior/resources.js | 2 + spec/behavior/versions.spec.js | 1 + spec/integration/autohost.spec.js | 105 ++++++++++++++++++++++- spec/integration/halOptionsNoPrefix.json | 2 +- src/index.js | 42 ++++++--- src/links.js | 11 ++- src/versions.js | 35 +++++++- 14 files changed, 272 insertions(+), 19 deletions(-) create mode 100644 spec/ah/secure/resource.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 614bfd5..38a9bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.7.x + +### 0.7.0 + * Support use of `latest` as a version specifier + * Support URL changes across versions (includes OPTIONS) + * Support authorize changes across versions + ## 0.6.x ### 0.6.3 diff --git a/README.md b/README.md index 29699a9..efb4e62 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,8 @@ The default versioning strategies parses the version specifier out of the `Accep * `application/json; version=2` * `application/hal+json; version=3` +> Note: hyped also supports the use of the keyword `latest` in place of a numeric indicator. + #### Versioning handlers Handlers behave a bit differently. When providing a new handle implementation for a version, that handle implementation will apply for the current version _and_ any consecutive versions after that don't provide their own handle implementation. diff --git a/package.json b/package.json index 03fdcc0..e45675a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyped", - "version": "0.6.3", + "version": "0.7.0", "description": "Hypermedia response generation engine", "main": "src/index.js", "dependencies": { diff --git a/spec/ah/board/resource.js b/spec/ah/board/resource.js index 7f52bda..7ce5339 100644 --- a/spec/ah/board/resource.js +++ b/spec/ah/board/resource.js @@ -73,6 +73,7 @@ module.exports = function( host ) { }, 10: { self: { + url: "/train/:id", handle: function( envelope ) { return { status: 200, diff --git a/spec/ah/secure/resource.js b/spec/ah/secure/resource.js new file mode 100644 index 0000000..d0900e4 --- /dev/null +++ b/spec/ah/secure/resource.js @@ -0,0 +1,29 @@ +module.exports = function() { + return { + name: "secure", + actions: { + self: { + method: "get", + url: "/", + authorize: function( envelope ) { + return envelope.data.level ? envelope.data.level > 1 : false; + }, + handle: function( envelope ) { + return { data: { result: "level 1" } }; + } + } + }, + versions: { + 2: { + self: { + authorize: function( envelope ) { + return envelope.data.level ? envelope.data.level > 2 : false; + }, + handle: function( envelope ) { + return { data: { result: "level 2" } }; + } + } + } + } + }; +}; diff --git a/spec/behavior/actions.spec.js b/spec/behavior/actions.spec.js index 5850f96..19db396 100644 --- a/spec/behavior/actions.spec.js +++ b/spec/behavior/actions.spec.js @@ -272,4 +272,23 @@ describe( "Action links", function() { } ); } ); } ); + + describe( "with action rendering with non-default version", function() { + describe( "when rendering an \"plain\" action", function() { + var expected = { self: { href: "/parent/2/1", method: "GET" } }; + var createLinks; + var model; + before( function() { + model = { + id: 1 + }; + createLinks = links.getLinkGenerator( resources, "", 2 ); + } ); + + it( "should produce expected links", function() { + return createLinks( "parent", "self", {}, model ) + .should.eventually.eql( expected ); + } ); + } ); + } ); } ); diff --git a/spec/behavior/hyperResource.spec.js b/spec/behavior/hyperResource.spec.js index 6aa741d..d455e9f 100644 --- a/spec/behavior/hyperResource.spec.js +++ b/spec/behavior/hyperResource.spec.js @@ -14,12 +14,12 @@ describe( "Hyper Resource", function() { var expected = { id: 1, title: "test", - _origin: { href: "/parent/1", method: "GET" }, + _origin: { href: "/parent/2/1", method: "GET" }, _resource: "parent", _action: "self", _version: 2, _links: { - self: { href: "/parent/1", method: "GET" }, + self: { href: "/parent/2/1", method: "GET" }, children: { href: "/parent/1/child", method: "GET", parameters: parameters }, "next-child-page": { href: "/parent/1/child?page=2&size=5", method: "GET", parameters: parameters }, "create-child": { href: "/parent/1/child", method: "POST" } @@ -240,6 +240,35 @@ describe( "Hyper Resource", function() { } ); } ); + describe( "when rendering options (version 10) excluding children as generic user", function() { + var expected = { + _mediaTypes: [], + _versions: [ "1", "2", "10" ], + _links: { + "parent:self": { href: "/parent/10/{id}", method: "GET", templated: true }, + "parent:list": { href: "/parent", method: "GET" }, + "parent:children": { href: "/parent/{id}/child", method: "GET", templated: true, parameters: { + size: { range: [ 1, 100 ] } + } } + } + }; + var options; + var envelope = { user: {} }; + + before( function() { + var fn = HyperResource.optionsGenerator( resources, "", 10, true, envelope ); + options = fn(); + } ); + + it( "should render options correctly", function() { + return options.should.eventually.eql( expected ); + } ); + + it( "should not have called authorize on hidden action", function() { + return should.not.exist( envelope.hiddenWasAuthorized ); + } ); + } ); + describe( "when rendering options excluding children as admin", function() { var expected = { _mediaTypes: [], diff --git a/spec/behavior/resources.js b/spec/behavior/resources.js index 24f0033..d6a3598 100644 --- a/spec/behavior/resources.js +++ b/spec/behavior/resources.js @@ -80,6 +80,7 @@ var resources = { versions: { 2: { self: { + url: "/parent/2/:id", include: [ "id", "title" ], handle: function() { return "two"; @@ -88,6 +89,7 @@ var resources = { }, 10: { self: { + url: "/parent/10/:id", include: [ "id" ], handle: function() { return "three"; diff --git a/spec/behavior/versions.spec.js b/spec/behavior/versions.spec.js index 0dbf72a..db6338d 100644 --- a/spec/behavior/versions.spec.js +++ b/spec/behavior/versions.spec.js @@ -16,6 +16,7 @@ describe( "Versioning", function() { it( "should merge distinct versions correctly", function() { should.not.exist( parentVersion1.actions.self.include ); + parentVersion2.actions.self.url.should.eql( "/parent/2/:id" ); parentVersion2.actions.self.include.should.eql( [ "id", "title" ] ); parentVersion3.actions.self.include.should.eql( [ "id" ] ); } ); diff --git a/spec/integration/autohost.spec.js b/spec/integration/autohost.spec.js index 5e9a384..3ee1197 100644 --- a/spec/integration/autohost.spec.js +++ b/spec/integration/autohost.spec.js @@ -387,8 +387,9 @@ describe( "Autohost Integration", function() { var body, contentType; before( function( done ) { - request( "http://localhost:8800/api/board/100", function( err, res ) { + request( "http://localhost:8800/api/board/train/100", function( err, res ) { body = JSON.parse( res.body ); + contentType = res.headers[ "content-type" ].split( ";" )[ 0 ]; done(); } ); @@ -464,7 +465,107 @@ describe( "Autohost Integration", function() { } ); } ); - describe( "with undefined api prefix", function() { + describe( "with versioned authorization calls", function() { + var hyped, host; + before( function( done ) { + hyped = require( "../../src/index.js" )( { + defaultContentType: "application/hal+json" + } ); + host = hyped.createHost( autohost, { + urlPrefix: "/test", + resources: "./spec/ah" + }, function() { + host.start(); + done(); + } ); + } ); + + describe( "when requesting with version 1 and adequate authorization", function() { + var body, contentType; + + before( function( done ) { + request( "http://localhost:8800/test/api/secure?level=2", + { headers: { accept: "*/*" } }, + function( err, res ) { + body = JSON.parse( res.body ); + contentType = res.headers[ "content-type" ].split( ";" )[ 0 ]; + done(); + } ); + } + ); + + it( "should get HAL version 1 (default content type and version)", function() { + contentType.should.equal( "application/hal+json" ); + body.result.should.eql( "level 1" ); + } ); + } ); + + describe( "when requesting with version 1 and inadequate authorization", function() { + var body, contentType; + + before( function( done ) { + request( "http://localhost:8800/test/api/secure?level=1", + { headers: { accept: "*/*" } }, + function( err, res ) { + body = JSON.parse( res.body ); + contentType = res.headers[ "content-type" ].split( ";" )[ 0 ]; + done(); + } ); + } + ); + + it( "should get HAL version 1 (default content type and version)", function() { + contentType.should.equal( "application/hal+json" ); + body.message.should.eql( "User lacks sufficient permissions" ); + } ); + } ); + + describe( "when requesting with version 2 and adequate authorization", function() { + var body, contentType; + + before( function( done ) { + request( "http://localhost:8800/test/api/secure?level=3", + { headers: { accept: "application/hal.v2+json" } }, + function( err, res ) { + body = JSON.parse( res.body ); + contentType = res.headers[ "content-type" ].split( ";" )[ 0 ]; + done(); + } ); + } + ); + + it( "should get HAL version 2 (default content type and version)", function() { + contentType.should.equal( "application/hal.v2+json" ); + body.result.should.eql( "level 2" ); + } ); + } ); + + describe( "when requesting with version 2 and inadequate authorization", function() { + var body, contentType; + + before( function( done ) { + request( "http://localhost:8800/test/api/secure?level=2", + { headers: { accept: "application/hal.v2+json" } }, + function( err, res ) { + body = JSON.parse( res.body ); + contentType = res.headers[ "content-type" ].split( ";" )[ 0 ]; + done(); + } ); + } + ); + + it( "should get HAL version 2 (default content type and version)", function() { + contentType.should.equal( "application/hal.v2+json" ); + body.message.should.eql( "User lacks sufficient permissions" ); + } ); + } ); + + after( function() { + host.stop(); + } ); + } ); + + describe( "with undefined api prefix and latest version as default", function() { var hyped, host; before( function( done ) { hyped = require( "../../src/index.js" )( true, true ); diff --git a/spec/integration/halOptionsNoPrefix.json b/spec/integration/halOptionsNoPrefix.json index 7a7a770..4543603 100644 --- a/spec/integration/halOptionsNoPrefix.json +++ b/spec/integration/halOptionsNoPrefix.json @@ -2,7 +2,7 @@ "_links": { "board:cards": { "href": "/board/{id}/card", "method": "GET", "templated": true }, - "board:self": { "href": "/board/{id}", "method": "GET", "templated": true }, + "board:self": { "href": "/board/train/{id}", "method": "GET", "templated": true }, "card:self": { "href": "/card/{id}", "method": "GET", "templated": true }, "card:move": { "href": "/card/{id}/board/{targetBoardId}/lane/{targetLaneId}", "method": "PUT", "templated": true }, "card:block": { "href": "/card/{id}/block", "method": "PUT", "templated": true }, diff --git a/src/index.js b/src/index.js index f2b7196..79c4c18 100644 --- a/src/index.js +++ b/src/index.js @@ -58,6 +58,7 @@ function addResourceMiddleware( state, host ) { function addResource( state, resource, resourceName ) { // jshint ignore:line versions.processHandles( resource ); + versions.processAuthorize( resource ); state.resources[ resourceName ] = resource; } @@ -108,7 +109,7 @@ function getContentType( req ) { // jshint ignore:line } function getEngine( state, mediaType ) { // jshint ignore:line - var filtered = mediaType.replace( /[.]v[0-9]*/, "" ).split( ";" )[ 0 ]; + var filtered = mediaType.replace( /[.]v([0-9]+|latest)/, "" ).split( ";" )[ 0 ]; return state.engines[ filtered ]; } @@ -136,23 +137,25 @@ function getOptionModel( state, envelope ) { } function getFullOptionModel( state ) { - var version = 1; - if ( !state.fullOptionModels[ version ] ) { - state.fullOptionModels[ version ] = HyperResource.routesGenerator( state.resources, state.prefix, version )( state.engines ); + var maxVersion = getMaxVersion( state ); + for ( var i = 1; i <= maxVersion; i++ ) { + if ( !state.fullOptionModels[ i ] ) { + state.fullOptionModels[ i ] = HyperResource.routesGenerator( state.resources, state.prefix, i )( state.engines ); + } } - return state.fullOptionModels[ version ]; + return state.fullOptionModels; } function getMaxVersion( state ) { // jshint ignore:line return _.reduce( state.resources, function( version, resource ) { - var max = _.maxBy( _.keys( resource.versions ), parseInt ); + var max = parseInt( _.maxBy( _.keys( resource.versions ), parseInt ) ) || 0; return max > version ? max : version; }, 1 ); } function getMimeVersion( header ) { var version; - var match = /[.]v([0-9]*)/.exec( header ); + var match = /[.]v([0-9]+|latest)/.exec( header ); if ( match && match.length > 0 ) { version = match[ 1 ]; } @@ -161,7 +164,7 @@ function getMimeVersion( header ) { function getParameterVersion( header ) { var version; - var match = /version\s*[=]\s*([0-9]+)[;]?/g.exec( header ); + var match = /version\s*[=]\s*([0-9]+|latest)[;]?/g.exec( header ); if ( match && match.length > 0 ) { version = match[ 1 ]; } @@ -178,6 +181,8 @@ function getVersion( state, envelope ) { var accept = envelope.headers.accept || ""; var mimeVersion = getMimeVersion( accept ); var parameterVersion = getParameterVersion( accept ); + parameterVersion = parameterVersion === "latest" ? state.maxVersion : parameterVersion; + mimeVersion = mimeVersion === "latest" ? state.maxVersion : mimeVersion; var version = state.preferLatest ? state.maxVersion : 1; var final = parseInt( mimeVersion || parameterVersion || version ); envelope.version = final; @@ -230,10 +235,25 @@ function urlStrategy( state, resourceName, actionName, action, resourceList ) { if ( _.isEmpty( state.resources ) ) { state.resources = resourceList; } - // will need to do this for all available versions at some point ... + var options = getFullOptionModel( state ); - var link = options._links[ [ resourceName, actionName ].join( ":" ) ]; - return link ? url.forExpress( link.href ) : ""; + var versions = options[ 1 ]._versions; + var key = [ resourceName, actionName ].join( ":" ); + var links = _.uniq( _.reduce( versions, function( acc, version ) { + var versionSet = options[ version ]; + if ( versionSet ) { + var link = url.forExpress( versionSet._links[ key ].href ); + acc.push( link || "" ); + } + return acc; + }, [] ) ); + if ( links.length === 0 ) { + return ""; + } else if ( links.length === 1 ) { + return links[ 0 ]; + } else { + return links; + } } module.exports = function( resourceList, defaultToNewest, includeChildrenInOptions ) { diff --git a/src/links.js b/src/links.js index 98875d0..d4f38b8 100644 --- a/src/links.js +++ b/src/links.js @@ -376,7 +376,16 @@ function getRenderPredicate( action, actionName, resourceName, forOptions ) { // }; } if ( action.authorize ) { - allowRender = action.authorize; + allowRender = function( envelope, data ) { + if ( _.isArray( action.authorize ) ) { + var fn = _.find( action.authorize, function( auth ) { + return auth.when( envelope ); + } ); + return fn.then( envelope, data ); + } else { + return action.authorize( envelope, data ); + } + }; } var authName = [ resourceName, actionName ].join( ":" ); return function( envelope, data, auth ) { diff --git a/src/versions.js b/src/versions.js index 05ed4aa..58515e3 100644 --- a/src/versions.js +++ b/src/versions.js @@ -54,7 +54,7 @@ function deepMerge( target, source ) { // jshint ignore:line target[ key ] = _.clone( val ); } } else { - target[ key ] = ( original === undefined ) ? _.clone( val ) : original; + target[ key ] = ( val ) ? _.clone( val ) : original; } } ); } @@ -117,8 +117,41 @@ function processHandles( resource ) { } } +function processAuthorize( resource ) { + if ( resource.versions ) { + _.each( resource.versions, function( change, version ) { + version = parseInt( version ); + _.each( change, function( action, name ) { + if ( action.authorize ) { + var targetAction = resource.actions[ name ]; + if ( !targetAction ) { + resource.actions[ name ].authorize = []; + } else if ( !_.isArray( targetAction.authorize ) ) { + var original = targetAction.authorize; + resource.actions[ name ].authorize = [ + { + when: function() { + return true; + }, + then: original + } + ]; + } + resource.actions[ name ].authorize.unshift( { + when: function( envelope ) { + return envelope.version >= version; + }, + then: action.authorize + } ); + } + } ); + } ); + } +} + module.exports = { getVersions: getVersions, getVersion: getVersion, + processAuthorize: processAuthorize, processHandles: processHandles };