From 5eb9ca909fd0da9c3f02e8e3f15837f932165feb Mon Sep 17 00:00:00 2001 From: Nate Coraor Date: Tue, 22 Jun 2010 13:10:28 -0400 Subject: [PATCH] Initial implementation of the Galaxy Web API. Disabled unless 'enable_api = True' in config file. You should not enable the API on production sites as this code is brand new and may contain serious bugs and security flaws! Implemented: * Display libraries * Display library info * Display library contents * Display library content info * Create library folders * Upload datasets to a library from a server directory or with a path paste. * Basic example scripts in scripts/api/ Framework changes that were made to support this: * API Key interface in User Preferences. * New api_keys database table for storing users' API Keys. * New API-specific route mapper in webapp. * API controllers in galaxy.web.api * Return handling in reused library_common methods. * expose_api decorator for API controller methods validates key and ensures valid JSON format. * UniverseWebTransaction renamed to GalaxyWebTransaction and subclassed for GalaxyWebUITransaction and GalaxyWebAPITransaction. Things that need to be done next: * Documentation! * Refactor reused code from library_common and other controllers into an even-more-generic location and format. The main changes are that the Web UI returns redirects and rendered templates, whereas the API returns various HTTP status codes and JSON. * Implement more functionality. * The request and response format should be considered alpha and are subject to change. They will be standardized as the API matures. Hints to get started can be found in scripts/api/README --- eggs.ini | 2 +- lib/galaxy/config.py | 2 + lib/galaxy/model/__init__.py | 54 +++++- lib/galaxy/model/mapping.py | 14 +- .../migrate/versions/0049_api_keys_table.py | 38 +++++ lib/galaxy/tools/actions/upload_common.py | 2 +- lib/galaxy/util/__init__.py | 8 + lib/galaxy/web/__init__.py | 2 +- lib/galaxy/web/api/__init__.py | 0 lib/galaxy/web/api/contents.py | 154 ++++++++++++++++++ lib/galaxy/web/api/libraries.py | 62 +++++++ lib/galaxy/web/buildapp.py | 26 +++ lib/galaxy/web/controllers/library_common.py | 105 ++++++++---- lib/galaxy/web/controllers/user.py | 24 ++- lib/galaxy/web/framework/__init__.py | 128 +++++++++++---- lib/galaxy/web/framework/base.py | 27 ++- lib/galaxy/web/security/__init__.py | 4 +- scripts/api/README | 106 ++++++++++++ scripts/api/common.py | 94 +++++++++++ scripts/api/display.py | 11 ++ scripts/api/library_create_folder.py | 20 +++ scripts/api/library_upload_from_import_dir.py | 19 +++ templates/webapps/galaxy/user/info.mako | 37 ++++- universe_wsgi.ini.sample | 3 + 24 files changed, 861 insertions(+), 81 deletions(-) create mode 100644 lib/galaxy/model/migrate/versions/0049_api_keys_table.py create mode 100644 lib/galaxy/web/api/__init__.py create mode 100644 lib/galaxy/web/api/contents.py create mode 100644 lib/galaxy/web/api/libraries.py create mode 100644 scripts/api/README create mode 100644 scripts/api/common.py create mode 100755 scripts/api/display.py create mode 100755 scripts/api/library_create_folder.py create mode 100755 scripts/api/library_upload_from_import_dir.py diff --git a/eggs.ini b/eggs.ini index 38692a98526..a198487ecad 100644 --- a/eggs.ini +++ b/eggs.ini @@ -41,7 +41,7 @@ Paste = 1.6 PasteDeploy = 1.3.3 PasteScript = 1.7.3 pexpect = 2.4 -Routes = 1.11 +Routes = 1.12.3 simplejson = 1.5 SQLAlchemy = 0.5.6 sqlalchemy_migrate = 0.5.4 diff --git a/lib/galaxy/config.py b/lib/galaxy/config.py index a1b6ed21155..8e6401be6c7 100644 --- a/lib/galaxy/config.py +++ b/lib/galaxy/config.py @@ -39,6 +39,8 @@ class Configuration( object ): self.file_path = resolve_path( kwargs.get( "file_path", "database/files" ), self.root ) self.new_file_path = resolve_path( kwargs.get( "new_file_path", "database/tmp" ), self.root ) self.cookie_path = kwargs.get( "cookie_path", "/" ) + # web API + self.enable_api = string_as_bool( kwargs.get( 'enable_api', False ) ) # dataset Track files self.track_store_path = kwargs.get( "track_store_path", "${extra_files_path}/tracks") self.tool_path = resolve_path( kwargs.get( "tool_path", "tools" ), self.root ) diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 49aceacfa90..4cf834fee48 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -829,6 +829,8 @@ class HistoryDatasetAssociationDisplayAtAuthorization( object ): class Library( object ): permitted_actions = get_permitted_actions( filter='LIBRARY' ) + api_collection_visible_keys = ( 'id', 'name' ) + api_element_visible_keys = ( 'name', 'description', 'synopsys' ) def __init__( self, name=None, description=None, synopsis=None, root_folder=None ): self.name = name or "Unnamed library" self.description = description @@ -870,8 +872,21 @@ class Library( object ): if isinstance( name, str ): name = unicode( name, 'utf-8' ) return name + def get_api_value( self, view='collection' ): + rval = {} + try: + visible_keys = self.__getattribute__( 'api_' + view + '_visible_keys' ) + except AttributeError: + raise Exception( 'Unknown API view: %s' % view ) + for key in visible_keys: + try: + rval[key] = self.__getattribute__( key ) + except AttributeError: + rval[key] = None + return rval class LibraryFolder( object ): + api_element_visible_keys = ( 'name', 'description', 'item_count', 'genome_build' ) def __init__( self, name=None, description=None, item_count=0, order_id=None ): self.name = name or "Unnamed folder" self.description = description @@ -961,6 +976,18 @@ class LibraryFolder( object ): if isinstance( name, str ): name = unicode( name, 'utf-8' ) return name + def get_api_value( self, view='collection' ): + rval = {} + try: + visible_keys = self.__getattribute__( 'api_' + view + '_visible_keys' ) + except AttributeError: + raise Exception( 'Unknown API view: %s' % view ) + for key in visible_keys: + try: + rval[key] = self.__getattribute__( key ) + except AttributeError: + rval[key] = None + return rval @property def parent_library( self ): f = self @@ -1011,6 +1038,28 @@ class LibraryDataset( object ): if not purged and self.purged: raise Exception( "Cannot unpurge once purged" ) purged = property( get_purged, set_purged ) + def get_api_value( self, view='collection' ): + # Since this class is a proxy to rather complex attributes we want to + # display in other objects, we can't use the simpler method used by + # other model classes. + ldda = self.library_dataset_dataset_association + rval = dict( name = ldda.name, + uploaded_by = ldda.user.email, + message = ldda.message, + date_uploaded = ldda.create_time.isoformat(), + file_size = int( ldda.get_size() ), + data_type = ldda.ext, + genome_build = ldda.dbkey, + misc_info = ldda.info, + misc_blurb = ldda.blurb ) + for name, spec in ldda.metadata.spec.items(): + val = ldda.metadata.get( name ) + if isinstance( val, MetadataFile ): + val = val.file_name + elif isinstance( val, list ): + val = ', '.join( val ) + rval['metadata_' + name] = val + return rval class LibraryDatasetDatasetAssociation( DatasetInstance ): def __init__( self, @@ -1734,6 +1783,9 @@ class UserAction( object ): self.params = params self.context = context +class APIKeys( object ): + pass + ## ---- Utility methods ------------------------------------------------------- def directory_hash_id( id ): @@ -1748,5 +1800,3 @@ def directory_hash_id( id ): padded = padded[:-3] # Break into chunks of three return [ padded[i*3:(i+1)*3] for i in range( len( padded ) // 3 ) ] - - diff --git a/lib/galaxy/model/mapping.py b/lib/galaxy/model/mapping.py index 9efb8291c91..b0eda4469e9 100644 --- a/lib/galaxy/model/mapping.py +++ b/lib/galaxy/model/mapping.py @@ -889,6 +889,12 @@ UserAction.table = Table( "user_action", metadata, Column( "context", Unicode( 512 ) ), Column( "params", Unicode( 1024 ) ) ) +APIKeys.table = Table( "api_keys", metadata, + Column( "id", Integer, primary_key=True ), + Column( "create_time", DateTime, default=now ), + Column( "user_id", Integer, ForeignKey( "galaxy_user.id" ), index=True ), + Column( "key", TrimmedString( 32 ), index=True, unique=True ) ) + # With the tables defined we can define the mappers and setup the # relationships between the model objects. @@ -1067,8 +1073,9 @@ assign_mapper( context, User, User.table, _preferences=relation( UserPreference, backref="user", collection_class=attribute_mapped_collection('name')), # addresses=relation( UserAddress, # primaryjoin=( User.table.c.id == UserAddress.table.c.user_id ) ) - values=relation( FormValues, - primaryjoin=( User.table.c.form_values_id == FormValues.table.c.id ) ), + values=relation( FormValues, + primaryjoin=( User.table.c.form_values_id == FormValues.table.c.id ) ), + api_keys=relation( APIKeys, backref="user", order_by=desc( APIKeys.table.c.create_time ) ), ) ) # Set up proxy so that this syntax is possible: @@ -1485,6 +1492,9 @@ assign_mapper( context, UserAction, UserAction.table, properties = dict( user=relation( User.mapper ) ) ) +assign_mapper( context, APIKeys, APIKeys.table, + properties = {} ) + def db_next_hid( self ): """ Override __next_hid to generate from the database in a concurrency diff --git a/lib/galaxy/model/migrate/versions/0049_api_keys_table.py b/lib/galaxy/model/migrate/versions/0049_api_keys_table.py new file mode 100644 index 00000000000..106920b2e5c --- /dev/null +++ b/lib/galaxy/model/migrate/versions/0049_api_keys_table.py @@ -0,0 +1,38 @@ +""" +Migration script to add the api_keys table. +""" + +from sqlalchemy import * +from migrate import * +from migrate.changeset import * +from galaxy.model.custom_types import * + +import datetime +now = datetime.datetime.utcnow + +import logging +log = logging.getLogger( __name__ ) + +metadata = MetaData( migrate_engine ) + +APIKeys_table = Table( "api_keys", metadata, + Column( "id", Integer, primary_key=True ), + Column( "create_time", DateTime, default=now ), + Column( "user_id", Integer, ForeignKey( "galaxy_user.id" ), index=True ), + Column( "key", TrimmedString( 32 ), index=True, unique=True ) ) + +def upgrade(): + print __doc__ + metadata.reflect() + try: + APIKeys_table.create() + except Exception, e: + log.debug( "Creating api_keys table failed: %s" % str( e ) ) + +def downgrade(): + # Load existing tables + metadata.reflect() + try: + APIKeys_table.drop() + except Exception, e: + log.debug( "Dropping api_keys table failed: %s" % str( e ) ) diff --git a/lib/galaxy/tools/actions/upload_common.py b/lib/galaxy/tools/actions/upload_common.py index 6e56fba429a..5d0db288884 100644 --- a/lib/galaxy/tools/actions/upload_common.py +++ b/lib/galaxy/tools/actions/upload_common.py @@ -124,7 +124,7 @@ def new_history_upload( trans, uploaded_dataset, state=None ): return hda def new_library_upload( trans, cntrller, uploaded_dataset, library_bunch, state=None ): current_user_roles = trans.get_current_user_roles() - if not ( cntrller in [ 'library_admin' ] or trans.app.security_agent.can_add_library_item( current_user_roles, library_bunch.folder ) ): + if not ( ( trans.user_is_admin() and cntrller in [ 'library_admin', 'api' ] ) or trans.app.security_agent.can_add_library_item( current_user_roles, library_bunch.folder ) ): # This doesn't have to be pretty - the only time this should happen is if someone's being malicious. raise Exception( "User is not authorized to add datasets to this library." ) folder = library_bunch.folder diff --git a/lib/galaxy/util/__init__.py b/lib/galaxy/util/__init__.py index 0789dbb6cc7..8f1ab4b7134 100644 --- a/lib/galaxy/util/__init__.py +++ b/lib/galaxy/util/__init__.py @@ -417,6 +417,14 @@ def stringify_dictionary_keys( in_dict ): out_dict[ str( key ) ] = value return out_dict +def recursively_stringify_dictionary_keys( d ): + if isinstance(d, dict): + return dict([(k.encode('utf-8'), recursively_stringify_dictionary_keys(v)) for k,v in d.iteritems()]) + elif isinstance(d, list): + return [recursively_stringify_dictionary_keys(x) for x in d] + else: + return d + def mkstemp_ln( src, prefix='mkstemp_ln_' ): """ From tempfile._mkstemp_inner, generate a hard link in the same dir with a diff --git a/lib/galaxy/web/__init__.py b/lib/galaxy/web/__init__.py index e9c2c41a52c..1c9295630e0 100644 --- a/lib/galaxy/web/__init__.py +++ b/lib/galaxy/web/__init__.py @@ -2,6 +2,6 @@ The Galaxy web application. """ -from framework import expose, json, json_pretty, require_login, require_admin, url_for, error, form, FormBuilder +from framework import expose, json, json_pretty, require_login, require_admin, url_for, error, form, FormBuilder, expose_api from framework.base import httpexceptions diff --git a/lib/galaxy/web/api/__init__.py b/lib/galaxy/web/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/galaxy/web/api/contents.py b/lib/galaxy/web/api/contents.py new file mode 100644 index 00000000000..e31fc267355 --- /dev/null +++ b/lib/galaxy/web/api/contents.py @@ -0,0 +1,154 @@ +""" +API operations on the contents of a library. +""" +import logging, os, string, shutil, urllib, re, socket +from cgi import escape, FieldStorage +from galaxy import util, datatypes, jobs, web, util +from galaxy.web.base.controller import * +from galaxy.util.sanitize_html import sanitize_html +from galaxy.model.orm import * + +log = logging.getLogger( __name__ ) + +class ContentsController( BaseController ): + + @web.expose_api + def index( self, trans, library_id, **kwd ): + """ + GET /api/libraries/{encoded_library_id}/contents + Displays a collection (list) of library contents (files and folders). + """ + rval = [] + current_user_roles = trans.get_current_user_roles() + def traverse( folder ): + admin = trans.user_is_admin() + rval = [] + for subfolder in folder.active_folders: + if not admin: + can_access, folder_ids = trans.app.security_agent.check_folder_contents( trans.user, current_user_roles, subfolder ) + if admin or can_access: + subfolder.api_path = folder.api_path + '/' + subfolder.name + subfolder.api_type = 'folder' + rval.append( subfolder ) + rval.extend( traverse( subfolder ) ) + for ld in folder.datasets: + if not admin: + can_access = trans.app.security_agent.can_access_dataset( current_user_roles, ld.library_dataset_dataset_association.dataset ) + if admin or can_access: + ld.api_path = folder.api_path + '/' + ld.name + ld.api_type = 'file' + rval.append( ld ) + return rval + try: + decoded_library_id = trans.security.decode_id( library_id ) + except TypeError: + trans.response.status = 400 + return "Malformed library id ( %s ) specified, unable to decode." % str( library_id ) + try: + library = trans.sa_session.query( trans.app.model.Library ).get( decoded_library_id ) + except: + library = None + if not library or not ( trans.user_is_admin() or trans.app.security_agent.can_access_library( current_user_roles, library ) ): + trans.response.status = 400 + return "Invalid library id ( %s ) specified." % str( library_id ) + encoded_id = trans.security.encode_id( 'folder.%s' % library.root_folder.id ) + rval.append( dict( id = encoded_id, + type = 'folder', + name = '/', + url = url_for( 'content', library_id=library_id, id=encoded_id ) ) ) + library.root_folder.api_path = '' + for content in traverse( library.root_folder ): + encoded_id = trans.security.encode_id( '%s.%s' % ( content.api_type, content.id ) ) + rval.append( dict( id = encoded_id, + type = content.api_type, + name = content.api_path, + url = url_for( 'content', library_id=library_id, id=encoded_id, ) ) ) + return rval + + @web.expose_api + def show( self, trans, id, library_id, **kwd ): + """ + GET /api/libraries/{encoded_library_id}/contents/{encoded_content_type_and_id} + Displays information about a library content (file or folder). + """ + content_id = id + try: + decoded_type_and_id = trans.security.decode_string_id( content_id ) + content_type, decoded_content_id = decoded_type_and_id.split( '.' ) + except: + trans.response.status = 400 + return "Malformed content id ( %s ) specified, unable to decode." % str( content_id ) + if content_type == 'folder': + model_class = trans.app.model.LibraryFolder + elif content_type == 'file': + model_class = trans.app.model.LibraryDataset + else: + trans.response.status = 400 + return "Invalid type ( %s ) specified." % str( content_type ) + try: + content = trans.sa_session.query( model_class ).get( decoded_content_id ) + except: + content = None + if not content or ( not trans.user_is_admin() and not trans.app.security_agent.can_access_library_item( trans.get_current_user_roles(), content, trans.user ) ): + trans.response.status = 400 + return "Invalid %s id ( %s ) specified." % ( content_type, str( content_id ) ) + return content.get_api_value( view='element' ) + + @web.expose_api + def create( self, trans, library_id, payload, **kwd ): + """ + POST /api/libraries/{encoded_library_id}/contents + Creates a new library content item (file or folder). + """ + create_type = None + if 'create_type' not in payload: + trans.response.status = 400 + return "Missing required 'create_type' parameter. Please consult the API documentation for help." + else: + create_type = payload.pop( 'create_type' ) + if create_type not in ( 'file', 'folder' ): + trans.response.status = 400 + return "Invalid value for 'create_type' parameter ( %s ) specified. Please consult the API documentation for help." % create_type + try: + content_id = str( payload.pop( 'folder_id' ) ) + decoded_type_and_id = trans.security.decode_string_id( content_id ) + parent_type, decoded_parent_id = decoded_type_and_id.split( '.' ) + assert parent_type in ( 'folder', 'file' ) + except: + trans.response.status = 400 + return "Malformed parent id ( %s ) specified, unable to decode." % content_id + # "content" can be either a folder or a file, but the parent of new contents can only be folders. + if parent_type == 'file': + trans.response.status = 400 + try: + # With admins or people who can access the dataset provided as the parent, be descriptive. + dataset = trans.sa_session.query( trans.app.model.LibraryDataset ).get( decoded_parent_id ).library_dataset_dataset_association.dataset + assert trans.user_is_admin() or trans.app.security_agent.can_access_dataset( trans.get_current_user_roles(), dataset ) + return "The parent id ( %s ) points to a file, not a folder." % content_id + except: + # If you can't access the parent we don't want to reveal its existence. + return "Invalid parent folder id ( %s ) specified." % content_id + # The rest of the security happens in the library_common controller. + folder_id = trans.security.encode_id( decoded_parent_id ) + # Now create the desired content object, either file or folder. + if create_type == 'file': + status, output = trans.webapp.controllers['library_common'].upload_library_dataset( trans, 'api', library_id, folder_id, **payload ) + elif create_type == 'folder': + status, output = trans.webapp.controllers['library_common'].create_folder( trans, 'api', folder_id, library_id, **payload ) + if status != 200: + trans.response.status = status + # We don't want to reveal the encoded folder_id since it's invalid + # in the API context. Instead, return the content_id originally + # supplied by the client. + output = output.replace( folder_id, content_id ) + return output + else: + rval = [] + for k, v in output.items(): + if type( v ) == trans.app.model.LibraryDatasetDatasetAssociation: + v = v.library_dataset + encoded_id = trans.security.encode_id( create_type + '.' + str( v.id ) ) + rval.append( dict( id = encoded_id, + name = v.name, + url = url_for( 'content', library_id=library_id, id=encoded_id ) ) ) + return rval diff --git a/lib/galaxy/web/api/libraries.py b/lib/galaxy/web/api/libraries.py new file mode 100644 index 00000000000..a5b06989b21 --- /dev/null +++ b/lib/galaxy/web/api/libraries.py @@ -0,0 +1,62 @@ +""" +API operations on a library. +""" +import logging, os, string, shutil, urllib, re, socket +from cgi import escape, FieldStorage +from galaxy import util, datatypes, jobs, web, util +from galaxy.web.base.controller import * +from galaxy.util.sanitize_html import sanitize_html +from galaxy.model.orm import * + +log = logging.getLogger( __name__ ) + +class LibrariesController( BaseController ): + + @web.expose_api + def index( self, trans, **kwd ): + """ + GET /api/libraries + Displays a collection (list) of libraries. + """ + query = trans.sa_session.query( trans.app.model.Library ).filter( trans.app.model.Library.table.c.deleted == False ) + current_user_role_ids = [ role.id for role in trans.get_current_user_roles() ] + library_access_action = trans.app.security_agent.permitted_actions.LIBRARY_ACCESS.action + restricted_library_ids = [ lp.library_id for lp in trans.sa_session.query( trans.model.LibraryPermissions ) \ + .filter( trans.model.LibraryPermissions.table.c.action == library_access_action ) \ + .distinct() ] + accessible_restricted_library_ids = [ lp.library_id for lp in trans.sa_session.query( trans.model.LibraryPermissions ) \ + .filter( and_( trans.model.LibraryPermissions.table.c.action == library_access_action, + trans.model.LibraryPermissions.table.c.role_id.in_( current_user_role_ids ) ) ) ] + query = query.filter( or_( not_( trans.model.Library.table.c.id.in_( restricted_library_ids ) ), + trans.model.Library.table.c.id.in_( accessible_restricted_library_ids ) ) ) + rval = [] + for library in query: + item = library.get_api_value() + item['url'] = url_for( 'library', id=trans.security.encode_id( library.id ) ) + item['id'] = trans.security.encode_id( item['id'] ) + rval.append( item ) + return rval + + @web.expose_api + def show( self, trans, id, **kwd ): + """ + GET /api/libraries/{encoded_library_id} + Displays information about a library. + """ + library_id = id + params = util.Params( kwd ) + try: + decoded_library_id = trans.security.decode_id( library_id ) + except TypeError: + trans.response.status = 400 + return "Malformed library id ( %s ) specified, unable to decode." % str( library_id ) + try: + library = trans.sa_session.query( trans.app.model.Library ).get( decoded_library_id ) + except: + library = None + if not library or not ( trans.user_is_admin() or trans.app.security_agent.can_access_library( trans.get_current_user_roles(), library ) ): + trans.response.status = 400 + return "Invalid library id ( %s ) specified." % str( library_id ) + item = library.get_api_value( view='element' ) + item['contents_url'] = url_for( 'contents', library_id=library_id ) + return item diff --git a/lib/galaxy/web/buildapp.py b/lib/galaxy/web/buildapp.py index 389db165e0c..4cb33c6f83b 100644 --- a/lib/galaxy/web/buildapp.py +++ b/lib/galaxy/web/buildapp.py @@ -48,6 +48,27 @@ def add_controllers( webapp, app ): if isclass( T ) and T is not BaseController and issubclass( T, BaseController ): webapp.add_controller( name, T( app ) ) +def add_api_controllers( webapp, app ): + from galaxy.web.base.controller import BaseController + from galaxy.web.base.controller import ControllerUnavailable + import galaxy.web.api + controller_dir = galaxy.web.api.__path__[0] + for fname in os.listdir( controller_dir ): + if not( fname.startswith( "_" ) ) and fname.endswith( ".py" ): + name = fname[:-3] + module_name = "galaxy.web.api." + name + try: + module = __import__( module_name ) + except ControllerUnavailable, exc: + log.debug("%s could not be loaded: %s" % (module_name, str(exc))) + continue + for comp in module_name.split( "." )[1:]: + module = getattr( module, comp ) + for key in dir( module ): + T = getattr( module, key ) + if isclass( T ) and T is not BaseController and issubclass( T, BaseController ): + webapp.add_api_controller( name, T( app ) ) + def app_factory( global_conf, **kwargs ): """ Return a wsgi application serving the root object @@ -80,6 +101,11 @@ def app_factory( global_conf, **kwargs ): webapp.add_route( '/u/:username/h/:slug', controller='history', action='display_by_username_and_slug' ) webapp.add_route( '/u/:username/w/:slug', controller='workflow', action='display_by_username_and_slug' ) webapp.add_route( '/u/:username/v/:slug', controller='visualization', action='display_by_username_and_slug' ) + # If enabled, add the web API + if asbool( kwargs.get( 'enable_api', False ) ): + add_api_controllers( webapp, app ) + webapp.api_mapper.resource( 'content', 'contents', path_prefix='/api/libraries/:library_id', parent_resources=dict( member_name='library', collection_name='libraries' ) ) + webapp.api_mapper.resource( 'library', 'libraries', path_prefix='/api' ) webapp.finalize_config() # Wrap the webapp in some useful middleware if kwargs.get( 'middleware', True ): diff --git a/lib/galaxy/web/controllers/library_common.py b/lib/galaxy/web/controllers/library_common.py index 0fce00d5380..3fea1124629 100644 --- a/lib/galaxy/web/controllers/library_common.py +++ b/lib/galaxy/web/controllers/library_common.py @@ -280,7 +280,7 @@ class LibraryCommon( BaseController ): status = params.get( 'status', 'done' ) show_deleted = util.string_as_bool( params.get( 'show_deleted', False ) ) use_panels = util.string_as_bool( params.get( 'use_panels', False ) ) - is_admin = trans.user_is_admin() and cntrller == 'library_admin' + is_admin = trans.user_is_admin() and cntrller in ( 'library_admin', 'api' ) current_user_roles = trans.get_current_user_roles() try: parent_folder = trans.sa_session.query( trans.app.model.LibraryFolder ).get( trans.security.decode_id( parent_id ) ) @@ -294,6 +294,8 @@ class LibraryCommon( BaseController ): # its parent library, or if they are not able to see the folder's contents. if not parent_folder or ( not is_admin and not trans.app.security_agent.can_access_library_item( current_user_roles, parent_folder, trans.user ) ): message = "Invalid parent folder id ( %s ) specified." % str( parent_id ) + if cntrller == 'api': + return 400, message # This doesn't give away the library's existence since # browse_library will simply punt to browse_libraries if the # user-supplied id is invalid or inaccessible. @@ -309,6 +311,8 @@ class LibraryCommon( BaseController ): if not ( is_admin or trans.app.security_agent.can_add_library_item( current_user_roles, parent_folder ) ): message = "You are not authorized to create a folder in parent folder '%s'." % parent_folder.name # Redirect to the real parent library since we know we have access to it. + if cntrller == 'api': + return 403, message return trans.response.send_redirect( web.url_for( controller='library_common', action='browse_library', cntrller=cntrller, @@ -317,7 +321,7 @@ class LibraryCommon( BaseController ): show_deleted=show_deleted, message=util.sanitize_text( message ), status='error' ) ) - if params.get( 'new_folder_button', False ): + if params.get( 'new_folder_button', False ) or cntrller == 'api': new_folder = trans.app.model.LibraryFolder( name=util.restore_text( params.name ), description=util.restore_text( params.description ) ) # We are associating the last used genome build with folders, so we will always @@ -329,6 +333,9 @@ class LibraryCommon( BaseController ): trans.sa_session.flush() # New folders default to having the same permissions as their parent folder trans.app.security_agent.copy_library_permissions( parent_folder, new_folder ) + # If we're creating in the API, we're done + if cntrller == 'api': + return 200, dict( created=new_folder ) # If we have an inheritable template, redirect to the folder_info page so information # can be filled in immediately. widgets = [] @@ -654,7 +661,7 @@ class LibraryCommon( BaseController ): show_deleted=show_deleted, message=util.sanitize_text( message ), status='error' ) ) - if ( trans.user_is_admin() and cntrller == 'library_admin' ): + if is_admin: # Get all associated hdas and lddas that use the same disk file. associated_hdas = trans.sa_session.query( trans.model.HistoryDatasetAssociation ) \ .filter( and_( trans.model.HistoryDatasetAssociation.deleted == False, @@ -848,7 +855,7 @@ class LibraryCommon( BaseController ): last_used_build = dbkey[0] else: last_used_build = dbkey - is_admin = trans.user_is_admin() and cntrller == 'library_admin' + is_admin = trans.user_is_admin() and cntrller in ( 'library_admin', 'api' ) current_user_roles = trans.get_current_user_roles() if replace_id not in [ None, 'None' ]: try: @@ -859,6 +866,8 @@ class LibraryCommon( BaseController ): # its parent library, or if they are not able to view the dataset itself. if not replace_dataset or ( not is_admin and not trans.app.security_agent.can_access_library_item( current_user_roles, replace_dataset, trans.user ) ): message = "Invalid library dataset id ( %s ) to replace specified." % replace_id + if cntrller == 'api': + return 400, message return trans.response.send_redirect( web.url_for( controller='library_common', action='browse_library', cntrller=cntrller, @@ -870,6 +879,8 @@ class LibraryCommon( BaseController ): # Deny access if the user is not an admin and does not have the LIBRARY_MODIFY permission. if not ( is_admin or trans.app.security_agent.can_modify_library_item( current_user_roles, replace_dataset ) ): message = "You are not authorized to replace library dataset '%s'." % replace_dataset.name + if cntrller == 'api': + return 403, message return trans.response.send_redirect( web.url_for( controller='library_common', action='browse_library', cntrller=cntrller, @@ -896,6 +907,8 @@ class LibraryCommon( BaseController ): # its parent library, or if they are not able to see the folder's contents. if not folder or ( not is_admin and not trans.app.security_agent.can_access_library_item( current_user_roles, folder, trans.user ) ): message = "Invalid parent folder id ( %s ) specified." % str( folder_id ) + if cntrller == 'api': + return 400, message return trans.response.send_redirect( web.url_for( controller='library_common', action='browse_library', cntrller=cntrller, @@ -907,6 +920,8 @@ class LibraryCommon( BaseController ): # Deny access if the user is not an admin and does not have the LIBRARY_ADD permission. if not ( is_admin or trans.app.security_agent.can_add_library_item( current_user_roles, folder ) ): message = "You are not authorized to create a library dataset in parent folder '%s'." % folder.name + if cntrller == 'api': + return 403, message return trans.response.send_redirect( web.url_for( controller='library_common', action='browse_library', cntrller=cntrller, @@ -918,7 +933,7 @@ class LibraryCommon( BaseController ): library = folder.parent_library if folder and last_used_build in [ 'None', None, '?' ]: last_used_build = folder.genome_build - if params.get( 'runtool_btn', False ) or params.get( 'ajax_upload', False ): + if params.get( 'runtool_btn', False ) or params.get( 'ajax_upload', False ) or cntrller == 'api': # Check to see if the user selected roles to associate with the DATASET_ACCESS permission # on the dataset that would cause accessibility issues. roles = params.get( 'roles', False ) @@ -931,7 +946,8 @@ class LibraryCommon( BaseController ): permissions, in_roles, error, message = \ trans.app.security_agent.derive_roles_from_access( trans, library.id, cntrller, library=True, **vars ) if error: - status = 'error' + if cntrller == 'api': + return 400, message trans.response.send_redirect( web.url_for( controller='library_common', action='upload_library_dataset', cntrller=cntrller, @@ -961,6 +977,11 @@ class LibraryCommon( BaseController ): replace_dataset=replace_dataset, **kwd ) if created_outputs_dict: + if cntrller == 'api': + # created_outputs_dict can only ever be a string if cntrller == 'api' + if type( created_outputs_dict ) == str: + return 400, created_outputs_dict + return 200, created_outputs_dict total_added = len( created_outputs_dict.keys() ) ldda_id_list = [ str( v.id ) for k, v in created_outputs_dict.items() ] created_ldda_ids=",".join( ldda_id_list ) @@ -1001,6 +1022,9 @@ class LibraryCommon( BaseController ): created_ldda_ids = '' message = "Upload failed" status='error' + if cntrller == 'api': + return 400, message + response_code = 400 trans.response.send_redirect( web.url_for( controller='library_common', action='browse_library', cntrller=cntrller, @@ -1068,7 +1092,6 @@ class LibraryCommon( BaseController ): # Library-specific params params = util.Params( kwd ) # is this filetoolparam safe? show_deleted = util.string_as_bool( params.get( 'show_deleted', False ) ) - library_bunch = upload_common.handle_library_params( trans, params, folder_id, replace_dataset ) message = util.restore_text( params.get( 'message', '' ) ) status = params.get( 'status', 'done' ) server_dir = util.restore_text( params.get( 'server_dir', '' ) ) @@ -1077,11 +1100,11 @@ class LibraryCommon( BaseController ): else: replace_id = None upload_option = params.get( 'upload_option', 'upload_file' ) - err_redirect = False + response_code = 200 if upload_option == 'upload_directory': if server_dir in [ None, 'None', '' ]: - err_redirect = True - if cntrller == 'library_admin': + response_code = 400 + if cntrller == 'library_admin' or ( cntrller == 'api' and trans.user_is_admin ): import_dir = trans.app.config.library_import_dir import_dir_desc = 'library_import_dir' full_dir = os.path.join( import_dir, server_dir ) @@ -1095,21 +1118,35 @@ class LibraryCommon( BaseController ): if import_dir: message = 'Select a directory' else: + response_code = 403 message = '"%s" is not defined in the Galaxy configuration file' % import_dir_desc - # Proceed with (mostly) regular upload processing - precreated_datasets = upload_common.get_precreated_datasets( trans, tool_params, trans.app.model.LibraryDatasetDatasetAssociation, controller=cntrller ) - if upload_option == 'upload_file': - tool_params = upload_common.persist_uploads( tool_params ) - uploaded_datasets = upload_common.get_uploaded_datasets( trans, cntrller, tool_params, precreated_datasets, dataset_upload_inputs, library_bunch=library_bunch ) - elif upload_option == 'upload_directory': - uploaded_datasets, err_redirect, message = self.get_server_dir_uploaded_datasets( trans, cntrller, params, full_dir, import_dir_desc, library_bunch, err_redirect, message ) elif upload_option == 'upload_paths': - uploaded_datasets, err_redirect, message = self.get_path_paste_uploaded_datasets( trans, cntrller, params, library_bunch, err_redirect, message ) - upload_common.cleanup_unused_precreated_datasets( precreated_datasets ) - if upload_option == 'upload_file' and not uploaded_datasets: - message = 'Select a file, enter a URL or enter text' - err_redirect = True - if err_redirect: + if not trans.app.config.allow_library_path_paste: + response_code = 403 + message = '"allow_library_path_paste" is not defined in the Galaxy configuration file' + # Some error handling should be added to this method. + try: + library_bunch = upload_common.handle_library_params( trans, params, folder_id, replace_dataset ) + except: + response_code = 500 + message = "Unable to parse upload parameters, please report this error." + # Proceed with (mostly) regular upload processing if we're still errorless + if response_code == 200: + precreated_datasets = upload_common.get_precreated_datasets( trans, tool_params, trans.app.model.LibraryDatasetDatasetAssociation, controller=cntrller ) + if upload_option == 'upload_file': + tool_params = upload_common.persist_uploads( tool_params ) + uploaded_datasets = upload_common.get_uploaded_datasets( trans, cntrller, tool_params, precreated_datasets, dataset_upload_inputs, library_bunch=library_bunch ) + elif upload_option == 'upload_directory': + uploaded_datasets, response_code, message = self.get_server_dir_uploaded_datasets( trans, cntrller, params, full_dir, import_dir_desc, library_bunch, response_code, message ) + elif upload_option == 'upload_paths': + uploaded_datasets, response_code, message = self.get_path_paste_uploaded_datasets( trans, cntrller, params, library_bunch, response_code, message ) + upload_common.cleanup_unused_precreated_datasets( precreated_datasets ) + if upload_option == 'upload_file' and not uploaded_datasets: + response_code = 400 + message = 'Select a file, enter a URL or enter text' + if response_code != 200: + if cntrller == 'api': + return ( response_code, message ) trans.response.send_redirect( web.url_for( controller='library_common', action='upload_library_dataset', cntrller=cntrller, @@ -1145,7 +1182,7 @@ class LibraryCommon( BaseController ): trans.sa_session.add_all( ( uploaded_dataset.data, uploaded_dataset.data.dataset ) ) trans.sa_session.flush() return uploaded_dataset - def get_server_dir_uploaded_datasets( self, trans, cntrller, params, full_dir, import_dir_desc, library_bunch, err_redirect, message ): + def get_server_dir_uploaded_datasets( self, trans, cntrller, params, full_dir, import_dir_desc, library_bunch, response_code, message ): files = [] try: for entry in os.listdir( full_dir ): @@ -1173,22 +1210,22 @@ class LibraryCommon( BaseController ): files.append( path ) except Exception, e: message = "Unable to get file list for configured %s, error: %s" % ( import_dir_desc, str( e ) ) - err_redirect = True - return None, err_redirect, message + response_code = 500 + return None, response_code, message if not files: message = "The directory '%s' contains no valid files" % full_dir - err_redirect = True - return None, err_redirect, message + response_code = 400 + return None, response_code, message uploaded_datasets = [] for file in files: name = os.path.basename( file ) uploaded_datasets.append( self.make_library_uploaded_dataset( trans, cntrller, params, name, file, 'server_dir', library_bunch ) ) - return uploaded_datasets, None, None - def get_path_paste_uploaded_datasets( self, trans, cntrller, params, library_bunch, err_redirect, message ): + return uploaded_datasets, 200, None + def get_path_paste_uploaded_datasets( self, trans, cntrller, params, library_bunch, response_code, message ): if params.get( 'filesystem_paths', '' ) == '': message = "No paths entered in the upload form" - err_redirect = True - return None, err_redirect, message + response_code = 400 + return None, response_code, message preserve_dirs = True if params.get( 'dont_preserve_dirs', False ): preserve_dirs = False @@ -1222,8 +1259,8 @@ class LibraryCommon( BaseController ): in_folder ) ) if bad_paths: message = "Invalid paths:
" % "
  • ".join( bad_paths ) - err_redirect = True - return None, err_redirect, message + response_code = 400 + return None, response_code, message return uploaded_datasets, None, None @web.expose def add_history_datasets_to_library( self, trans, cntrller, library_id, folder_id, hda_ids='', **kwd ): diff --git a/lib/galaxy/web/controllers/user.py b/lib/galaxy/web/controllers/user.py index 81fdd0edadf..ccfa7a111f4 100644 --- a/lib/galaxy/web/controllers/user.py +++ b/lib/galaxy/web/controllers/user.py @@ -978,4 +978,26 @@ class User( BaseController ): message=message, lines_skipped=lines_skipped ) - \ No newline at end of file + + @web.expose + def new_api_key( self, trans, **kwd ): + params = util.Params( kwd ) + message = util.restore_text( params.get( 'message', '' ) ) + status = params.get( 'status', 'done' ) + admin_view = util.string_as_bool( params.get( 'admin_view', False ) ) + error = '' + user = trans.sa_session.query( trans.app.model.User ).get( int( params.get( 'user_id', None ) ) ) + if params.get( 'new_api_key_button', None ) == 'Generate a new key now': + new_key = trans.app.model.APIKeys() + new_key.user_id = user.id + new_key.key = trans.app.security.get_new_guid() + trans.sa_session.add( new_key ) + trans.sa_session.flush() + message = "Generated a new web API key" + status = "done" + return trans.response.send_redirect( web.url_for( controller='user', + action='show_info', + admin_view=admin_view, + user_id=user.id, + message=message, + status=status ) ) diff --git a/lib/galaxy/web/framework/__init__.py b/lib/galaxy/web/framework/__init__.py index 22148771b85..440bce1e002 100644 --- a/lib/galaxy/web/framework/__init__.py +++ b/lib/galaxy/web/framework/__init__.py @@ -30,6 +30,7 @@ from babel.support import Translations pkg_resources.require( "SQLAlchemy >= 0.4" ) from sqlalchemy import and_ +from sqlalchemy.orm.exc import NoResultFound pkg_resources.require( "pexpect" ) pkg_resources.require( "amqplib" ) @@ -87,6 +88,43 @@ def require_login( verb="perform this action", use_panels=False, webapp='galaxy' return decorator return argcatcher +def expose_api( func ): + def decorator( self, trans, *args, **kwargs ): + def error( environ, start_response ): + start_response( error_status, [('Content-type', 'text/plain')] ) + return error_message + error_status = '403 Forbidden' + if 'key' not in kwargs: + error_message = 'No API key provided with request, please consult the API documentation.' + return error + try: + provided_key = trans.sa_session.query( trans.app.model.APIKeys ).filter( trans.app.model.APIKeys.table.c.key == kwargs['key'] ).one() + except NoResultFound: + error_message = 'Provided API key is not valid.' + return error + newest_key = provided_key.user.api_keys[0] + if newest_key.key != provided_key.key: + error_message = 'Provided API key has expired.' + return error + if trans.request.body: + try: + payload = util.recursively_stringify_dictionary_keys( simplejson.loads( trans.request.body ) ) + kwargs['payload'] = payload + except ValueError: + error_status = '400 Bad Request' + error_message = 'Your request did not appear to be valid JSON, please consult the API documentation' + return error + trans.response.set_content_type( "application/json" ) + trans.set_user( provided_key.user ) + if trans.debug: + return simplejson.dumps( func( self, trans, *args, **kwargs ), indent=4, sort_keys=True ) + else: + return simplejson.dumps( func( self, trans, *args, **kwargs ) ) + if not hasattr(func, '_orig'): + decorator._orig = func + decorator.exposed = True + return decorator + def require_admin( func ): def decorator( self, trans, *args, **kwargs ): admin_users = trans.app.config.get( "admin_users", "" ).split( "," ) @@ -119,7 +157,7 @@ def form( *args, **kwargs ): class WebApplication( base.WebApplication ): def __init__( self, galaxy_app, session_cookie='galaxysession' ): base.WebApplication.__init__( self ) - self.set_transaction_factory( lambda e: UniverseWebTransaction( e, galaxy_app, self, session_cookie ) ) + self.set_transaction_factory( lambda e: self.transaction_chooser( e, galaxy_app, session_cookie ) ) # Mako support self.mako_template_lookup = mako.lookup.TemplateLookup( directories = [ galaxy_app.config.template_path ] , @@ -135,21 +173,21 @@ class WebApplication( base.WebApplication ): if isinstance( body, FormBuilder ): body = trans.show_form( body ) return base.WebApplication.make_body_iterable( self, trans, body ) + def transaction_chooser( self, environ, galaxy_app, session_cookie ): + if 'is_api_request' in environ: + return GalaxyWebAPITransaction( environ, galaxy_app, self ) + else: + return GalaxyWebUITransaction( environ, galaxy_app, self, session_cookie ) -class UniverseWebTransaction( base.DefaultWebTransaction ): +class GalaxyWebTransaction( base.DefaultWebTransaction ): """ - Encapsulates web transaction specific state for the Universe application + Encapsulates web transaction specific state for the Galaxy application (specifically the user's "cookie" session and history) """ - def __init__( self, environ, app, webapp, session_cookie ): + def __init__( self, environ, app, webapp ): self.app = app self.webapp = webapp self.security = webapp.security - # FIXME: the following 3 attributes are not currently used - # Remove them if they are not going to be... - self.__user = NOT_SET - self.__history = NOT_SET - self.__galaxy_session = NOT_SET base.DefaultWebTransaction.__init__( self, environ ) self.setup_i18n() self.sa_session.expunge_all() @@ -158,13 +196,6 @@ class UniverseWebTransaction( base.DefaultWebTransaction ): # that the current history should not be used for parameter values # and such). self.workflow_building_mode = False - # Always have a valid galaxy session - self.__ensure_valid_session( session_cookie ) - # Prevent deleted users from accessing Galaxy - if self.app.config.use_remote_user and self.galaxy_session.user.deleted: - self.response.send_redirect( url_for( '/static/user_disabled.html' ) ) - if self.app.config.require_login: - self.__ensure_logged_in_user( environ ) def setup_i18n( self ): if 'HTTP_ACCEPT_LANGUAGE' in self.environ: # locales looks something like: ['en', 'en-us;q=0.7', 'ja;q=0.3'] @@ -252,12 +283,7 @@ class UniverseWebTransaction( base.DefaultWebTransaction ): tstamp = time.localtime ( time.time() + 3600 * 24 * age ) self.response.cookies[name]['expires'] = time.strftime( '%a, %d-%b-%Y %H:%M:%S GMT', tstamp ) self.response.cookies[name]['version'] = version - #@property - #def galaxy_session( self ): - # if not self.__galaxy_session: - # self.__ensure_valid_session() - # return self.__galaxy_session - def __ensure_valid_session( self, session_cookie ): + def _ensure_valid_session( self, session_cookie ): """ Ensure that a valid Galaxy session exists and is available as trans.session (part of initialization) @@ -344,7 +370,7 @@ class UniverseWebTransaction( base.DefaultWebTransaction ): # If the old session was invalid, get a new history with our new session if invalidate_existing_session: self.new_history() - def __ensure_logged_in_user( self, environ ): + def _ensure_logged_in_user( self, environ ): allowed_paths = ( url_for( controller='root', action='index' ), url_for( controller='root', action='tool_menu' ), @@ -537,15 +563,6 @@ class UniverseWebTransaction( base.DefaultWebTransaction ): self.sa_session.add_all( ( self.galaxy_session, history ) ) self.sa_session.flush() return history - def get_user( self ): - """Return the current user if logged in or None.""" - return self.galaxy_session.user - def set_user( self, user ): - """Set the current user.""" - self.galaxy_session.user = user - self.sa_session.add( self.galaxy_session ) - self.sa_session.flush() - user = property( get_user, set_user ) def get_current_user_roles( self ): user = self.get_user() if user: @@ -735,6 +752,53 @@ class FormInput( object ): self.help = help self.use_label = use_label +class GalaxyWebAPITransaction( GalaxyWebTransaction ): + def __init__( self, environ, app, webapp ): + GalaxyWebTransaction.__init__( self, environ, app, webapp ) + self.__user = None + self._ensure_valid_session( None ) + def _ensure_valid_session( self, session_cookie ): + self.galaxy_session = Bunch() + self.galaxy_session.history = self.galaxy_session.current_history = Bunch() + self.galaxy_session.history.genome_build = None + self.galaxy_session.is_api = True + def get_user( self ): + """Return the current user (the expose_api decorator ensures that it is set).""" + return self.__user + def set_user( self, user ): + """Compatibility method""" + self.__user = user + user = property( get_user, set_user ) + @property + def db_builds( self ): + dbnames = [] + if 'dbkeys' in self.user.preferences: + user_keys = from_json_string( self.user.preferences['dbkeys'] ) + for key, chrom_dict in user_keys.iteritems(): + dbnames.append((key, "%s (%s) [Custom]" % (chrom_dict['name'], key) )) + dbnames.extend( util.dbnames ) + return dbnames + +class GalaxyWebUITransaction( GalaxyWebTransaction ): + def __init__( self, environ, app, webapp, session_cookie ): + GalaxyWebTransaction.__init__( self, environ, app, webapp ) + # Always have a valid galaxy session + self._ensure_valid_session( session_cookie ) + # Prevent deleted users from accessing Galaxy + if self.app.config.use_remote_user and self.galaxy_session.user.deleted: + self.response.send_redirect( url_for( '/static/user_disabled.html' ) ) + if self.app.config.require_login: + self._ensure_logged_in_user( environ ) + def get_user( self ): + """Return the current user if logged in or None.""" + return self.galaxy_session.user + def set_user( self, user ): + """Set the current user.""" + self.galaxy_session.user = user + self.sa_session.add( self.galaxy_session ) + self.sa_session.flush() + user = property( get_user, set_user ) + class SelectInput( FormInput ): """ A select form input. """ def __init__( self, name, label, value=None, options=[], error=None, help=None, use_label=True ): diff --git a/lib/galaxy/web/framework/base.py b/lib/galaxy/web/framework/base.py index 15ae4bd22d1..a39babdb981 100644 --- a/lib/galaxy/web/framework/base.py +++ b/lib/galaxy/web/framework/base.py @@ -45,7 +45,13 @@ class WebApplication( object ): and `__call__` to handle a request (WSGI style). """ self.controllers = dict() + self.api_controllers = dict() self.mapper = routes.Mapper() + # FIXME: The following two options are deprecated and should be + # removed. Consult the Routes documentation. + self.mapper.minimization = True + self.mapper.explicit = False + self.api_mapper = routes.Mapper() self.transaction_factory = DefaultWebTransaction def add_controller( self, controller_name, controller ): """ @@ -56,6 +62,10 @@ class WebApplication( object ): log.debug( "Enabling '%s' controller, class: %s", controller_name, controller.__class__.__name__ ) self.controllers[ controller_name ] = controller + def add_api_controller( self, controller_name, controller ): + log.debug( "Enabling '%s' API controller, class: %s", + controller_name, controller.__class__.__name__ ) + self.api_controllers[ controller_name ] = controller def add_route( self, route, **kwargs ): """ Add a route to match a URL with a method. Accepts all keyword @@ -80,6 +90,7 @@ class WebApplication( object ): """ # Create/compile the regular expressions for route mapping self.mapper.create_regs( self.controllers.keys() ) + self.api_mapper.create_regs( self.api_controllers.keys() ) def __call__( self, environ, start_response ): """ Call interface as specified by WSGI. Wraps the environment in user @@ -88,12 +99,20 @@ class WebApplication( object ): """ # Map url using routes path_info = environ.get( 'PATH_INFO', '' ) - map = self.mapper.match( path_info ) + map = self.mapper.match( path_info, environ ) + if map is None: + environ[ 'is_api_request' ] = True + map = self.api_mapper.match( path_info, environ ) + mapper = self.api_mapper + controllers = self.api_controllers + else: + mapper = self.mapper + controllers = self.controllers if map == None: raise httpexceptions.HTTPNotFound( "No route for " + path_info ) # Setup routes rc = routes.request_config() - rc.mapper = self.mapper + rc.mapper = mapper rc.mapper_dict = map rc.environ = environ # Setup the transaction @@ -101,7 +120,7 @@ class WebApplication( object ): rc.redirect = trans.response.send_redirect # Get the controller class controller_name = map.pop( 'controller', None ) - controller = self.controllers.get( controller_name, None ) + controller = controllers.get( controller_name, None ) if controller_name is None: raise httpexceptions.HTTPNotFound( "No controller for " + path_info ) # Resolve action method on controller @@ -112,7 +131,7 @@ class WebApplication( object ): if method is None: raise httpexceptions.HTTPNotFound( "No action for " + path_info ) # Is the method exposed - if not getattr( method, 'exposed', False ): + if not getattr( method, 'exposed', False ): raise httpexceptions.HTTPNotFound( "Action not exposed for " + path_info ) # Is the method callable if not callable( method ): diff --git a/lib/galaxy/web/security/__init__.py b/lib/galaxy/web/security/__init__.py index 04adc34fd13..bfcc03d0436 100644 --- a/lib/galaxy/web/security/__init__.py +++ b/lib/galaxy/web/security/__init__.py @@ -43,6 +43,8 @@ class SecurityHelper( object ): return self.id_cipher.encrypt( s ).encode( 'hex' ) def decode_id( self, obj_id ): return int( self.id_cipher.decrypt( obj_id.decode( 'hex' ) ).lstrip( "!" ) ) + def decode_string_id( self, obj_id ): + return self.id_cipher.decrypt( obj_id.decode( 'hex' ) ).lstrip( "!" ) def encode_guid( self, session_key ): # Session keys are strings # Pad to a multiple of 8 with leading "!" @@ -55,4 +57,4 @@ class SecurityHelper( object ): def get_new_guid( self ): # Generate a unique, high entropy 128 bit random number return get_random_bytes( 16 ) - \ No newline at end of file + diff --git a/scripts/api/README b/scripts/api/README new file mode 100644 index 00000000000..9503d008ea0 --- /dev/null +++ b/scripts/api/README @@ -0,0 +1,106 @@ +This is not documentation. These are hints and examples to get you started +until the documentation is written. + +Set these options in universe_wsgi.ini and start the server: + +enable_api = True +admin_users = you@example.org +library_import_dir = /path/to/some/directory + +In the directory you specified for 'library_import_dir', create some +subdirectories, and put (or symlink) files to import into Galaxy into those +subdirectories. + +In Galaxy, create an account that matches the address you put in 'admin_users', +then browse to that user's preferences and generate a new API Key. Copy the +key to your clipboard. Create a new library (doing this via the API is not yet +implemented). Then take your API Key and use the scripts in scripts/api/ to do +things: + +% ./display.py my_key http://localhost:4096/api/libraries +Collection Members +------------------ +/api/libraries/f3f73e481f432006 + name: api_test + id: f3f73e481f432006 + +% ./display.py my_key http://localhost:4096/api/libraries/f3f73e481f432006 +Member Information +------------------ +synopsys: None +contents_url: /api/libraries/f3f73e481f432006/contents +description: API Test Library +name: api_test + +% ./display.py my_key http://localhost:4096/api/libraries/f3f73e481f432006/contents +Collection Members +------------------ +/api/libraries/f3f73e481f432006/contents/28202595c0d2591f61ddda595d2c3670 + name: / + type: folder + id: 28202595c0d2591f61ddda595d2c3670 + +% ./library_create_folder.py my_key http://localhost:4096/api/libraries/f3f73e481f432006/contents 28202595c0d2591f61ddda595d2c3670 api_test_folder1 'API Test Folder 1' +Response +-------- +/api/libraries/f3f73e481f432006/contents/28202595c0d2591fa4f9089d2303fd89 + name: api_test_folder1 + id: 28202595c0d2591fa4f9089d2303fd89 + +% ./library_upload_from_import_dir.py my_key http://localhost:4096/api/libraries/f3f73e481f432006/contents 28202595c0d2591fa4f9089d2303fd89 bed bed hg19 +Response +-------- +/api/libraries/f3f73e481f432006/contents/e9ef7fdb2db87d7b + name: 2.bed + id: e9ef7fdb2db87d7b +/api/libraries/f3f73e481f432006/contents/3b7f6a31f80a5018 + name: 3.bed + id: 3b7f6a31f80a5018 + +% ./display.py my_key http://localhost:4096/api/libraries/f3f73e481f432006/contents +Collection Members +------------------ +/api/libraries/f3f73e481f432006/contents/28202595c0d2591f61ddda595d2c3670 + name: / + type: folder + id: 28202595c0d2591f61ddda595d2c3670 +/api/libraries/f3f73e481f432006/contents/28202595c0d2591fa4f9089d2303fd89 + name: /api_test_folder1 + type: folder + id: 28202595c0d2591fa4f9089d2303fd89 +/api/libraries/f3f73e481f432006/contents/e9ef7fdb2db87d7b + name: /api_test_folder1/2.bed + type: file + id: e9ef7fdb2db87d7b +/api/libraries/f3f73e481f432006/contents/3b7f6a31f80a5018 + name: /api_test_folder1/3.bed + type: file + id: 3b7f6a31f80a5018 + +% ./display.py my_key http://localhost:4096/api/libraries/f3f73e481f432006/contents/e9ef7fdb2db87d7b +Member Information +------------------ +misc_blurb: 68 regions +metadata_endCol: 3 +data_type: bed +metadata_columns: 6 +metadata_nameCol: 4 +uploaded_by: nate@... +metadata_strandCol: 6 +name: 2.bed +genome_build: hg19 +metadata_comment_lines: None +metadata_startCol: 2 +metadata_chromCol: 1 +file_size: 4272 +metadata_data_lines: 68 +message: +metadata_dbkey: hg19 +misc_info: uploaded bed file +date_uploaded: 2010-06-22T17:01:51.266119 +metadata_column_types: str, int, int, str, int, str + +Other parameters are valid when uploading, they are the same parameters as are +used in the web form, like 'link_data_only' and etc. + +The request and response format should be considered alpha and are subject to change. diff --git a/scripts/api/common.py b/scripts/api/common.py new file mode 100644 index 00000000000..5a83bf5d643 --- /dev/null +++ b/scripts/api/common.py @@ -0,0 +1,94 @@ +import os, sys, urllib, urllib2 + +new_path = [ os.path.join( os.path.dirname( __file__ ), '..', '..', 'lib' ) ] +new_path.extend( sys.path[1:] ) +sys.path = new_path + +from galaxy import eggs +import pkg_resources + +pkg_resources.require( "simplejson" ) +import simplejson + +def make_url( api_key, url, args=None ): + # Adds the API Key to the URL if it's not already there. + if args is None: + args = [] + argsep = '&' + if '?' not in url: + argsep = '?' + if '?key=' not in url and '&key=' not in url: + args.insert( 0, ( 'key', api_key ) ) + return url + argsep + '&'.join( [ '='.join( t ) for t in args ] ) + +def get( api_key, url ): + # Do the actual GET. + url = make_url( api_key, url ) + return simplejson.loads( urllib2.urlopen( url ).read() ) + +def post( api_key, url, data ): + # Do the actual POST. + url = make_url( api_key, url ) + req = urllib2.Request( url, headers = { 'Content-Type': 'application/json' }, data = simplejson.dumps( data ) ) + return simplejson.loads( urllib2.urlopen( req ).read() ) + +def display( api_key, url ): + # Sends an API GET request and acts as a generic formatter for the JSON response. + try: + r = get( api_key, url ) + except urllib2.HTTPError, e: + print e + print e.read( 1024 ) # Only return the first 1K of errors. + sys.exit( 1 ) + if type( r ) == unicode: + print 'error: %s' % r + return None + elif type( r ) == list: + # Response is a collection as defined in the REST style. + print 'Collection Members' + print '------------------' + for i in r: + # All collection members should have a name and url in the response. + print i.pop( 'url' ) + print ' name: %s' % i.pop( 'name' ) + for k, v in i.items(): + print ' %s: %s' % ( k, v ) + print '' + print '%d elements in collection' % len( r ) + elif type( r ) == dict: + # Response is an element as defined in the REST style. + print 'Member Information' + print '------------------' + for k, v in r.items(): + print '%s: %s' % ( k, v ) + else: + print 'response is unknown type: %s' % type( r ) + +def submit( api_key, url, data ): + # Sends an API POST request and acts as a generic formatter for the JSON response. + # 'data' will become the JSON payload read by Galaxy. + try: + r = post( api_key, url, data ) + except urllib2.HTTPError, e: + print e + print e.read( 1024 ) + sys.exit( 1 ) + print 'Response' + print '--------' + if type( r ) == list: + # Currently the only implemented responses are lists of dicts, because + # submission creates some number of collection elements. + for i in r: + if type( i ) == dict: + if 'url' in i: + print i.pop( 'url' ) + else: + print '----' + if 'name' in i: + print ' name: %s' % i.pop( 'name' ) + for k, v in i.items(): + print ' %s: %s' % ( k, v ) + else: + print i + else: + print r diff --git a/scripts/api/display.py b/scripts/api/display.py new file mode 100755 index 00000000000..d0ac26c3147 --- /dev/null +++ b/scripts/api/display.py @@ -0,0 +1,11 @@ +#!/usr/bin/python + +import os, sys +sys.path.insert( 0, os.path.dirname( __file__ ) ) +from common import display + +try: + display( *sys.argv[1:3] ) +except TypeError: + print 'usage: %s key url' % os.path.basename( sys.argv[0] ) + sys.exit( 1 ) diff --git a/scripts/api/library_create_folder.py b/scripts/api/library_create_folder.py new file mode 100755 index 00000000000..f392fa9f25d --- /dev/null +++ b/scripts/api/library_create_folder.py @@ -0,0 +1,20 @@ +#!/usr/bin/python + +import os, sys +sys.path.insert( 0, os.path.dirname( __file__ ) ) +from common import submit + +try: + data = {} + data[ 'folder_id' ] = sys.argv[3] + data[ 'name' ] = sys.argv[4] + data[ 'create_type' ] = 'folder' +except IndexError: + print 'usage: %s key url folder_id name [description]' % os.path.basename( sys.argv[0] ) + sys.exit( 1 ) +try: + data[ 'description' ] = sys.argv[5] +except IndexError: + data[ 'description' ] = '' + +submit( sys.argv[1], sys.argv[2], data ) diff --git a/scripts/api/library_upload_from_import_dir.py b/scripts/api/library_upload_from_import_dir.py new file mode 100755 index 00000000000..3ea7887e583 --- /dev/null +++ b/scripts/api/library_upload_from_import_dir.py @@ -0,0 +1,19 @@ +#!/usr/bin/python + +import os, sys +sys.path.insert( 0, os.path.dirname( __file__ ) ) +from common import submit + +try: + data = {} + data[ 'folder_id' ] = sys.argv[3] + data[ 'file_type' ] = sys.argv[4] + data[ 'server_dir' ] = sys.argv[5] + data[ 'dbkey' ] = sys.argv[6] + data[ 'upload_option' ] = 'upload_directory' + data[ 'create_type' ] = 'file' +except IndexError: + print 'usage: %s key url folder_id file_type server_dir dbkey' % os.path.basename( sys.argv[0] ) + sys.exit( 1 ) + +submit( sys.argv[1], sys.argv[2], data ) diff --git a/templates/webapps/galaxy/user/info.mako b/templates/webapps/galaxy/user/info.mako index 222eadbccbe..471b2a599a0 100644 --- a/templates/webapps/galaxy/user/info.mako +++ b/templates/webapps/galaxy/user/info.mako @@ -66,8 +66,9 @@ ${render_user_info()}

    %endif +

    +

    -

    User Addresses
    @@ -118,5 +119,37 @@ ${render_user_info()}
    -

    + +

    + +%if trans.app.config.enable_api: +

    +
    +
    Web API Key
    +
    +
    + + %if user.api_keys: + ${user.api_keys[0].key} + %else: + none set + %endif +
    +
    + + %if user.api_keys: + (invalidates old key) + %endif +
    + An API key will allow you to access Galaxy via its web + API (documentation forthcoming). Please note that + this key acts as an alternate means to access + your account, and should be treated with the same care + as your login password. +
    +
    +
    +
    +
    +%endif diff --git a/universe_wsgi.ini.sample b/universe_wsgi.ini.sample index d342bfc18f9..5eb2a9b186d 100644 --- a/universe_wsgi.ini.sample +++ b/universe_wsgi.ini.sample @@ -112,6 +112,9 @@ gbrowse_display_sites = wormbase,tair,modencode_worm,modencode_fly # Define your GeneTrack servers in tool-data/shared/genetrack/genetrack_sites.txt #genetrack_display_sites = +# Enable the (experimental! beta!) Web API. Documentation forthcoming. +#enable_api = False + # Serving static files (needed if running standalone) static_enabled = True static_cache_time = 360