diff --git a/components/tools/OmeroWeb/omeroweb/api/api_exceptions.py b/components/tools/OmeroWeb/omeroweb/api/api_exceptions.py index bfcfda7380c..607793140e8 100644 --- a/components/tools/OmeroWeb/omeroweb/api/api_exceptions.py +++ b/components/tools/OmeroWeb/omeroweb/api/api_exceptions.py @@ -20,7 +20,16 @@ """Exceptions used by the api/views methods.""" -class BadRequestError(Exception): +class ApiException(Exception): + """A base exception class that handles message and stactrace.""" + + def __init__(self, message, stacktrace=None): + """Override init to handle message and stacktrace.""" + super(ApiException, self).__init__(message) + self.stacktrace = stacktrace + + +class BadRequestError(ApiException): """ An exception that will result in a response status of 400. @@ -29,13 +38,8 @@ class BadRequestError(Exception): status = 400 - def __init__(self, message, stacktrace=None): - """Override init to handle message and stacktrace.""" - super(BadRequestError, self).__init__(message) - self.stacktrace = stacktrace - -class NotFoundError(Exception): +class NotFoundError(ApiException): """ An exception that will result in a response status of 404. @@ -44,10 +48,16 @@ class NotFoundError(Exception): status = 404 - def __init__(self, message, stacktrace=None): - """Override init to handle message and stacktrace.""" - super(NotFoundError, self).__init__(message) - self.stacktrace = stacktrace + +class MethodNotSupportedError(ApiException): + """ + An exception that will result in a response status of 405. + + Raised if user tries to DELETE, POST or PUT for an Object + where we don't support those methods. + """ + + status = 405 class CreatedObject(Exception): diff --git a/components/tools/OmeroWeb/omeroweb/api/decorators.py b/components/tools/OmeroWeb/omeroweb/api/decorators.py index 59718240112..dab11da2d8a 100644 --- a/components/tools/OmeroWeb/omeroweb/api/decorators.py +++ b/components/tools/OmeroWeb/omeroweb/api/decorators.py @@ -27,7 +27,10 @@ import traceback from django.http import JsonResponse from functools import update_wrapper -from api_exceptions import NotFoundError, BadRequestError, CreatedObject +from api_exceptions import BadRequestError, \ + CreatedObject, \ + MethodNotSupportedError, \ + NotFoundError logger = logging.getLogger(__name__) @@ -75,7 +78,7 @@ def handle_error(self, ex, trace): # But we try to handle all 'expected' errors appropriately # TODO: handle omero.ConcurrencyException status = 500 - if isinstance(ex, NotFoundError): + if isinstance(ex, (NotFoundError, MethodNotSupportedError)): status = ex.status if isinstance(ex, BadRequestError): status = ex.status diff --git a/components/tools/OmeroWeb/omeroweb/api/views.py b/components/tools/OmeroWeb/omeroweb/api/views.py index 45d978eda80..31df70e2567 100644 --- a/components/tools/OmeroWeb/omeroweb/api/views.py +++ b/components/tools/OmeroWeb/omeroweb/api/views.py @@ -32,8 +32,10 @@ from omero_marshal import get_encoder, get_decoder, OME_SCHEMA_URL from omero import ValidationException from omeroweb.connector import Server -from omeroweb.api.api_exceptions import BadRequestError, NotFoundError, \ - CreatedObject +from api_exceptions import BadRequestError, \ + CreatedObject, \ + MethodNotSupportedError, \ + NotFoundError from omeroweb.api.decorators import login_required, json_response from omeroweb.webgateway.util import getIntOrDefault @@ -149,6 +151,8 @@ def add_data(self, marshalled, request, urls=None, **kwargs): class ObjectView(ApiView): """Handle access to an individual Object to GET or DELETE it.""" + CAN_DELETE = True + def get_opts(self, request): """Return a dict for use in conn.getObjects() based on request.""" return {} @@ -184,6 +188,9 @@ def delete(self, request, object_id, conn=None, **kwargs): Return 404 if not found. """ + if not self.CAN_DELETE: + raise MethodNotSupportedError( + "Delete of %s not supported" % self.OMERO_TYPE) try: obj = conn.getQueryService().get(self.OMERO_TYPE, long(object_id), conn.SERVICE_OPTS) @@ -225,6 +232,8 @@ class ImageView(ObjectView): OMERO_TYPE = 'Image' + CAN_DELETE = False + def get_opts(self, request): """Add support for load_pixels and load_channels.""" opts = super(ImageView, self).get_opts(request) @@ -250,6 +259,8 @@ class PlateView(ObjectView): OMERO_TYPE = 'Plate' + CAN_DELETE = False + # Urls to add to marshalled object. See ProjectsView for more details urls = { 'url:wells': {'name': 'api_plate_wells', @@ -262,6 +273,8 @@ class WellView(ObjectView): OMERO_TYPE = 'Well' + CAN_DELETE = False + def get_opts(self, request): """Add support for load_images.""" opts = super(WellView, self).get_opts(request) @@ -498,12 +511,25 @@ class SaveView(View): POST to create a new Object and PUT to replace existing one. """ + CAN_PUT = ['Project', 'Dataset', 'Screen'] + + CAN_POST = ['Project', 'Dataset', 'Screen'] + @method_decorator(login_required(useragent='OMERO.webapi')) @method_decorator(json_response()) def dispatch(self, *args, **kwargs): """Apply decorators for class methods below.""" return super(SaveView, self).dispatch(*args, **kwargs) + def get_type_name(self, marshalled): + """Get the '@type' name from marshalled data.""" + if '@type' not in marshalled: + raise BadRequestError('Need to specify @type attribute') + schema_type = marshalled['@type'] + if '#' not in schema_type: + return None + return schema_type.split('#')[1] + def put(self, request, conn=None, **kwargs): """ PUT handles saving of existing objects. @@ -511,6 +537,10 @@ def put(self, request, conn=None, **kwargs): Therefore '@id' should be set. """ object_json = json.loads(request.body) + obj_type = self.get_type_name(object_json) + if obj_type not in self.CAN_PUT: + raise MethodNotSupportedError( + "Update of %s not supported" % obj_type) if '@id' not in object_json: raise BadRequestError( "No '@id' attribute. Use POST to create new objects") @@ -523,6 +553,10 @@ def post(self, request, conn=None, **kwargs): Therefore '@id' should not be set. """ object_json = json.loads(request.body) + obj_type = self.get_type_name(object_json) + if obj_type not in self.CAN_POST: + raise MethodNotSupportedError( + "Creation of %s not supported" % obj_type) if '@id' in object_json: raise BadRequestError( "Object has '@id' attribute. Use PUT to update objects") @@ -535,8 +569,6 @@ def _save_object(self, request, conn, object_json, **kwargs): # Try to get group from request, OR from details below... group = getIntOrDefault(request, 'group', None) decoder = None - if '@type' not in object_json: - raise BadRequestError('Need to specify @type attribute') objType = object_json['@type'] decoder = get_decoder(objType) # If we are passed incomplete object, or decoder couldn't be found... diff --git a/components/tools/OmeroWeb/test/integration/test_api_containers.py b/components/tools/OmeroWeb/test/integration/test_api_containers.py index baf0813d583..90555ebffa5 100644 --- a/components/tools/OmeroWeb/test/integration/test_api_containers.py +++ b/components/tools/OmeroWeb/test/integration/test_api_containers.py @@ -35,6 +35,7 @@ WellI, \ WellSampleI from omero.rtypes import rstring, rint +from omero_marshal import OME_SCHEMA_URL def build_url(client, url_name, url_kwargs): @@ -178,8 +179,41 @@ def user_screens(self, user1): screens.sort(cmp_name_insensitive) return screens + @pytest.mark.parametrize("dtype", ['Plate', 'Image', 'Well', + 'Channel', 'foo']) + @pytest.mark.parametrize("method", [(_csrf_post_json, 'Creation'), + (_csrf_put_json, 'Update')]) + def test_create_update_unsupported(self, user1, dtype, method): + """Test create and update are rejected for unsupported types.""" + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + save_url = reverse('api_save', kwargs={'api_version': version}) + payload = {'Name': 'test', + '@type': OME_SCHEMA_URL + '#%s' % dtype} + # Test PUT/POST + rsp = method[0](django_client, save_url, payload, + status_code=405) + assert rsp['message'] == '%s of %s not supported' % (method[1], dtype) + + @pytest.mark.parametrize("dtype", ['Plate', 'Image', 'Well']) + def test_delete_unsupported(self, user1, dtype): + """Test delete is rejected for unsupported types.""" + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + # Delete (fake url - image doesn't need to exist for test) + url_name = 'api_%s' % dtype.lower() + delete_url = reverse(url_name, kwargs={'api_version': version, + 'object_id': 1}) + rsp = _csrf_delete_response_json(django_client, delete_url, {}, + status_code=405) + assert rsp['message'] == 'Delete of %s not supported' % dtype + @pytest.mark.parametrize("dtype", ['Project', 'Dataset', - 'Screen', 'Plate']) + 'Screen']) def test_container_crud(self, dtype): """ Test create, read, update and delete of Containers. diff --git a/components/tools/OmeroWeb/test/integration/test_api_errors.py b/components/tools/OmeroWeb/test/integration/test_api_errors.py index 2774b643b85..d48d1680c5a 100644 --- a/components/tools/OmeroWeb/test/integration/test_api_errors.py +++ b/components/tools/OmeroWeb/test/integration/test_api_errors.py @@ -24,7 +24,7 @@ from django.conf import settings import pytest from test_api_projects import get_connection -from omero.model import ProjectI +from omero.model import ProjectI, TagAnnotationI from omero.rtypes import rstring from omero_marshal import get_encoder, get_decoder, OME_SCHEMA_URL from omero import ValidationException @@ -59,6 +59,7 @@ def test_save_post_no_id(self): version = settings.API_VERSIONS[-1] save_url = reverse('api_save', kwargs={'api_version': version}) payload = {'Name': 'test_save_post_no_id', + '@type': '%s#Project' % OME_SCHEMA_URL, '@id': 1} rsp = _csrf_post_json(django_client, save_url, payload, status_code=400) @@ -70,7 +71,8 @@ def test_save_put_id(self): django_client = self.django_root_client version = settings.API_VERSIONS[-1] save_url = reverse('api_save', kwargs={'api_version': version}) - payload = {'Name': 'test_save_put_id'} + payload = {'Name': 'test_save_put_id', + '@type': '%s#Project' % OME_SCHEMA_URL} rsp = _csrf_put_json(django_client, save_url, payload, status_code=400) assert (rsp['message'] == @@ -81,7 +83,7 @@ def test_marshal_type(self): django_client = self.django_root_client version = settings.API_VERSIONS[-1] save_url = reverse('api_save', kwargs={'api_version': version}) - objType = 'SomeInvalid#Type' + objType = 'SomeInvalidSchema#Project' payload = {'Name': 'test_marshal_type', '@type': objType} rsp = _csrf_post_json(django_client, save_url, payload, @@ -142,16 +144,16 @@ def test_validation_exception(self, user_A): save_url += '?group=' + str(group) # Create Tag - tag = {'Value': 'test_tag', - '@type': OME_SCHEMA_URL + '#TagAnnotation'} - rsp = _csrf_post_json(django_client, save_url, tag, - status_code=201) - tag_rsp = rsp['data'] + tag = TagAnnotationI() + tag.textValue = rstring('test_tag') + tag = conn.getUpdateService().saveAndReturnObject(tag) + tag_json = {'Value': 'test_tag', + '@id': tag.id.val, + '@type': OME_SCHEMA_URL + '#TagAnnotation'} # Add Tag twice to Project to get Validation Exception - del tag_rsp['omero:details'] payload = {'Name': 'test_validation', '@type': OME_SCHEMA_URL + '#Project', - 'Annotations': [tag_rsp, tag_rsp]} + 'Annotations': [tag_json, tag_json]} rsp = _csrf_post_json(django_client, save_url, payload, status_code=400) # NB: message contains whole stack trace