1 new commit in galaxy-central: https://bitbucket.org/galaxy/galaxy-central/commits/ae31fdf708ef/ Changeset: ae31fdf708ef User: carlfeberhard Date: 2013-05-17 21:06:51 Summary: Visualizations API: rough draft of index, show, create, and update; Browser tests: tests for visualizations API Affected #: 3 files diff -r fcf327d7dd51c19b2478fe2ab4361b67fe8d3897 -r ae31fdf708efc84f1db7176b170596c9eb7f0212 lib/galaxy/webapps/galaxy/api/visualizations.py --- a/lib/galaxy/webapps/galaxy/api/visualizations.py +++ b/lib/galaxy/webapps/galaxy/api/visualizations.py @@ -1,28 +1,263 @@ -from galaxy import web -from galaxy.web.base.controller import BaseController, BaseAPIController +""" +Visualizations resource control over the API. -class VisualizationsController( BaseAPIController ): +NOTE!: this is a work in progress and functionality and data structures +may change often. +""" + +import pkg_resources +pkg_resources.require( 'SQLAlchemy >= 0.4' ) +from sqlalchemy import or_ + +from galaxy import web, util +from galaxy.web.base.controller import BaseAPIController, UsesVisualizationMixin +from galaxy.model.item_attrs import UsesAnnotations +from galaxy.exceptions import ( ItemAccessibilityException, ItemDeletionException, ItemOwnershipException, + MessageException ) + +from galaxy.web import url_for + +import logging +log = logging.getLogger( __name__ ) + +class VisualizationsController( BaseAPIController, UsesVisualizationMixin, UsesAnnotations ): """ RESTful controller for interactions with visualizations. """ @web.expose_api - def index( self, trans, **kwds ): + def index( self, trans, **kwargs ): """ GET /api/visualizations: """ - pass + #TODO: search for vizsesses that apply to an object (sending model class and id? - how to do this?) + rval = [] + try: + if not trans.user: + raise ItemAccessibilityException( 'You must be logged in to access visualizations' ) + user = trans.user + + #TODO: search for: title, made by user, creation time range, type (vis name), dbkey, etc. + #TODO: limit, offset, order_by + #TODO: deleted + + # this is the default search - user's vis, vis shared with user, published vis + visualizations = self.get_visualizations_by_user( trans, user ) + visualizations += self.get_visualizations_shared_with_user( trans, user ) + visualizations += self.get_published_visualizations( trans, exclude_user=user ) + #TODO: the admin case - everything + + for visualization in visualizations: + item = self.get_visualization_summary_dict( visualization ) + item = trans.security.encode_dict_ids( item ) + item[ 'url' ] = web.url_for( 'visualization', id=item[ 'id' ] ) + rval.append( item ) + + except ItemAccessibilityException, exception: + trans.response.status = 403 + rval = { 'error': str( exception ) } + if trans.debug: + log.exception( str( exception ) ) + + except Exception, exception: + trans.response.status = 500 + rval = { 'error': str( exception ) } + log.exception( 'visualizations index failed: %s' %( str( exception ) ) ) + + return rval @web.json - def show( self, trans, id, **kwd ): + def show( self, trans, id, **kwargs ): """ GET /api/visualizations/{viz_id} """ - pass + #TODO: revisions should be a contents/nested controller like viz/xxx/r/xxx)? + # the important thing is the config + rval = {} + #TODO:?? /api/visualizations/registry -> json of registry.listings? + try: + visualization = self.get_visualization( trans, id, check_ownership=False, check_accessible=True ) + dictionary = trans.security.encode_dict_ids( self.get_visualization_dict( visualization ) ) + dictionary[ 'url' ] = url_for( controller='visualization', + action="display_by_username_and_slug", username=visualization.user.username, slug=visualization.slug ) + dictionary[ 'annotation' ] = self.get_item_annotation_str( trans.sa_session, trans.user, visualization ) + + # need to encode ids in revisions as well + encoded_revisions = [] + for revision in dictionary[ 'revisions' ]: + #NOTE: does not encode ids inside the configs + encoded_revisions.append( trans.security.encode_id( revision ) ) + dictionary[ 'revisions' ] = encoded_revisions + dictionary[ 'latest_revision' ] = trans.security.encode_dict_ids( dictionary[ 'latest_revision' ] ) + + rval = dictionary + + except ( ItemAccessibilityException, ItemDeletionException ), exception: + trans.response.status = 403 + rval = { 'error': str( exception ) } + if trans.debug: + log.exception( 'visualization show forbidden (%s): %s' %( id, str( exception ) ) ) + + except Exception, exception: + trans.response.status = 500 + rval = { 'error': str( exception ) } + log.exception( 'visualization show failed (%s): %s' %( id, str( exception ) ) ) + + return rval @web.expose_api - def create( self, trans, payload, **kwd ): + def create( self, trans, payload, **kwargs ): """ POST /api/visualizations + creates a new visualization using the given payload + + POST /api/visualizations?import_id={encoded_visualization_id} + imports a copy of an existing visualization into the user's workspace """ - pass \ No newline at end of file + rval = None + try: + if 'import_id' in payload: + import_id = payload( 'import_id' ) + visualization = self.import_visualization( trans, import_id, user=trans.user ) + + else: + payload = self._validate_and_parse_payload( payload ) + payload[ 'save' ] = True + # create needs defaults like wizard needs food - generate defaults - this will err if given a weird key? + visualization = self.create_visualization( trans, **payload ) + + rval = { 'id' : trans.security.encode_id( visualization.id ) } + + #TODO: exception boilerplate + except ( ItemAccessibilityException, ItemDeletionException ), exception: + trans.response.status = 403 + rval = { 'error': str( exception ) } + if trans.debug: + log.exception( str( exception ) ) + + except ( ValueError, AttributeError ), exception: + trans.response.status = 400 + rval = { 'error': str( exception ) } + if trans.debug: + log.exception( str( exception ) ) + + except Exception, exception: + trans.response.status = 500 + rval = { 'error': str( exception ) } + log.exception( 'creation of visualization failed: %s' %( str( exception ) ) ) + + return rval + + @web.expose_api + def update( self, trans, id, payload, **kwargs ): + """ + PUT /api/visualizations/{encoded_visualization_id} + """ + rval = None + try: + payload = self._validate_and_parse_payload( payload ) + + # there's a differentiation here between updating the visualiztion and creating a new revision + # that needs to be handled clearly here + # or alternately, using a different controller like PUT /api/visualizations/{id}/r/{id} + + #TODO: consider allowing direct alteration of revisions title (without a new revision) + # only create a new revsion on a different config + + # only update owned visualizations + visualization = self.get_visualization( trans, id, check_ownership=True ) + title = payload.get( 'title', visualization.latest_revision.title ) + dbkey = payload.get( 'dbkey', visualization.latest_revision.dbkey ) + config = payload.get( 'config', visualization.latest_revision.config ) + + latest_config = visualization.latest_revision.config + if( ( title != visualization.latest_revision.title ) + or ( dbkey != visualization.latest_revision.dbkey ) + or ( util.json.to_json_string( config ) != util.json.to_json_string( latest_config ) ) ): + revision = self.add_visualization_revision( trans, visualization, config, title, dbkey ) + #TODO: need to somehow get what changed here? + rval = { 'id' : revision.id } + + except ( ItemAccessibilityException, ItemDeletionException ), exception: + trans.response.status = 403 + rval = { 'error': str( exception ) } + if trans.debug: + log.exception( str( exception ) ) + + except ( ValueError, AttributeError ), exception: + trans.response.status = 400 + rval = { 'error': str( exception ) } + if trans.debug: + log.exception( str( exception ) ) + + except Exception, exception: + trans.response.status = 500 + rval = { 'error': str( exception ) } + log.exception( 'update of visualization (%s) failed: %s' %( id, str( exception ) ) ) + + return rval + + def _validate_and_parse_payload( self, payload ): + """ + Validate and parse incomming data payload for a visualization. + """ + # This layer handles (most of the stricter idiot proofing): + # - unknown/unallowed keys + # - changing data keys from api key to attribute name + # - protection against bad data form/type + # - protection against malicious data content + # all other conversions and processing (such as permissions, etc.) should happen down the line + + # keys listed here don't error when attempting to set, but fail silently + # this allows PUT'ing an entire model back to the server without attribute errors on uneditable attrs + valid_but_uneditable_keys = ( + 'id', 'model_class' + #TODO: fill out when we create get_api_value, get_dict, whatevs + ) + #TODO: deleted + #TODO: importable + + validated_payload = {} + for key, val in payload.items(): + if key == 'config': + if not isinstance( val, dict ): + raise ValueError( '%s must be a dictionary (JSON): %s' %( key, str( type( val ) ) ) ) + + elif key == 'annotation': + if not ( isinstance( val, str ) or isinstance( val, unicode ) ): + raise ValueError( '%s must be a string or unicode: %s' %( key, str( type( val ) ) ) ) + val = util.sanitize_html.sanitize_html( val, 'utf-8' ) + + # these are keys that actually only be *updated* at the revision level and not here + # (they are still valid for create, tho) + elif key == 'title': + if not ( isinstance( val, str ) or isinstance( val, unicode ) ): + raise ValueError( '%s must be a string or unicode: %s' %( key, str( type( val ) ) ) ) + val = util.sanitize_html.sanitize_html( val, 'utf-8' ) + elif key == 'slug': + if not isinstance( val, str ): + raise ValueError( '%s must be a string: %s' %( key, str( type( val ) ) ) ) + val = util.sanitize_html.sanitize_html( val, 'utf-8' ) + elif key == 'type': + if not isinstance( val, str ): + raise ValueError( '%s must be a string: %s' %( key, str( type( val ) ) ) ) + val = util.sanitize_html.sanitize_html( val, 'utf-8' ) + #TODO: validate types in VALID_TYPES/registry names at the mixin/model level? + elif key == 'dbkey': + if not ( isinstance( val, str ) or isinstance( val, unicode ) ): + raise ValueError( '%s must be a string or unicode: %s' %( key, str( type( val ) ) ) ) + val = util.sanitize_html.sanitize_html( val, 'utf-8' ) + + elif key not in valid_but_uneditable_keys: + raise AttributeError( 'unknown key: %s' %( str( key ) ) ) + + validated_payload[ key ] = val + return validated_payload + + #@web.expose_api + #def delete( self, trans, id, **kwd ): + # """ + # DELETE /api/visualizations/{encoded_history_id} + # Deletes a visualization from the database + # """ + # pass diff -r fcf327d7dd51c19b2478fe2ab4361b67fe8d3897 -r ae31fdf708efc84f1db7176b170596c9eb7f0212 test/casperjs/api-visualizations-tests.js --- /dev/null +++ b/test/casperjs/api-visualizations-tests.js @@ -0,0 +1,282 @@ +/* Utility to load a specific page and output html, page text, or a screenshot + * Optionally wait for some time, text, or dom selector + */ +try { + //...if there's a better way - please let me know, universe + var scriptDir = require( 'system' ).args[3] + // remove the script filename + .replace( /[\w|\.|\-|_]*$/, '' ) + // if given rel. path, prepend the curr dir + .replace( /^(?!\/)/, './' ), + spaceghost = require( scriptDir + 'spaceghost' ).create({ + // script options here (can be overridden by CLI) + //verbose: true, + //logLevel: debug, + scriptDir: scriptDir + }); + +} catch( error ){ + console.debug( error ); + phantom.exit( 1 ); +} +spaceghost.start(); + + +// =================================================================== SET UP +var utils = require( 'utils' ); + +var email = spaceghost.user.getRandomEmail(), + password = '123456'; +if( spaceghost.fixtureData.testUser ){ + email = spaceghost.fixtureData.testUser.email; + password = spaceghost.fixtureData.testUser.password; +} +spaceghost.user.loginOrRegisterUser( email, password ); + +function hasKeys( object, keysArray ){ + if( !utils.isObject( object ) ){ return false; } + for( var i=0; i<keysArray.length; i += 1 ){ + if( !object.hasOwnProperty( keysArray[i] ) ){ + spaceghost.debug( 'object missing key: ' + keysArray[i] ); + return false; + } + } + return true; +} + +function countKeys( object ){ + if( !utils.isObject( object ) ){ return 0; } + var count = 0; + for( var key in object ){ + if( object.hasOwnProperty( key ) ){ count += 1; } + } + return count; +} + +function compareObjs( obj, compare_to, exclusionList ){ + exclusionList = exclusionList || []; + for( var key in compare_to ){ + if( compare_to.hasOwnProperty( key ) && exclusionList.indexOf( key ) === -1 ){ + if( !obj.hasOwnProperty( key ) ){ + spaceghost.debug( 'obj missing key: ' + key ); + return false; + } + if( obj[ key ] !== compare_to[ key ] ){ + spaceghost.debug( 'obj value not equal. obj: ' + obj[ key ] + ', v.: ' + compare_to[ key ] ); + return false; + } + } + } + return true; +} + +function assertRaises( fn, expectedCode, errMsgShouldContain, assertionMsg, debug ){ + //TODO: errMsgShouldContain optional, any old error + assertionMsg = assertionMsg || 'create returned error'; + debug = debug || false; + var errorReturned = {}; + try { + fn.call( spaceghost ); + } catch( error ){ + errorReturned = error; + } + if( errorReturned ){ + if( debug ){ spaceghost.debug( 'error:\n' + spaceghost.jsonStr( errorReturned ) ); } + var errorMsg = errorReturned.message || ''; + if( errorMsg ){ + try { + errorMsg = JSON.parse( errorReturned.message ).error; + } catch( parseErr ){ + spaceghost.warn( 'error parsing error.message' ); + } + } + var properCode = errorReturned.status === expectedCode, + properMsg = errorMsg.indexOf( errMsgShouldContain ) !== -1; + spaceghost.test.assert( properCode && properMsg, + assertionMsg + ': (' + errorReturned.status + ') "' + errorMsg + '"' ); + if( !( properCode && properMsg ) ){ + throw errorReturned; + } + } else { + spaceghost.test.fail( assertionMsg + ': (no error)' ); + } +} + + +// =================================================================== TESTS +spaceghost.thenOpen( spaceghost.baseUrl ).then( function(){ + var ALWAYS_CREATE = true, + indexKeys = [ + 'id', 'title', 'type', 'dbkey', 'url' + ], + showKeys = indexKeys.concat([ + 'user_id', 'model_class', 'revisions', 'latest_revision', 'annotation' + ]), + revisionKeys = [ + 'id', 'title', 'visualization_id', 'dbkey', 'model_class', 'config' + ]; + + // ------------------------------------------------------------------------------------------- set up + var visualizationIndex = this.api.visualizations.index(); + if( ALWAYS_CREATE || !visualizationIndex.length ){ + // post a visualization + this.info( 'creating new visualization for tests' ); + var testVisualization = this.api.visualizations.create({ + title : 'Test Visualization', + // needs to be unique + slug : 'test-visualization-' + Date.now(), + type : 'test', + dbkey : 'hg17', + annotation : 'this is a test of the emergency visualization system', + config : { + x : 10, + y : 12 + } + }); + this.debug( this.jsonStr( testVisualization ) ); + } + + // ------------------------------------------------------------------------------------------- INDEX + this.test.comment( 'index should get a list of visualizations' ); + visualizationIndex = this.api.visualizations.index(); + this.debug( this.jsonStr( visualizationIndex ) ); + this.test.assert( utils.isArray( visualizationIndex ), + "index returned an array: length " + visualizationIndex.length ); + this.test.assert( visualizationIndex.length >= 1, 'Has at least one visualization' ); + + var firstVisualization = visualizationIndex[0]; + this.test.assert( hasKeys( firstVisualization, indexKeys ), 'Has the proper keys' ); + this.test.assert( this.api.isEncodedId( firstVisualization.id ), 'Id appears well-formed' ); + + //TODO: index searching + //TODO: anon user + //TODO: admin user + + // ------------------------------------------------------------------------------------------- SHOW + this.test.comment( 'show should get a visualization details object' ); + var visualizationShow = this.api.visualizations.show( firstVisualization.id ); + this.debug( this.jsonStr( visualizationShow ) ); + this.test.assert( hasKeys( visualizationShow, showKeys ), 'Has the proper keys' ); + this.test.assert( visualizationShow.model_class === 'Visualization', + 'Has the proper model_class: ' + visualizationShow.model_class ); + + this.test.comment( 'a visualization details object should contain an array of revision ids' ); + var revisions = visualizationShow.revisions; + this.test.assert( utils.isArray( revisions ), 'revisions is an array' ); + this.test.assert( revisions.length >= 1, 'revisions has at least one entry' ); + var areIds = true; + revisions.forEach( function( revision ){ + if( !spaceghost.api.isEncodedId( revision ) ){ areIds = false; } + }); + this.test.assert( areIds, 'all revisions are ids' ); + + this.test.comment( 'a visualization details object should contain a subobject of the latest revision' ); + var latestRevision = visualizationShow.latest_revision; + this.test.assert( utils.isObject( latestRevision ), 'latestRevision is an object' ); + this.test.assert( hasKeys( latestRevision, revisionKeys ), 'latestRevision has the proper keys' ); + this.test.assert( latestRevision.model_class === 'VisualizationRevision', + 'Has the proper model_class: ' + latestRevision.model_class ); + this.test.assert( latestRevision.visualization_id === visualizationShow.id, + 'revision visualization_id matches containing visualization id: ' + latestRevision.visualization_id ); + this.test.assert( visualizationShow.revisions.indexOf( latestRevision.id ) !== -1, + 'found latest_revision id in revisions' ); + + this.test.comment( 'a visualization revision should contain a subobject for the config' ); + var config = latestRevision.config; + this.test.assert( utils.isObject( config ), 'config is an object:\n' + this.jsonStr( config ) ); + + //TODO: url in visualizationIndex == show url + //TODO: non existing id throws error + //TODO: anon user + //TODO: user1 has no permissions to show user2 + + // ------------------------------------------------------------------------------------------- CREATE + this.test.comment( 'Calling create should create a new visualization and allow setting the name' ); + var visualizationData = { + title : 'Created Visualization', + // needs to be unique + slug : 'created-visualization-' + Date.now(), + type : 'test', + dbkey : 'hg17', + annotation : 'invisible visualization', + config : { + x : 10, + y : 12 + } + }; + var created = this.api.visualizations.create( visualizationData ); + this.debug( 'returned from create:\n' + this.jsonStr( created ) ); + this.test.assert( this.api.isEncodedId( created.id ), "create returned an id: " + created.id ); + + // check v. show + visualizationShow = this.api.visualizations.show( created.id ); + this.debug( 'visualizationShow:\n' + this.jsonStr( visualizationShow ) ); + // config is re-located into a revision and won't be there + this.test.assert( compareObjs( visualizationShow, visualizationData, [ 'config' ] ), + "show results seem to match create data" ); + + // the following errors are produced within base.controller.UsesVisualizationsMixin._create_visualization + this.test.comment( 'Calling create with a non-unique slug will cause an API error' ); + assertRaises( function(){ + created = this.api.visualizations.create( visualizationData ); + }, 400, 'visualization identifier must be unique' ); + + this.test.comment( 'Calling create with no title will cause an API error' ); + visualizationData.title = ''; + assertRaises( function(){ + created = this.api.visualizations.create( visualizationData ); + }, 400, 'visualization name is required' ); + visualizationData.title = 'Created Visualization'; + + this.test.comment( 'Calling create with improper characters in the slug will cause an API error' ); + var oldSlug = visualizationData.slug; + visualizationData.slug = '123_()'; + assertRaises( function(){ + created = this.api.visualizations.create( visualizationData ); + }, 400, "visualization identifier must consist of only lowercase letters, numbers, and the '-' character" ); + visualizationData.slug = oldSlug; + + this.test.comment( 'Calling create with an unrecognized key will cause an API error' ); + visualizationData.bler = 'blah'; + assertRaises( function(){ + created = this.api.visualizations.create( visualizationData ); + }, 400, 'unknown key: bler' ); + delete visualizationData.bler; + + this.test.comment( 'Calling create with an unparsable JSON config will cause an API error' ); + visualizationData.config = '3 = nime'; + assertRaises( function(){ + created = this.api.visualizations.create( visualizationData ); + }, 400, 'config must be a dictionary (JSON)' ); + + // ------------------------------------------------------------------------------------------ UPDATE + // ........................................................................................... idiot proofing + this.test.comment( 'updating using a nonsense key should fail with an error' ); + assertRaises( function(){ + returned = this.api.visualizations.update( created.id, { bler : 'blah' }); + }, 400, 'unknown key: bler' ); + + this.test.comment( 'updating by attempting to change type should cause an error' ); + assertRaises( function(){ + returned = this.api.visualizations.update( created.id, { title : 30 }); + }, 400, 'title must be a string or unicode' ); + //TODO: the other types... + + // ........................................................................................... title + //this.test.comment( 'update should create a new visualization revision' ); + // + //this.test.comment( 'updating with a new title should NOT change the visualization title...' ); + //latestRevision = this.api.visualizations.show( created.id ).latest_revision; + //returned = this.api.visualizations.update( created.id, { + // title : 'New title' + //}); + //visualizationShow = this.api.visualizations.show( created.id ); + //this.debug( this.jsonStr( visualizationShow ) ); + //this.test.assert( visualizationShow.title === visualizationData.title, + // "Title does not set via update: " + visualizationShow.title ); + +}); + +// =================================================================== +spaceghost.run( function(){ +}); diff -r fcf327d7dd51c19b2478fe2ab4361b67fe8d3897 -r ae31fdf708efc84f1db7176b170596c9eb7f0212 test/casperjs/modules/api.js --- a/test/casperjs/modules/api.js +++ b/test/casperjs/modules/api.js @@ -24,6 +24,7 @@ this.tools = new ToolsAPI( this ); this.workflows = new WorkflowsAPI( this ); this.users = new UsersAPI( this ); + this.visualizations = new VisualizationsAPI( this ); }; exports.API = API; @@ -73,6 +74,7 @@ if( resp.status !== 200 ){ // grrr... this doesn't lose the \n\r\t //throw new APIError( resp.responseText.replace( /[\s\n\r\t]+/gm, ' ' ).replace( /"/, '' ) ); + this.spaceghost.debug( 'api error response status code: ' + resp.status ); throw new APIError( resp.responseText, resp.status ); } return JSON.parse( resp.responseText ); @@ -490,3 +492,57 @@ }; +// =================================================================== HISTORIES +var VisualizationsAPI = function VisualizationsAPI( api ){ + this.api = api; +}; +VisualizationsAPI.prototype.toString = function toString(){ + return this.api + '.VisualizationsAPI'; +}; + +// ------------------------------------------------------------------- +VisualizationsAPI.prototype.urlTpls = { + index : 'api/visualizations', + show : 'api/visualizations/%s', + create : 'api/visualizations', + //delete_ : 'api/visualizations/%s', + //undelete: 'api/visualizations/deleted/%s/undelete', + update : 'api/visualizations/%s' +}; + +VisualizationsAPI.prototype.index = function index(){ + this.api.spaceghost.info( 'visualizations.index' ); + + return this.api._ajax( this.urlTpls.index ); +}; + +VisualizationsAPI.prototype.show = function show( id ){ + this.api.spaceghost.info( 'visualizations.show' ); + + return this.api._ajax( utils.format( this.urlTpls.show, this.api.ensureId( id ) ) ); +}; + +VisualizationsAPI.prototype.create = function create( payload ){ + this.api.spaceghost.info( 'visualizations.create: ' + this.api.spaceghost.jsonStr( payload ) ); + + // py.payload <-> ajax.data + payload = this.api.ensureObject( payload ); + return this.api._ajax( utils.format( this.urlTpls.create ), { + type : 'POST', + data : payload + }); +}; + +VisualizationsAPI.prototype.update = function create( id, payload ){ + this.api.spaceghost.info( 'visualizations.update: ' + id + ',' + this.api.spaceghost.jsonStr( payload ) ); + + // py.payload <-> ajax.data + id = this.api.ensureId( id ); + payload = this.api.ensureObject( payload ); + url = utils.format( this.urlTpls.update, id ); + + return this.api._ajax( url, { + type : 'PUT', + data : payload + }); +}; Repository URL: https://bitbucket.org/galaxy/galaxy-central/ -- This is a commit notification from bitbucket.org. You are receiving this because you have the service enabled, addressing the recipient of this email.