class TestHateoas(Test): hateoas = Hateoas() @with_context def setUp(self): super(TestHateoas, self).setUp() project = ProjectFactory.create(published=True, id=1) task = TaskFactory.create(id=1, project=project) TaskRunFactory.create(project=project, task=task) # Tests @with_context @patch('pybossa.api.task.TaskAPI._verify_auth') def test_00_link_object(self, auth): """Test HATEOAS object link is created""" user = UserFactory.create(admin=True) auth.return_value = True # For project res = self.app.get('/api/project/1?api_key=' + user.api_key, follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg err_msg = "There should be a Links list with the category URI" assert output['links'] is not None, err_msg assert len(output['links']) == 1, err_msg project_link = self.hateoas.link(rel='category', title='category', href='http://{}/api/category/1'.format(self.flask_app.config['SERVER_NAME'])) assert project_link == output['links'][0], (project_link, output['links'][0], err_msg) project_link = self.hateoas.link(rel='self', title='project', href='http://{}/api/project/1'.format(self.flask_app.config['SERVER_NAME'])) err_msg = "The object link is wrong: %s" % output['link'] assert project_link == output['link'], err_msg # For task res = self.app.get('/api/task/1?api_key=' + user.api_key, follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='task', href='http://{}/api/task/1'.format(self.flask_app.config['SERVER_NAME'])) err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be one parent link: project" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 1, err_msg err_msg = "The parent link is wrong" project_link = self.hateoas.link(rel='parent', title='project', href='http://{}/api/project/1'.format(self.flask_app.config['SERVER_NAME'])) assert output.get('links')[0] == project_link, err_msg # For taskrun res = self.app.get('/api/taskrun/1?api_key=' + user.api_key, follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='taskrun', href='http://{}/api/taskrun/1'.format(self.flask_app.config['SERVER_NAME'])) err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be two parent links: project and task" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 2, err_msg err_msg = "The parent project link is wrong" project_link = self.hateoas.link(rel='parent', title='project', href='http://{}/api/project/1'.format(self.flask_app.config['SERVER_NAME'])) assert output.get('links')[0] == project_link, err_msg err_msg = "The parent task link is wrong" project_link = self.hateoas.link(rel='parent', title='task', href='http://{}/api/task/1'.format(self.flask_app.config['SERVER_NAME'])) assert output.get('links')[1] == project_link, err_msg res = self.app.post("/api/taskrun") # For category res = self.app.get("/api/category/1", follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg category_link = self.hateoas.link(rel='self', title='category', href='http://{}/api/category/1'.format(self.flask_app.config['SERVER_NAME'])) err_msg = "The object link is wrong: %s" % output['link'] assert category_link == output['link'], err_msg err_msg = "There should be no other links" assert output.get('links') is None, err_msg err_msg = "The object links should are wrong" # For user # Pending define what user fields will be visible through the API # Issue #626. For now let's suppose link and links are not visible # res = self.app.get("/api/user/1?api_key=" + self.root_api_key, follow_redirects=True) # output = json.loads(res.data) # err_msg = "There should be a Link with the object URI" # assert output['link'] is not None, err_msg # user_link = self.hateoas.link(rel='self', title='user', # href='http://{}/api/user/1') # err_msg = "The object link ir wrong: %s" % output['link'] # assert user_link == output['link'], err_msg # # when the links specification of a user will be set, modify the following # err_msg = "The list of links should be empty for now" # assert output.get('links') == None, err_msg @with_context @patch('pybossa.api.task.TaskAPI._verify_auth') def test_01_link_object(self, auth): """Test HATEOAS object link is created""" # For project user = UserFactory.create(admin=True) auth.return_value = True res = self.app.get('/api/project?all=1&api_key=' + user.api_key, follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg project_link = self.hateoas.link(rel='self', title='project', href='http://{}/api/project/1'.format(self.flask_app.config['SERVER_NAME'])) err_msg = "The object link is wrong: %s" % output['link'] assert project_link == output['link'], err_msg err_msg = "There should be a Links list with the category URI" assert output['links'] is not None, err_msg assert len(output['links']) == 1, err_msg project_link = self.hateoas.link(rel='category', title='category', href='http://{}/api/category/1'.format(self.flask_app.config['SERVER_NAME'])) assert project_link == output['links'][0], err_msg # For task res = self.app.get('/api/task?all=1&api_key=' + user.api_key, follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='task', href='http://{}/api/task/1'.format(self.flask_app.config['SERVER_NAME'])) err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be one parent link: project" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 1, err_msg err_msg = "The parent link is wrong" project_link = self.hateoas.link(rel='parent', title='project', href='http://{}/api/project/1'.format(self.flask_app.config['SERVER_NAME'])) assert output.get('links')[0] == project_link, project_link # For taskrun res = self.app.get('/api/taskrun?all=1&api_key=' + user.api_key, follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='taskrun', href='http://{}/api/taskrun/1'.format(self.flask_app.config['SERVER_NAME'])) err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be two parent links: project and task" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 2, err_msg err_msg = "The parent project link is wrong" project_link = self.hateoas.link(rel='parent', title='project', href='http://{}/api/project/1'.format(self.flask_app.config['SERVER_NAME'])) assert output.get('links')[0] == project_link, err_msg err_msg = "The parent task link is wrong" project_link = self.hateoas.link(rel='parent', title='task', href='http://{}/api/task/1'.format(self.flask_app.config['SERVER_NAME'])) assert output.get('links')[1] == project_link, err_msg # Check that hateoas removes all link and links from item without_links = self.hateoas.remove_links(output) err_msg = "There should not be any link or links keys" assert without_links.get('link') is None, err_msg assert without_links.get('links') is None, err_msg # For category res = self.app.get("/api/category", follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg category_link = self.hateoas.link(rel='self', title='category', href='http://{}/api/category/1'.format(self.flask_app.config['SERVER_NAME'])) err_msg = "The object link is wrong: %s" % output['link'] assert category_link == output['link'], err_msg err_msg = "There should be no other links" assert output.get('links') is None, err_msg err_msg = "The object links should are wrong"
class APIBase(MethodView): """Class to create CRUD methods.""" hateoas = Hateoas() allowed_classes_upload = ['blogpost', 'helpingmaterial', 'announcement'] def refresh_cache(self, cls_name, oid): """Refresh the cache.""" if caching.get(cls_name): caching.get(cls_name)['refresh'](oid) def valid_args(self): """Check if the domain object args are valid.""" for k in request.args.keys(): if k not in ['api_key']: getattr(self.__class__, k) def options(self, **kwargs): # pragma: no cover """Return '' for Options method.""" return '' @jsonpify @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def get(self, oid): """Get an object. Returns an item from the DB with the request.data JSON object or all the items if oid == None :arg self: The class of the object to be retrieved :arg integer oid: the ID of the object in the DB :returns: The JSON item/s stored in the DB """ try: ensure_authorized_to('read', self.__class__) query = self._db_query(oid) json_response = self._create_json_response(query, oid) return Response(json_response, mimetype='application/json') except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='GET') def _create_json_response(self, query_result, oid): if len(query_result) == 1 and query_result[0] is None: raise abort(404) items = [] for result in query_result: # This is for n_favs orderby case if not isinstance(result, DomainObject): if 'n_favs' in result.keys(): result = result[0] try: if (result.__class__ != self.__class__): (item, headline, rank) = result else: item = result headline = None rank = None datum = self._create_dict_from_model(item) if headline: datum['headline'] = headline if rank: datum['rank'] = rank ensure_authorized_to('read', item) items.append(datum) except (Forbidden, Unauthorized): # Remove last added item, as it is 401 or 403 if len(items) > 0: items.pop() except Exception: # pragma: no cover raise if oid is not None: ensure_authorized_to('read', query_result[0]) items = items[0] return json.dumps(items) def _create_dict_from_model(self, model): return self._select_attributes(self._add_hateoas_links(model)) def _add_hateoas_links(self, item): obj = item.dictize() related = request.args.get('related') if related: if item.__class__.__name__ == 'Task': obj['task_runs'] = [] obj['result'] = None task_runs = task_repo.filter_task_runs_by(task_id=item.id) results = result_repo.filter_by(task_id=item.id, last_version=True) for tr in task_runs: obj['task_runs'].append(tr.dictize()) for r in results: obj['result'] = r.dictize() if item.__class__.__name__ == 'TaskRun': tasks = task_repo.filter_tasks_by(id=item.task_id) results = result_repo.filter_by(task_id=item.task_id, last_version=True) obj['task'] = None obj['result'] = None for t in tasks: obj['task'] = t.dictize() for r in results: obj['result'] = r.dictize() if item.__class__.__name__ == 'Result': tasks = task_repo.filter_tasks_by(id=item.task_id) task_runs = task_repo.filter_task_runs_by(task_id=item.task_id) obj['task_runs'] = [] for t in tasks: obj['task'] = t.dictize() for tr in task_runs: obj['task_runs'].append(tr.dictize()) links, link = self.hateoas.create_links(item) if links: obj['links'] = links if link: obj['link'] = link return obj def _db_query(self, oid): """Returns a list with the results of the query""" repo_info = repos[self.__class__.__name__] if oid is None: limit, offset, orderby = self._set_limit_and_offset() results = self._filter_query(repo_info, limit, offset, orderby) else: repo = repo_info['repo'] query_func = repo_info['get'] results = [getattr(repo, query_func)(oid)] return results def api_context(self, all_arg, **filters): if current_user.is_authenticated(): filters['owner_id'] = current_user.id if filters.get('owner_id') and all_arg == '1': del filters['owner_id'] return filters def _filter_query(self, repo_info, limit, offset, orderby): filters = {} for k in request.args.keys(): if k not in [ 'limit', 'offset', 'api_key', 'last_id', 'all', 'fulltextsearch', 'desc', 'orderby', 'related', 'participated', 'full' ]: # Raise an error if the k arg is not a column if self.__class__ == Task and k == 'external_uid': pass else: getattr(self.__class__, k) filters[k] = request.args[k] repo = repo_info['repo'] filters = self.api_context(all_arg=request.args.get('all'), **filters) query_func = repo_info['filter'] filters = self._custom_filter(filters) last_id = request.args.get('last_id') if request.args.get('participated'): filters['participated'] = get_user_id_or_ip() fulltextsearch = request.args.get('fulltextsearch') desc = request.args.get('desc') if request.args.get('desc') else False desc = fuzzyboolean(desc) if last_id: results = getattr(repo, query_func)(limit=limit, last_id=last_id, fulltextsearch=fulltextsearch, desc=False, orderby=orderby, **filters) else: results = getattr(repo, query_func)(limit=limit, offset=offset, fulltextsearch=fulltextsearch, desc=desc, orderby=orderby, **filters) return results def _set_limit_and_offset(self): try: limit = min(100, int(request.args.get('limit'))) except (ValueError, TypeError): limit = 20 try: offset = int(request.args.get('offset')) except (ValueError, TypeError): offset = 0 try: orderby = request.args.get('orderby') if request.args.get( 'orderby') else 'id' except (ValueError, TypeError): orderby = 'updated' return limit, offset, orderby @jsonpify @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def post(self): """Post an item to the DB with the request.data JSON object. :arg self: The class of the object to be inserted :returns: The JSON item stored in the DB """ try: cls_name = self.__class__.__name__ self.valid_args() data = self._file_upload(request) if data is None: data = json.loads(request.data) self._forbidden_attributes(data) inst = self._create_instance_from_request(data) repo = repos[self.__class__.__name__]['repo'] save_func = repos[self.__class__.__name__]['save'] getattr(repo, save_func)(inst) self._log_changes(None, inst) self.refresh_cache(cls_name, inst.id) return json.dumps(inst.dictize()) except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='POST') def _create_instance_from_request(self, data): data = self.hateoas.remove_links(data) inst = self.__class__(**data) self._update_object(inst) ensure_authorized_to('create', inst) self._validate_instance(inst) return inst @jsonpify @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def delete(self, oid): """Delete a single item from the DB. :arg self: The class of the object to be deleted :arg integer oid: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.7>`_. """ try: self.valid_args() self._delete_instance(oid) cls_name = self.__class__.__name__ self.refresh_cache(cls_name, oid) return '', 204 except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='DELETE') def _delete_instance(self, oid): repo = repos[self.__class__.__name__]['repo'] query_func = repos[self.__class__.__name__]['get'] inst = getattr(repo, query_func)(oid) if inst is None: raise NotFound ensure_authorized_to('delete', inst) self._file_delete(request, inst) self._log_changes(inst, None) delete_func = repos[self.__class__.__name__]['delete'] getattr(repo, delete_func)(inst) return inst @jsonpify @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def put(self, oid): """Update a single item in the DB. :arg self: The class of the object to be updated :arg integer oid: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.6>`_. """ try: self.valid_args() cls_name = self.__class__.__name__ repo = repos[cls_name]['repo'] query_func = repos[cls_name]['get'] existing = getattr(repo, query_func)(oid) if existing is None: raise NotFound ensure_authorized_to('update', existing) data = self._file_upload(request) inst = self._update_instance(existing, repo, repos, new_upload=data) self.refresh_cache(cls_name, oid) return Response(json.dumps(inst.dictize()), 200, mimetype='application/json') except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='PUT') def _update_instance(self, existing, repo, repos, new_upload=None): data = dict() if new_upload is None: data = json.loads(request.data) self._forbidden_attributes(data) # Remove hateoas links data = self.hateoas.remove_links(data) else: self._forbidden_attributes(request.form) # may be missing the id as we allow partial updates self.__class__(**data) old = self.__class__(**existing.dictize()) for key in data: setattr(existing, key, data[key]) if new_upload: existing.media_url = new_upload['media_url'] existing.info['container'] = new_upload['info']['container'] existing.info['file_name'] = new_upload['info']['file_name'] self._update_attribute(existing, old) update_func = repos[self.__class__.__name__]['update'] self._validate_instance(existing) getattr(repo, update_func)(existing) self._log_changes(old, existing) return existing def _update_object(self, data_dict): """Update object. Method to be overriden in inheriting classes which wish to update data dict. """ pass def _update_attribute(self, new, old): """Update object attribute if new value is passed. Method to be overriden in inheriting classes which wish to update data dict. """ def _select_attributes(self, item_data): """Method to be overriden in inheriting classes in case it is not desired that every object attribute is returned by the API. """ return item_data def _custom_filter(self, query): """Method to be overriden in inheriting classes which wish to consider specific filtering criteria. """ return query def _validate_instance(self, instance): """Method to be overriden in inheriting classes which may need to validate the creation (POST) or modification (PUT) of a domain object for reasons other than business logic ones (e.g. overlapping of a project name witht a URL). """ pass def _log_changes(self, old_obj, new_obj): """Method to be overriden by inheriting classes for logging purposes""" pass def _forbidden_attributes(self, data): """Method to be overriden by inheriting classes that will not allow for certain fields to be used in PUT or POST requests""" pass def _file_upload(self, data): """Method that must be overriden by the class to allow file uploads for only a few classes.""" cls_name = self.__class__.__name__.lower() content_type = 'multipart/form-data' if (content_type in request.headers.get('Content-Type') and cls_name in self.allowed_classes_upload): tmp = dict() for key in request.form.keys(): tmp[key] = request.form[key] if isinstance(self, announcement.Announcement): # don't check project id for announcements ensure_authorized_to('create', self) upload_method = current_app.config.get('UPLOAD_METHOD') if request.files.get('file') is None: raise AttributeError _file = request.files['file'] container = "user_%s" % current_user.id else: ensure_authorized_to('create', self.__class__, project_id=tmp['project_id']) project = project_repo.get(tmp['project_id']) upload_method = current_app.config.get('UPLOAD_METHOD') if request.files.get('file') is None: raise AttributeError _file = request.files['file'] if current_user.admin: container = "user_%s" % project.owner.id else: container = "user_%s" % current_user.id uploader.upload_file(_file, container=container) file_url = get_avatar_url(upload_method, _file.filename, container) tmp['media_url'] = file_url if tmp.get('info') is None: tmp['info'] = dict() tmp['info']['container'] = container tmp['info']['file_name'] = _file.filename return tmp else: return None def _file_delete(self, request, obj): """Delete file object.""" cls_name = self.__class__.__name__.lower() if cls_name in self.allowed_classes_upload: keys = obj.info.keys() if 'file_name' in keys and 'container' in keys: ensure_authorized_to('delete', obj) uploader.delete_file(obj.info['file_name'], obj.info['container'])
class APIBase(MethodView): """ Class to create CRUD methods for all the items: applications, tasks and task runs. """ hateoas = Hateoas() error_status = {"Forbidden": 403, "NotFound": 404, "Unauthorized": 401, "TypeError": 415, "ValueError": 415, "DataError": 415, "AttributeError": 415, "IntegrityError": 415} def valid_args(self): for k in request.args.keys(): if k not in ['api_key']: getattr(self.__class__, k) def format_exception(self, e, action): """Formats the exception to a valid JSON object""" exception_cls = e.__class__.__name__ if self.error_status.get(exception_cls): status = self.error_status.get(exception_cls) else: status = 200 error = dict(action=action, status="failed", status_code=status, target=self.__class__.__name__.lower(), exception_cls=exception_cls, exception_msg=e.message) return Response(json.dumps(error), status=status, mimetype='application/json') @crossdomain(origin='*', headers=cors_headers) def options(self): return '' @jsonpify @crossdomain(origin='*', headers=cors_headers) def get(self, id): """ Returns an item from the DB with the request.data JSON object or all the items if id == None :arg self: The class of the object to be retrieved :arg integer id: the ID of the object in the DB :returns: The JSON item/s stored in the DB """ try: getattr(require, self.__class__.__name__.lower()).read() if id is None: query = db.session.query(self.__class__) for k in request.args.keys(): if k not in ['limit', 'offset', 'api_key']: # Raise an error if the k arg is not a column getattr(self.__class__, k) query = query.filter(getattr(self.__class__, k) == request.args[k]) try: limit = min(10000, int(request.args.get('limit'))) except (ValueError, TypeError): limit = 20 try: offset = int(request.args.get('offset')) except (ValueError, TypeError): offset = 0 query = query.order_by(self.__class__.id) query = query.limit(limit) query = query.offset(offset) items = [] for item in query.all(): obj = item.dictize() links, link = self.hateoas.create_links(item) if links: obj['links'] = links if link: obj['link'] = link items.append(obj) return Response(json.dumps(items), mimetype='application/json') else: item = db.session.query(self.__class__).get(id) if item is None: abort(404) else: obj = item.dictize() links, link = self.hateoas.create_links(item) if links: obj['links'] = links if link: obj['link'] = link return Response(json.dumps(obj), mimetype='application/json') except Exception as e: return self.format_exception(e, action='GET') @jsonpify @crossdomain(origin='*', headers=cors_headers) def post(self): """ Adds an item to the DB with the request.data JSON object :arg self: The class of the object to be inserted :returns: The JSON item stored in the DB """ try: self.valid_args() data = json.loads(request.data) # Clean HATEOAS args data = self.hateoas.remove_links(data) inst = self.__class__(**data) getattr(require, self.__class__.__name__.lower()).create(inst) self._update_object(inst) db.session.add(inst) db.session.commit() return json.dumps(inst.dictize()) except Exception as e: return self.format_exception(e, action='POST') @jsonpify @crossdomain(origin='*', headers=cors_headers) def delete(self, id): """ Deletes a single item from the DB :arg self: The class of the object to be deleted :arg integer id: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.7>`_. """ try: self.valid_args() item = db.session.query(self.__class__).get(id) if item is None: raise NotFound getattr(require, self.__class__.__name__.lower()).delete(item) db.session.delete(item) db.session.commit() return '', 204 except Exception as e: return self.format_exception(e, action='DELETE') @jsonpify @crossdomain(origin='*', headers=cors_headers) def put(self, id): """ Updates a single item in the DB :arg self: The class of the object to be updated :arg integer id: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.6>`_. """ try: self.valid_args() existing = db.session.query(self.__class__).get(id) if existing is None: raise NotFound getattr(require, self.__class__.__name__.lower()).update(existing) data = json.loads(request.data) # may be missing the id as we allow partial updates data['id'] = id # Clean HATEOAS args data = self.hateoas.remove_links(data) inst = self.__class__(**data) db.session.merge(inst) db.session.commit() return Response(json.dumps(inst.dictize()), 200, mimetype='application/json') except Exception as e: return self.format_exception(e, 'PUT') def _update_object(self, data_dict): '''Method to be overriden in inheriting classes which wish to update data dict.''' pass
class APIBase(MethodView): """Class to create CRUD methods.""" hateoas = Hateoas() slave_session = get_session(db, bind='slave') def valid_args(self): """Check if the domain object args are valid.""" for k in request.args.keys(): if k not in ['api_key']: getattr(self.__class__, k) @crossdomain(origin='*', headers=cors_headers) def options(self): # pragma: no cover """Return '' for Options method.""" return '' @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def get(self, id): """Get an object. Returns an item from the DB with the request.data JSON object or all the items if id == None :arg self: The class of the object to be retrieved :arg integer id: the ID of the object in the DB :returns: The JSON item/s stored in the DB """ try: getattr(require, self.__class__.__name__.lower()).read() query = self._db_query(id) json_response = self._create_json_response(query, id) return Response(json_response, mimetype='application/json') except Exception as e: self.slave_session.rollback() return error.format_exception( e, target=self.__class__.__name__.lower(), action='GET') finally: self.slave_session.close() def _create_json_response(self, query_result, id): if len(query_result) == 1 and query_result[0] is None: raise abort(404) items = [] for item in query_result: try: items.append(self._create_dict_from_model(item)) getattr(require, self.__class__.__name__.lower()).read(item) except (Forbidden, Unauthorized): # Remove last added item, as it is 401 or 403 items.pop() except: # pragma: no cover raise if id: getattr(require, self.__class__.__name__.lower()).read(query_result[0]) items = items[0] return json.dumps(items) def _create_dict_from_model(self, model): return self._select_attributes(self._add_hateoas_links(model)) def _add_hateoas_links(self, item): obj = item.dictize() links, link = self.hateoas.create_links(item) if links: obj['links'] = links if link: obj['link'] = link return obj def _db_query(self, id): """ Returns a list with the results of the query""" query = self.slave_session.query(self.__class__) if id is None: limit, offset = self._set_limit_and_offset() query = self._filter_query(query, limit, offset) else: query = [query.get(id)] return query def _filter_query(self, query, limit, offset): for k in request.args.keys(): if k not in ['limit', 'offset', 'api_key']: # Raise an error if the k arg is not a column getattr(self.__class__, k) query = query.filter( getattr(self.__class__, k) == request.args[k]) query = self._custom_filter(query) return self._format_query_result(query, limit, offset) def _format_query_result(self, query, limit, offset): query = query.order_by(self.__class__.id) query = query.limit(limit) query = query.offset(offset) return query.all() def _set_limit_and_offset(self): try: limit = min(100, int(request.args.get('limit'))) except (ValueError, TypeError): limit = 20 try: offset = int(request.args.get('offset')) except (ValueError, TypeError): offset = 0 return limit, offset @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def post(self): """Post an item to the DB with the request.data JSON object. :arg self: The class of the object to be inserted :returns: The JSON item stored in the DB """ try: self.valid_args() data = json.loads(request.data) # Clean HATEOAS args inst = self._create_instance_from_request(data) db.session.add(inst) db.session.commit() return json.dumps(inst.dictize()) except IntegrityError: db.session.rollback() raise except Exception as e: db.session.rollback() return error.format_exception( e, target=self.__class__.__name__.lower(), action='POST') def _create_instance_from_request(self, data): data = self.hateoas.remove_links(data) inst = self.__class__(**data) self._update_object(inst) getattr(require, self.__class__.__name__.lower()).create(inst) return inst @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def delete(self, id): """Delete a single item from the DB. :arg self: The class of the object to be deleted :arg integer id: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.7>`_. """ try: self.valid_args() inst = self._delete_instance(id) self._refresh_cache(inst) return '', 204 except Exception as e: db.session.rollback() return error.format_exception( e, target=self.__class__.__name__.lower(), action='DELETE') def _delete_instance(self, id): inst = db.session.query(self.__class__).get(id) if inst is None: raise NotFound getattr(require, self.__class__.__name__.lower()).delete(inst) db.session.delete(inst) db.session.commit() return inst @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def put(self, id): """Update a single item in the DB. :arg self: The class of the object to be updated :arg integer id: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.6>`_. """ try: self.valid_args() inst = self._update_instance(id) self._refresh_cache(inst) return Response(json.dumps(inst.dictize()), 200, mimetype='application/json') except IntegrityError: db.session.rollback() raise except Exception as e: db.session.rollback() return error.format_exception( e, target=self.__class__.__name__.lower(), action='PUT') def _update_instance(self, id): existing = db.session.query(self.__class__).get(id) if existing is None: raise NotFound getattr(require, self.__class__.__name__.lower()).update(existing) data = json.loads(request.data) # may be missing the id as we allow partial updates data['id'] = id # Clean HATEOAS args data = self.hateoas.remove_links(data) inst = self.__class__(**data) db.session.merge(inst) db.session.commit() return inst def _update_object(self, data_dict): """Update object. Method to be overriden in inheriting classes which wish to update data dict. """ pass def _refresh_cache(self, data_dict): """Refresh cache. Method to be overriden in inheriting classes which wish to refresh cache for given object. """ pass def _select_attributes(self, item_data): """Method to be overriden in inheriting classes in case it is not desired that every object attribute is returned by the API """ return item_data def _custom_filter(self, query): """Method to be overriden in inheriting classes which wish to consider specific filtering criteria """ return query
class TestHateoas(Test): hateoas = Hateoas() def setUp(self): super(TestHateoas, self).setUp() with self.flask_app.app_context(): self.create() # Tests def test_00_link_object(self): """Test HATEOAS object link is created""" # For project res = self.app.get("/api/project/1", follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg err_msg = "There should be a Links list with the category URI" assert output['links'] is not None, err_msg assert len(output['links']) == 1, err_msg project_link = self.hateoas.link( rel='category', title='category', href='http://localhost/api/category/1') assert project_link == output['links'][0], err_msg project_link = self.hateoas.link(rel='self', title='project', href='http://localhost/api/project/1') err_msg = "The object link is wrong: %s" % output['link'] assert project_link == output['link'], err_msg # For task res = self.app.get("/api/task/1", follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='task', href='http://localhost/api/task/1') err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be one parent link: project" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 1, err_msg err_msg = "The parent link is wrong" project_link = self.hateoas.link(rel='parent', title='project', href='http://localhost/api/project/1') assert output.get('links')[0] == project_link, err_msg # For taskrun res = self.app.get("/api/taskrun/1", follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='taskrun', href='http://localhost/api/taskrun/1') err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be two parent links: project and task" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 2, err_msg err_msg = "The parent project link is wrong" project_link = self.hateoas.link(rel='parent', title='project', href='http://localhost/api/project/1') assert output.get('links')[0] == project_link, err_msg err_msg = "The parent task link is wrong" project_link = self.hateoas.link(rel='parent', title='task', href='http://localhost/api/task/1') assert output.get('links')[1] == project_link, err_msg res = self.app.post("/api/taskrun") # For category res = self.app.get("/api/category/1", follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg category_link = self.hateoas.link( rel='self', title='category', href='http://localhost/api/category/1') err_msg = "The object link is wrong: %s" % output['link'] assert category_link == output['link'], err_msg err_msg = "There should be no other links" assert output.get('links') is None, err_msg err_msg = "The object links should are wrong" # For user # Pending define what user fields will be visible through the API # Issue #626. For now let's suppose link and links are not visible # res = self.app.get("/api/user/1?api_key=" + self.root_api_key, follow_redirects=True) # output = json.loads(res.data) # err_msg = "There should be a Link with the object URI" # assert output['link'] is not None, err_msg # user_link = self.hateoas.link(rel='self', title='user', # href='http://localhost/api/user/1') # err_msg = "The object link ir wrong: %s" % output['link'] # assert user_link == output['link'], err_msg # # when the links specification of a user will be set, modify the following # err_msg = "The list of links should be empty for now" # assert output.get('links') == None, err_msg def test_01_link_object(self): """Test HATEOAS object link is created""" # For project res = self.app.get("/api/project", follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg project_link = self.hateoas.link(rel='self', title='project', href='http://localhost/api/project/1') err_msg = "The object link is wrong: %s" % output['link'] assert project_link == output['link'], err_msg err_msg = "There should be a Links list with the category URI" assert output['links'] is not None, err_msg assert len(output['links']) == 1, err_msg project_link = self.hateoas.link( rel='category', title='category', href='http://localhost/api/category/1') assert project_link == output['links'][0], err_msg # For task res = self.app.get("/api/task", follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='task', href='http://localhost/api/task/1') err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be one parent link: project" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 1, err_msg err_msg = "The parent link is wrong" project_link = self.hateoas.link(rel='parent', title='project', href='http://localhost/api/project/1') assert output.get('links')[0] == project_link, project_link # For taskrun res = self.app.get("/api/taskrun", follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='taskrun', href='http://localhost/api/taskrun/1') err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be two parent links: project and task" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 2, err_msg err_msg = "The parent project link is wrong" project_link = self.hateoas.link(rel='parent', title='project', href='http://localhost/api/project/1') assert output.get('links')[0] == project_link, err_msg err_msg = "The parent task link is wrong" project_link = self.hateoas.link(rel='parent', title='task', href='http://localhost/api/task/1') assert output.get('links')[1] == project_link, err_msg # Check that hateoas removes all link and links from item without_links = self.hateoas.remove_links(output) err_msg = "There should not be any link or links keys" assert without_links.get('link') is None, err_msg assert without_links.get('links') is None, err_msg # For category res = self.app.get("/api/category", follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg category_link = self.hateoas.link( rel='self', title='category', href='http://localhost/api/category/1') err_msg = "The object link is wrong: %s" % output['link'] assert category_link == output['link'], err_msg err_msg = "There should be no other links" assert output.get('links') is None, err_msg err_msg = "The object links should are wrong"
class APIBase(MethodView): """Class to create CRUD methods.""" hateoas = Hateoas() def valid_args(self): """Check if the domain object args are valid.""" for k in request.args.keys(): if k not in ['api_key']: getattr(self.__class__, k) @crossdomain(origin='*', headers=cors_headers) def options(self): # pragma: no cover """Return '' for Options method.""" return '' @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def get(self, oid): """Get an object. Returns an item from the DB with the request.data JSON object or all the items if oid == None :arg self: The class of the object to be retrieved :arg integer oid: the ID of the object in the DB :returns: The JSON item/s stored in the DB """ try: ensure_authorized_to('read', self.__class__) query = self._db_query(oid) json_response = self._create_json_response(query, oid) return Response(json_response, mimetype='application/json') except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='GET') def _create_json_response(self, query_result, oid): if len(query_result) == 1 and query_result[0] is None: raise abort(404) items = [] for item in query_result: try: items.append(self._create_dict_from_model(item)) ensure_authorized_to('read', item) except (Forbidden, Unauthorized): # Remove last added item, as it is 401 or 403 items.pop() except Exception: # pragma: no cover raise if oid is not None: ensure_authorized_to('read', query_result[0]) items = items[0] return json.dumps(items) def _create_dict_from_model(self, model): return self._select_attributes(self._add_hateoas_links(model)) def _add_hateoas_links(self, item): obj = item.dictize() links, link = self.hateoas.create_links(item) if links: obj['links'] = links if link: obj['link'] = link return obj def _db_query(self, oid): """Returns a list with the results of the query""" repo_info = repos[self.__class__.__name__] if oid is None: limit, offset = self._set_limit_and_offset() results = self._filter_query(repo_info, limit, offset) else: repo = repo_info['repo'] query_func = repo_info['get'] results = [getattr(repo, query_func)(oid)] return results def api_context(self, all_arg, **filters): if current_user.is_authenticated(): filters['owner_id'] = current_user.id if filters.get('owner_id') and all_arg == '1': del filters['owner_id'] return filters def _filter_query(self, repo_info, limit, offset): filters = {} for k in request.args.keys(): if k not in [ 'limit', 'offset', 'api_key', 'last_id', 'all', 'fulltextsearch' ]: # Raise an error if the k arg is not a column getattr(self.__class__, k) filters[k] = request.args[k] repo = repo_info['repo'] filters = self.api_context(all_arg=request.args.get('all'), **filters) query_func = repo_info['filter'] filters = self._custom_filter(filters) last_id = request.args.get('last_id') fulltextsearch = request.args.get('fulltextsearch') if last_id: results = getattr(repo, query_func)(limit=limit, last_id=last_id, fulltextsearch=fulltextsearch, **filters) else: results = getattr(repo, query_func)(limit=limit, offset=offset, fulltextsearch=fulltextsearch, **filters) return results def _set_limit_and_offset(self): try: limit = min(100, int(request.args.get('limit'))) except (ValueError, TypeError): limit = 20 try: offset = int(request.args.get('offset')) except (ValueError, TypeError): offset = 0 return limit, offset @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def post(self): """Post an item to the DB with the request.data JSON object. :arg self: The class of the object to be inserted :returns: The JSON item stored in the DB """ try: self.valid_args() data = json.loads(request.data) self._forbidden_attributes(data) inst = self._create_instance_from_request(data) repo = repos[self.__class__.__name__]['repo'] save_func = repos[self.__class__.__name__]['save'] getattr(repo, save_func)(inst) self._log_changes(None, inst) return json.dumps(inst.dictize()) except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='POST') def _create_instance_from_request(self, data): data = self.hateoas.remove_links(data) inst = self.__class__(**data) self._update_object(inst) ensure_authorized_to('create', inst) self._validate_instance(inst) return inst @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def delete(self, oid): """Delete a single item from the DB. :arg self: The class of the object to be deleted :arg integer oid: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.7>`_. """ try: self.valid_args() self._delete_instance(oid) return '', 204 except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='DELETE') def _delete_instance(self, oid): repo = repos[self.__class__.__name__]['repo'] query_func = repos[self.__class__.__name__]['get'] inst = getattr(repo, query_func)(oid) if inst is None: raise NotFound ensure_authorized_to('delete', inst) self._log_changes(inst, None) delete_func = repos[self.__class__.__name__]['delete'] getattr(repo, delete_func)(inst) return inst @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER')) def put(self, oid): """Update a single item in the DB. :arg self: The class of the object to be updated :arg integer oid: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.6>`_. """ try: self.valid_args() inst = self._update_instance(oid) return Response(json.dumps(inst.dictize()), 200, mimetype='application/json') except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='PUT') def _update_instance(self, oid): repo = repos[self.__class__.__name__]['repo'] query_func = repos[self.__class__.__name__]['get'] existing = getattr(repo, query_func)(oid) if existing is None: raise NotFound ensure_authorized_to('update', existing) data = json.loads(request.data) self._forbidden_attributes(data) # Remove hateoas links data = self.hateoas.remove_links(data) # may be missing the id as we allow partial updates data['id'] = oid self.__class__(**data) old = self.__class__(**existing.dictize()) for key in data: setattr(existing, key, data[key]) self._update_attribute(existing, old) update_func = repos[self.__class__.__name__]['update'] self._validate_instance(existing) getattr(repo, update_func)(existing) self._log_changes(old, existing) return existing def _update_object(self, data_dict): """Update object. Method to be overriden in inheriting classes which wish to update data dict. """ pass def _update_attribute(self, new, old): """Update object attribute if new value is passed. Method to be overriden in inheriting classes which wish to update data dict. """ def _select_attributes(self, item_data): """Method to be overriden in inheriting classes in case it is not desired that every object attribute is returned by the API. """ return item_data def _custom_filter(self, query): """Method to be overriden in inheriting classes which wish to consider specific filtering criteria. """ return query def _validate_instance(self, instance): """Method to be overriden in inheriting classes which may need to validate the creation (POST) or modification (PUT) of a domain object for reasons other than business logic ones (e.g. overlapping of a project name witht a URL). """ pass def _log_changes(self, old_obj, new_obj): """Method to be overriden by inheriting classes for logging purposes""" pass def _forbidden_attributes(self, data): """Method to be overriden by inheriting classes that will not allow for certain fields to be used in PUT or POST requests""" pass
class APIBase(MethodView): """Class to create CRUD methods.""" hateoas = Hateoas() def valid_args(self): """Check if the domain object args are valid.""" for k in request.args.keys(): if k not in ['api_key']: getattr(self.__class__, k) @crossdomain(origin='*', headers=cors_headers) def options(self): # pragma: no cover """Return '' for Options method.""" return '' @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=300, per=15 * 60) def get(self, id): """Get an object. Returns an item from the DB with the request.data JSON object or all the items if id == None :arg self: The class of the object to be retrieved :arg integer id: the ID of the object in the DB :returns: The JSON item/s stored in the DB """ try: self._get() getattr(require, self.__class__.__name__.lower()).read() if id is None: query = db.session.query(self.__class__) for k in request.args.keys(): if k not in ['limit', 'offset', 'api_key']: # Raise an error if the k arg is not a column getattr(self.__class__, k) query = query.filter( getattr(self.__class__, k) == request.args[k]) try: limit = min(10000, int(request.args.get('limit'))) except (ValueError, TypeError): limit = 20 try: offset = int(request.args.get('offset')) except (ValueError, TypeError): offset = 0 query = query.order_by(self.__class__.id) query = query.limit(limit) query = query.offset(offset) items = [] for item in query.all(): obj = item.dictize() links, link = self.hateoas.create_links(item) if links: obj['links'] = links if link: obj['link'] = link items.append(obj) return Response(json.dumps(items), mimetype='application/json') else: item = db.session.query(self.__class__).get(id) if item is None: raise abort(404) else: getattr(require, self.__class__.__name__.lower()).read(item) obj = item.dictize() links, link = self.hateoas.create_links(item) if links: obj['links'] = links if link: obj['link'] = link return Response(json.dumps(obj), mimetype='application/json') except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='GET') @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=300, per=15 * 60) def post(self): """Post an item to the DB with the request.data JSON object. :arg self: The class of the object to be inserted :returns: The JSON item stored in the DB """ try: self._post() self.valid_args() data = json.loads(request.data) # Clean HATEOAS args data = self.hateoas.remove_links(data) inst = self.__class__(**data) getattr(require, self.__class__.__name__.lower()).create(inst) self._update_object(inst) db.session.add(inst) db.session.commit() return json.dumps(inst.dictize()) except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='POST') @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=300, per=15 * 60) def delete(self, id): """Delete a single item from the DB. :arg self: The class of the object to be deleted :arg integer id: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.7>`_. """ try: self._delete() self.valid_args() inst = db.session.query(self.__class__).get(id) if inst is None: raise NotFound getattr(require, self.__class__.__name__.lower()).delete(inst) db.session.delete(inst) db.session.commit() self._refresh_cache(inst) return '', 204 except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='DELETE') @jsonpify @crossdomain(origin='*', headers=cors_headers) @ratelimit(limit=300, per=15 * 60) def put(self, id): """Update a single item in the DB. :arg self: The class of the object to be updated :arg integer id: the ID of the object in the DB :returns: An HTTP status code based on the output of the action. More info about HTTP status codes for this action `here <http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.6>`_. """ try: self._put() self.valid_args() existing = db.session.query(self.__class__).get(id) if existing is None: raise NotFound getattr(require, self.__class__.__name__.lower()).update(existing) data = json.loads(request.data) # may be missing the id as we allow partial updates data['id'] = id # Clean HATEOAS args data = self.hateoas.remove_links(data) inst = self.__class__(**data) db.session.merge(inst) db.session.commit() self._refresh_cache(inst) return Response(json.dumps(inst.dictize()), 200, mimetype='application/json') except Exception as e: return error.format_exception( e, target=self.__class__.__name__.lower(), action='PUT') def _get(self): """GET method to override.""" pass def _post(self): """POST method to override.""" pass def _put(self): """PUT method to override.""" pass def _delete(self): """DELETE method to override.""" pass def _update_object(self, data_dict): """Update object. Method to be overriden in inheriting classes which wish to update data dict. """ pass def _refresh_cache(self, data_dict): """Refresh cache. Method to be overriden in inheriting classes which wish to refresh cache for given object. """ pass
class TestHateoas(web_helper.Helper): url = "/app/%s/tasks/export" % Fixtures.app_short_name hateoas = Hateoas() def setUp(self): super(TestHateoas, self).setUp() Fixtures.create() # Tests def test_00_link_object(self): """Test HATEOAS object link is created""" # For app res = self.app.get("/api/app/1", follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg app_link = self.hateoas.link(rel='self', title='app', href='http://localhost/api/app/1') err_msg = "The object link is wrong: %s" % output['link'] assert app_link == output['link'], err_msg err_msg = "There should not be links, this is the parent object" assert output.get('links') is None, err_msg # For task res = self.app.get("/api/task/1", follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='task', href='http://localhost/api/task/1') err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be one parent link: app" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 1, err_msg err_msg = "The parent link is wrong" app_link = self.hateoas.link(rel='parent', title='app', href='http://localhost/api/app/1') assert output.get('links')[0] == app_link, err_msg # For taskrun res = self.app.get("/api/taskrun/1", follow_redirects=True) output = json.loads(res.data) err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='taskrun', href='http://localhost/api/taskrun/1') err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be two parent links: app and task" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 2, err_msg err_msg = "The parent app link is wrong" app_link = self.hateoas.link(rel='parent', title='app', href='http://localhost/api/app/1') assert output.get('links')[0] == app_link, err_msg err_msg = "The parent task link is wrong" app_link = self.hateoas.link(rel='parent', title='task', href='http://localhost/api/task/1') assert output.get('links')[1] == app_link, err_msg def test_01_link_object(self): """Test HATEOAS object link is created""" # For app res = self.app.get("/api/app", follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg app_link = self.hateoas.link(rel='self', title='app', href='http://localhost/api/app/1') err_msg = "The object link is wrong: %s" % output['link'] assert app_link == output['link'], err_msg err_msg = "There should not be links, this is the parent object" assert output.get('links') is None, err_msg # For task res = self.app.get("/api/task", follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='task', href='http://localhost/api/task/1') err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be one parent link: app" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 1, err_msg err_msg = "The parent link is wrong" app_link = self.hateoas.link(rel='parent', title='app', href='http://localhost/api/app/1') assert output.get('links')[0] == app_link, err_msg # For taskrun res = self.app.get("/api/taskrun", follow_redirects=True) output = json.loads(res.data)[0] err_msg = "There should be a Link with the object URI" assert output['link'] is not None, err_msg task_link = self.hateoas.link(rel='self', title='taskrun', href='http://localhost/api/taskrun/1') err_msg = "The object link is wrong: %s" % output['link'] assert task_link == output['link'], err_msg err_msg = "There should be two parent links: app and task" assert output.get('links') is not None, err_msg assert len(output.get('links')) == 2, err_msg err_msg = "The parent app link is wrong" app_link = self.hateoas.link(rel='parent', title='app', href='http://localhost/api/app/1') assert output.get('links')[0] == app_link, err_msg err_msg = "The parent task link is wrong" app_link = self.hateoas.link(rel='parent', title='task', href='http://localhost/api/task/1') assert output.get('links')[1] == app_link, err_msg