diff --git a/Dockerfile b/Dockerfile index b317c8c81..c004c5f9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ FROM python:3.8-alpine3.13 as BUILD-BACKEND RUN apk add --update \ && apk add --no-cache build-base curl-dev linux-headers bash git musl-dev libffi-dev \ - && apk add --no-cache python3-dev openssl-dev rust cargo \ + && apk add --no-cache python3-dev openssl-dev rust cargo libstdc++ \ && rm -rf /var/cache/apk/* COPY requirements.txt /root/submitter/requirements.txt @@ -34,7 +34,7 @@ RUN pip install --upgrade pip && \ FROM python:3.8-alpine3.13 -RUN apk add --no-cache --update bash +RUN apk add --no-cache --update libstdc++ LABEL maintainer="CSC Developers" LABEL org.label-schema.schema-version="1.0" diff --git a/docs/specification.yml b/docs/specification.yml index f054db993..510e32bb6 100644 --- a/docs/specification.yml +++ b/docs/specification.yml @@ -295,7 +295,7 @@ paths: parameters: - name: schema in: path - description: Title of the XML schema. + description: Name of the Metadata schema. schema: type: string required: true @@ -529,7 +529,7 @@ paths: parameters: - name: schema in: path - description: Title of the XML schema. + description: Name of the Metadata schema. schema: type: string required: true @@ -749,6 +749,134 @@ paths: application/json: schema: $ref: "#/components/schemas/403Forbidden" + /templates/{schema}: + post: + tags: + - Submission + summary: Submit data to a specific schema + parameters: + - name: schema + in: path + description: Title of the template schema. + schema: + type: string + required: true + requestBody: + content: + application/json: + schema: + type: object + responses: + 201: + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/ObjectCreated" + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/400BadRequest" + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/401Unauthorized" + 403: + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/403Forbidden" + /templates/{schema}/{accessionId}: + get: + tags: + - Query + summary: List of object by accession ID. + parameters: + - name: schema + in: path + description: Unique id of the targeted service. + schema: + type: string + required: true + - name: accessionId + in: path + description: filter objects in schema using accession ID + schema: + type: string + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Object" + text/xml: + schema: + type: string + format: binary + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/401Unauthorized" + 403: + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/403Forbidden" + 404: + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/404NotFound" + delete: + tags: + - Manage + summary: Delete object from a schema with a specified accession ID + parameters: + - name: schema + in: path + description: Unique id of the targeted service. + schema: + type: string + required: true + - name: accessionId + in: path + description: filter objects in schema using accession ID + schema: + type: string + required: true + responses: + 204: + description: No Content + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/400BadRequest" + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/401Unauthorized" + 403: + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/403Forbidden" + /folders: get: tags: diff --git a/metadata_backend/api/handlers.py b/metadata_backend/api/handlers.py index a0ce381f0..2f68b18d8 100644 --- a/metadata_backend/api/handlers.py +++ b/metadata_backend/api/handlers.py @@ -1,4 +1,5 @@ """Handle HTTP methods for server.""" +import ujson import json import re import mimetypes @@ -89,8 +90,9 @@ async def _handle_check_ownedby_user(self, req: Request, collection: str, access elif check: # if the draft object is found in folder we just need to check if the folder belongs to user _check = await user_op.check_user_has_doc("folders", current_user, folder_id) - elif collection.startswith("draft"): - # if collection is draft but not found in a folder we also check if object is in drafts of the user + elif collection.startswith("template"): + # if collection is template but not found in a folder + # we also check if object is in templates of the user # they will be here if they will not be deleted after publish _check = await user_op.check_user_has_doc(collection, current_user, accession_id) else: @@ -179,7 +181,7 @@ async def get_schema_types(self, req: Request) -> Response: :param req: GET Request :returns: JSON list of schema types """ - types_json = json.dumps([x["description"] for x in schema_types.values()]) + types_json = ujson.dumps([x["description"] for x in schema_types.values()], escape_forward_slashes=False) LOG.info(f"GET schema types. Retrieved {len(schema_types)} schemas.") return web.Response(body=types_json, status=200, content_type="application/json") @@ -197,7 +199,9 @@ async def get_json_schema(self, req: Request) -> Response: try: schema = JSONSchemaLoader().get_schema(schema_type) LOG.info(f"{schema_type} schema loaded.") - return web.Response(body=json.dumps(schema), status=200, content_type="application/json") + return web.Response( + body=ujson.dumps(schema, escape_forward_slashes=False), status=200, content_type="application/json" + ) except SchemaNotFoundException as error: reason = f"{error} ({schema_type})" @@ -249,7 +253,7 @@ async def _handle_query(self, req: Request) -> Response: collection, req.query, page, per_page, filter_list ) - result = json.dumps( + result = ujson.dumps( { "page": { "page": page_num, @@ -258,7 +262,8 @@ async def _handle_query(self, req: Request) -> Response: "totalObjects": total_objects, }, "objects": data, - } + }, + escape_forward_slashes=False, ) url = f"{req.scheme}://{req.host}{req.path}" link_headers = await self._header_links(url, page_num, per_page, total_objects) @@ -275,7 +280,7 @@ async def get_object(self, req: Request) -> Response: """Get one metadata object by its accession id. Returns original XML object from backup if format query parameter is - set, otherwise json. + set, otherwise JSON. :param req: GET request :returns: JSON or XML response containing metadata object @@ -296,7 +301,7 @@ async def get_object(self, req: Request) -> Response: data, content_type = await operator.read_metadata_object(type_collection, accession_id) - data = data if req_format == "xml" else json.dumps(data) + data = data if req_format == "xml" else ujson.dumps(data, escape_forward_slashes=False) LOG.info(f"GET object with accesssion ID {accession_id} from schema {collection}.") return web.Response(body=data, status=200, content_type=content_type) @@ -328,9 +333,9 @@ async def post_object(self, req: Request) -> Response: accession_id = await operator.create_metadata_object(collection, content) - body = json.dumps({"accessionId": accession_id}) + body = ujson.dumps({"accessionId": accession_id}, escape_forward_slashes=False) url = f"{req.scheme}://{req.host}{req.path}" - location_headers = CIMultiDict(Location=f"{url}{accession_id}") + location_headers = CIMultiDict(Location=f"{url}/{accession_id}") LOG.info(f"POST object with accesssion ID {accession_id} in schema {collection} was successful.") return web.Response( body=body, @@ -377,15 +382,9 @@ async def delete_object(self, req: Request) -> Response: raise web.HTTPUnauthorized(reason=reason) await folder_op.remove_object(folder_id, collection, accession_id) else: - user_op = UserOperator(db_client) - current_user = get_session(req)["user_info"] - check_user = await user_op.check_user_has_doc(collection, current_user, accession_id) - if check_user: - await user_op.remove_objects(current_user, "drafts", [accession_id]) - else: - reason = "This object does not seem to belong to any user." - LOG.error(reason) - raise web.HTTPUnprocessableEntity(reason=reason) + reason = "This object does not seem to belong to any user." + LOG.error(reason) + raise web.HTTPUnprocessableEntity(reason=reason) accession_id = await Operator(db_client).delete_metadata_object(collection, accession_id) @@ -427,7 +426,7 @@ async def put_object(self, req: Request) -> Response: accession_id = await operator.replace_metadata_object(collection, accession_id, content) - body = json.dumps({"accessionId": accession_id}) + body = ujson.dumps({"accessionId": accession_id}, escape_forward_slashes=False) LOG.info(f"PUT object with accession ID {accession_id} in schema {collection} was successful.") return web.Response(body=body, status=200, content_type="application/json") @@ -468,11 +467,162 @@ async def patch_object(self, req: Request) -> Response: accession_id = await operator.update_metadata_object(collection, accession_id, content) - body = json.dumps({"accessionId": accession_id}) + body = ujson.dumps({"accessionId": accession_id}, escape_forward_slashes=False) LOG.info(f"PATCH object with accession ID {accession_id} in schema {collection} was successful.") return web.Response(body=body, status=200, content_type="application/json") +class TemplatesAPIHandler(RESTAPIHandler): + """API Handler for Templates.""" + + async def get_template(self, req: Request) -> Response: + """Get one metadata object by its accession id. + + Returns JSON. + + :param req: GET request + :returns: JSON response containing template object + """ + accession_id = req.match_info["accessionId"] + schema_type = req.match_info["schema"] + self._check_schema_exists(schema_type) + collection = f"template-{schema_type}" + + db_client = req.app["db_client"] + operator = Operator(db_client) + + await operator.check_exists(collection, accession_id) + + await self._handle_check_ownedby_user(req, collection, accession_id) + + data, content_type = await operator.read_metadata_object(collection, accession_id) + + data = ujson.dumps(data, escape_forward_slashes=False) + LOG.info(f"GET object with accesssion ID {accession_id} from schema {collection}.") + return web.Response(body=data, status=200, content_type=content_type) + + async def post_template(self, req: Request) -> Response: + """Save metadata object to database. + + For JSON request body we validate it is consistent with the + associated JSON schema. + + :param req: POST request + :returns: JSON response containing accessionId for submitted object + """ + schema_type = req.match_info["schema"] + self._check_schema_exists(schema_type) + collection = f"template-{schema_type}" + + db_client = req.app["db_client"] + content = await self._get_data(req) + + user_op = UserOperator(db_client) + current_user = get_session(req)["user_info"] + + operator = Operator(db_client) + + if isinstance(content, list): + tmpl_list = [] + for num, tmpl in enumerate(content): + if "template" not in tmpl: + reason = f"template key is missing from request body for element: {num}." + LOG.error(reason) + raise web.HTTPBadRequest(reason=reason) + accession_id = await operator.create_metadata_object(collection, tmpl["template"]) + data = [{"accessionId": accession_id, "schema": collection}] + if "tags" in tmpl: + data[0]["tags"] = tmpl["tags"] + await user_op.assign_objects(current_user, "templates", data) + tmpl_list.append({"accessionId": accession_id}) + + body = ujson.dumps(tmpl_list, escape_forward_slashes=False) + else: + if "template" not in content: + reason = "template key is missing from request body." + LOG.error(reason) + raise web.HTTPBadRequest(reason=reason) + accession_id = await operator.create_metadata_object(collection, content["template"]) + data = [{"accessionId": accession_id, "schema": collection}] + if "tags" in content: + data[0]["tags"] = content["tags"] + await user_op.assign_objects(current_user, "templates", data) + + body = ujson.dumps({"accessionId": accession_id}, escape_forward_slashes=False) + + url = f"{req.scheme}://{req.host}{req.path}" + location_headers = CIMultiDict(Location=f"{url}/{accession_id}") + LOG.info(f"POST object with accesssion ID {accession_id} in schema {collection} was successful.") + return web.Response( + body=body, + status=201, + headers=location_headers, + content_type="application/json", + ) + + async def patch_template(self, req: Request) -> Response: + """Update metadata object in database. + + :param req: PATCH request + :raises: HTTPUnauthorized if object is in published folder + :returns: JSON response containing accessionId for submitted object + """ + schema_type = req.match_info["schema"] + accession_id = req.match_info["accessionId"] + self._check_schema_exists(schema_type) + collection = f"template-{schema_type}" + + db_client = req.app["db_client"] + operator: Union[Operator, XMLOperator] + + content = await self._get_data(req) + operator = Operator(db_client) + + await operator.check_exists(collection, accession_id) + + await self._handle_check_ownedby_user(req, collection, accession_id) + + accession_id = await operator.update_metadata_object(collection, accession_id, content) + + body = ujson.dumps({"accessionId": accession_id}, escape_forward_slashes=False) + LOG.info(f"PATCH object with accession ID {accession_id} in schema {collection} was successful.") + return web.Response(body=body, status=200, content_type="application/json") + + async def delete_template(self, req: Request) -> Response: + """Delete metadata object from database. + + :param req: DELETE request + :raises: HTTPUnauthorized if folder published + :raises: HTTPUnprocessableEntity if object does not belong to current user + :returns: HTTPNoContent response + """ + schema_type = req.match_info["schema"] + self._check_schema_exists(schema_type) + collection = f"template-{schema_type}" + + accession_id = req.match_info["accessionId"] + db_client = req.app["db_client"] + + await Operator(db_client).check_exists(collection, accession_id) + + await self._handle_check_ownedby_user(req, collection, accession_id) + + user_op = UserOperator(db_client) + current_user = get_session(req)["user_info"] + check_user = await user_op.check_user_has_doc(collection, current_user, accession_id) + if check_user: + await user_op.remove_objects(current_user, "templates", [accession_id]) + else: + reason = "This object does not seem to belong to any user." + LOG.error(reason) + raise web.HTTPUnprocessableEntity(reason=reason) + + accession_id = await Operator(db_client).delete_metadata_object(collection, accession_id) + + LOG.info(f"DELETE object with accession ID {accession_id} in schema {collection} was successful.") + return web.Response(status=204) + + class FolderAPIHandler(RESTAPIHandler): """API Handler for folders.""" @@ -557,7 +707,7 @@ async def get_folders(self, req: Request) -> Response: folder_operator = FolderOperator(db_client) folders, total_folders = await folder_operator.query_folders(folder_query, page, per_page) - result = json.dumps( + result = ujson.dumps( { "page": { "page": page, @@ -566,7 +716,8 @@ async def get_folders(self, req: Request) -> Response: "totalFolders": total_folders, }, "folders": folders, - } + }, + escape_forward_slashes=False, ) url = f"{req.scheme}://{req.host}{req.path}" @@ -599,7 +750,7 @@ async def post_folder(self, req: Request) -> Response: current_user = get_session(req)["user_info"] await user_op.assign_objects(current_user, "folders", [folder]) - body = json.dumps({"folderId": folder}) + body = ujson.dumps({"folderId": folder}, escape_forward_slashes=False) url = f"{req.scheme}://{req.host}{req.path}" location_headers = CIMultiDict(Location=f"{url}/{folder}") @@ -624,7 +775,9 @@ async def get_folder(self, req: Request) -> Response: folder = await operator.read_folder(folder_id) LOG.info(f"GET folder with ID {folder_id} was successful.") - return web.Response(body=json.dumps(folder), status=200, content_type="application/json") + return web.Response( + body=ujson.dumps(folder, escape_forward_slashes=False), status=200, content_type="application/json" + ) async def patch_folder(self, req: Request) -> Response: """Update object folder with a specific folder id. @@ -654,7 +807,7 @@ async def patch_folder(self, req: Request) -> Response: upd_folder = await operator.update_folder(folder_id, patch_ops if isinstance(patch_ops, list) else [patch_ops]) - body = json.dumps({"folderId": upd_folder}) + body = ujson.dumps({"folderId": upd_folder}, escape_forward_slashes=False) LOG.info(f"PATCH folder with ID {upd_folder} was successful.") return web.Response(body=body, status=200, content_type="application/json") @@ -686,7 +839,7 @@ async def publish_folder(self, req: Request) -> Response: ] new_folder = await operator.update_folder(folder_id, patch) - body = json.dumps({"folderId": new_folder}) + body = ujson.dumps({"folderId": new_folder}, escape_forward_slashes=False) LOG.info(f"Patching folder with ID {new_folder} was successful.") return web.Response(body=body, status=200, content_type="application/json") @@ -737,9 +890,9 @@ def _check_patch_user(self, patch_ops: Any) -> None: :raises: HTTPUnauthorized if request tries to do anything else than add or replace :returns: None """ - _arrays = ["/drafts/-", "/folders/-"] + _arrays = ["/templates/-", "/folders/-"] _required_values = ["schema", "accessionId"] - _tags = re.compile("^/(drafts)/[0-9]*/(tags)$") + _tags = re.compile("^/(templates)/[0-9]*/(tags)$") for op in patch_ops: if _tags.match(op["path"]): LOG.info(f"{op['op']} on tags in folder") @@ -762,7 +915,7 @@ def _check_patch_user(self, patch_ops: Any) -> None: reason = "We only accept string folder IDs." LOG.error(reason) raise web.HTTPBadRequest(reason=reason) - if op["path"] == "/drafts/-": + if op["path"] == "/templates/-": _ops = op["value"] if isinstance(op["value"], list) else [op["value"]] for item in _ops: if not all(key in item.keys() for key in _required_values): @@ -783,7 +936,7 @@ async def get_user(self, req: Request) -> Response: :param req: GET request :raises: HTTPUnauthorized if not current user - :returns: JSON response containing user object or list of user drafts or user folders by id + :returns: JSON response containing user object or list of user templates or user folders by id """ user_id = req.match_info["userId"] if user_id != "current": @@ -794,21 +947,23 @@ async def get_user(self, req: Request) -> Response: item_type = req.query.get("items", "").lower() if item_type: - # Return only list of drafts or list of folder IDs owned by the user + # Return only list of templates or list of folder IDs owned by the user result, link_headers = await self._get_user_items(req, current_user, item_type) return web.Response( - body=json.dumps(result), + body=ujson.dumps(result, escape_forward_slashes=False), status=200, headers=link_headers, content_type="application/json", ) else: - # Return whole user object if drafts or folders are not specified in query + # Return whole user object if templates or folders are not specified in query db_client = req.app["db_client"] operator = UserOperator(db_client) user = await operator.read_user(current_user) LOG.info(f"GET user with ID {user_id} was successful.") - return web.Response(body=json.dumps(user), status=200, content_type="application/json") + return web.Response( + body=ujson.dumps(user, escape_forward_slashes=False), status=200, content_type="application/json" + ) async def patch_user(self, req: Request) -> Response: """Update user object with a specific user ID. @@ -831,7 +986,7 @@ async def patch_user(self, req: Request) -> Response: current_user = get_session(req)["user_info"] user = await operator.update_user(current_user, patch_ops if isinstance(patch_ops, list) else [patch_ops]) - body = json.dumps({"userId": user}) + body = ujson.dumps({"userId": user}) LOG.info(f"PATCH user with ID {user} was successful.") return web.Response(body=body, status=200, content_type="application/json") @@ -861,7 +1016,7 @@ async def delete_user(self, req: Request) -> Response: await obj_ops.delete_metadata_object(obj["schema"], obj["accessionId"]) await fold_ops.delete_folder(folder_id) - for tmpl in user["drafts"]: + for tmpl in user["templates"]: await obj_ops.delete_metadata_object(tmpl["schema"], tmpl["accessionId"]) await operator.delete_user(current_user) @@ -887,13 +1042,13 @@ async def _get_user_items(self, req: Request, user: Dict, item_type: str) -> Tup :param req: GET request :param user: User object - :param item_type: Name of the items ("drafts" or "folders") + :param item_type: Name of the items ("templates" or "folders") :raises: HTTPUnauthorized if not current user :returns: Paginated list of user draft templates and link header """ # Check item_type parameter is not faulty - if item_type not in ["drafts", "folders"]: - reason = f"{item_type} is a faulty item parameter. Should be either folders or drafts" + if item_type not in ["templates", "folders"]: + reason = f"{item_type} is a faulty item parameter. Should be either folders or templates" LOG.error(reason) raise web.HTTPBadRequest(reason=reason) @@ -991,7 +1146,7 @@ async def submit(self, req: Request) -> Response: result = await self._execute_action(schema_type, content_xml, db_client, action) results.append(result) - body = json.dumps(results) + body = ujson.dumps(results, escape_forward_slashes=False) LOG.info(f"Processed a submission of {len(results)} actions.") return web.Response(body=body, status=200, content_type="application/json") @@ -1067,7 +1222,7 @@ async def _execute_action(self, schema: str, content: str, db_client: AsyncIOMot elif action == "validate": validator = await self._perform_validation(schema, content) - return json.loads(validator.resp_body) + return ujson.loads(validator.resp_body) else: reason = f"Action {action} in XML is not supported." diff --git a/metadata_backend/api/health.py b/metadata_backend/api/health.py index bc56100a0..8e5a0e617 100644 --- a/metadata_backend/api/health.py +++ b/metadata_backend/api/health.py @@ -1,5 +1,5 @@ """Handle health check endpoint.""" -import json +import ujson import time from typing import Dict, Union, Any @@ -35,7 +35,9 @@ async def get_health_status(self, req: Request) -> Response: full_status["services"] = services LOG.info("Health status collected.") - return web.Response(body=json.dumps(full_status), status=200, content_type="application/json") + return web.Response( + body=ujson.dumps(full_status, escape_forward_slashes=False), status=200, content_type="application/json" + ) async def create_test_db_client(self) -> AsyncIOMotorClient: """Initialize a new database client to test Mongo connection. diff --git a/metadata_backend/api/middlewares.py b/metadata_backend/api/middlewares.py index 0b04cf7b1..beaae4127 100644 --- a/metadata_backend/api/middlewares.py +++ b/metadata_backend/api/middlewares.py @@ -1,5 +1,5 @@ """Middleware methods for server.""" -import json +import ujson from http import HTTPStatus from typing import Callable, Tuple from cryptography.fernet import InvalidToken @@ -84,6 +84,7 @@ async def check_login(request: Request, handler: Callable) -> StreamResponse: controlled_paths = [ "/schemas", "/drafts", + "/templates", "/validate", "/publish", "/submit", @@ -161,7 +162,7 @@ def generate_cookie(request: Request) -> Tuple[dict, str]: } # Return a tuple of the session as an encrypted JSON string, and the # cookie itself - return (cookie, request.app["Crypt"].encrypt(json.dumps(cookie).encode("utf-8")).decode("utf-8")) + return (cookie, request.app["Crypt"].encrypt(ujson.dumps(cookie).encode("utf-8")).decode("utf-8")) def decrypt_cookie(request: web.Request) -> dict: @@ -176,7 +177,7 @@ def decrypt_cookie(request: web.Request) -> dict: raise web.HTTPUnauthorized() try: cookie_json = request.app["Crypt"].decrypt(request.cookies["MTD_SESSION"].encode("utf-8")).decode("utf-8") - cookie = json.loads(cookie_json) + cookie = ujson.loads(cookie_json) LOG.debug(f"Decrypted cookie: {cookie}") return cookie except InvalidToken: @@ -229,7 +230,7 @@ def _json_exception(status: int, exception: web.HTTPException, url: URL) -> str: :param url: Request URL that caused the exception :returns: Problem detail JSON object as a string """ - body = json.dumps( + body = ujson.dumps( { "type": "about:blank", # Replace type value above with an URL to @@ -237,6 +238,7 @@ def _json_exception(status: int, exception: web.HTTPException, url: URL) -> str: "title": HTTPStatus(status).phrase, "detail": exception.reason, "instance": url.path, # optional - } + }, + escape_forward_slashes=False, ) return body diff --git a/metadata_backend/api/operators.py b/metadata_backend/api/operators.py index 9d5924b3a..8c3d014eb 100644 --- a/metadata_backend/api/operators.py +++ b/metadata_backend/api/operators.py @@ -524,7 +524,7 @@ async def _format_data_to_create_and_add_to_db(self, schema_type: str, data: str :returns: Accession Id for object inserted to database """ db_client = self.db_service.db_client - # remove `drafs-` from schema type + # remove `draft-` from schema type schema = schema_type[6:] if schema_type.startswith("draft") else schema_type data_as_json = XMLToJSONParser().parse(schema, data) accession_id = await Operator(db_client)._format_data_to_create_and_add_to_db(schema_type, data_as_json) @@ -843,7 +843,7 @@ def __init__(self, db_client: AsyncIOMotorClient) -> None: self.db_service = DBService(mongo_database, db_client) async def check_user_has_doc(self, collection: str, user_id: str, accession_id: str) -> bool: - """Check a folder/draft belongs to user. + """Check a folder/template belongs to user. :param collection: collection it belongs to, it would be used as path :param user_id: user_id from session @@ -852,8 +852,8 @@ async def check_user_has_doc(self, collection: str, user_id: str, accession_id: :returns: True if accession_id belongs to user """ try: - if collection.startswith("draft"): - user_query = {"drafts": {"$elemMatch": {"accessionId": accession_id}}, "userId": user_id} + if collection.startswith("template"): + user_query = {"templates": {"$elemMatch": {"accessionId": accession_id}}, "userId": user_id} else: user_query = {"folders": {"$elemMatch": {"$eq": accession_id}}, "userId": user_id} user_cursor = self.db_service.query("user", user_query) @@ -891,7 +891,7 @@ async def create_user(self, data: Tuple) -> str: LOG.info(f"User with identifier: {external_id} exists, no need to create.") return existing_user_id else: - user_data["drafts"] = [] + user_data["templates"] = [] user_data["folders"] = [] user_data["userId"] = user_id = self._generate_user_id() user_data["name"] = name @@ -991,12 +991,12 @@ async def update_user(self, user_id: str, patch: List) -> str: async def assign_objects(self, user_id: str, collection: str, object_ids: List) -> None: """Assing object to user. - An object can be folder(s) or draft(s). + An object can be folder(s) or templates(s). :param user_id: ID of user to update :param collection: collection where to remove the id from :param object_ids: ID or list of IDs of folder(s) to assign - :raises: HTTPBadRequest if assigning drafts/folders to user was not successful + :raises: HTTPBadRequest if assigning templates/folders to user was not successful returns: None """ try: @@ -1019,7 +1019,7 @@ async def assign_objects(self, user_id: str, collection: str, object_ids: List) async def remove_objects(self, user_id: str, collection: str, object_ids: List) -> None: """Remove object from user. - An object can be folder(s) or draft(s). + An object can be folder(s) or template(s). :param user_id: ID of user to update :param collection: collection where to remove the id from @@ -1031,8 +1031,8 @@ async def remove_objects(self, user_id: str, collection: str, object_ids: List) try: await self._check_user_exists(user_id) for obj in object_ids: - if collection == "drafts": - remove_content = {"drafts": {"accessionId": obj}} + if collection == "templates": + remove_content = {"templates": {"accessionId": obj}} else: remove_content = {"folders": obj} await self.db_service.remove("user", user_id, remove_content) diff --git a/metadata_backend/conf/conf.py b/metadata_backend/conf/conf.py index ebe4e0b35..6cecb7f96 100644 --- a/metadata_backend/conf/conf.py +++ b/metadata_backend/conf/conf.py @@ -31,7 +31,7 @@ and inserted here in projects Dockerfile. """ -import json +import ujson import os from pathlib import Path from distutils.util import strtobool @@ -107,7 +107,7 @@ def create_db_client() -> AsyncIOMotorClient: # Default schemas will be ENA schemas path_to_schema_file = Path(__file__).parent / "ena_schemas.json" with open(path_to_schema_file) as schema_file: - schema_types = json.load(schema_file) + schema_types = ujson.load(schema_file) # 3) Define mapping between url query parameters and mongodb queries diff --git a/metadata_backend/helpers/logger.py b/metadata_backend/helpers/logger.py index ffb86ed51..34af3161d 100644 --- a/metadata_backend/helpers/logger.py +++ b/metadata_backend/helpers/logger.py @@ -1,6 +1,6 @@ """Logging formatting and functions for debugging.""" -import json +import ujson import logging from typing import Any, Dict import os @@ -31,4 +31,4 @@ def pprint_json(content: Dict) -> None: :param content: JSON-formatted content to be printed """ - LOG.info(json.dumps(content, indent=4)) + LOG.info(ujson.dumps(content, indent=4, escape_forward_slashes=False)) diff --git a/metadata_backend/helpers/schemas/users.json b/metadata_backend/helpers/schemas/users.json index 0f021b1eb..aed6a5a4e 100644 --- a/metadata_backend/helpers/schemas/users.json +++ b/metadata_backend/helpers/schemas/users.json @@ -14,7 +14,7 @@ "type": "string", "title": "User Name" }, - "drafts": { + "templates": { "type": "array", "title": "User templates schema", "items": { @@ -42,7 +42,6 @@ "type": "string", "title": "Type of submission", "enum": [ - "XML", "Form" ] } diff --git a/metadata_backend/helpers/validator.py b/metadata_backend/helpers/validator.py index 05d9fec22..68549ddf3 100644 --- a/metadata_backend/helpers/validator.py +++ b/metadata_backend/helpers/validator.py @@ -1,6 +1,6 @@ """Utility classes for validating XML or JSON files.""" -import json +import ujson import re from io import StringIO from typing import Any, Dict, cast @@ -38,7 +38,7 @@ def resp_body(self) -> str: try: self.schema.validate(self.xml_content) LOG.info("Submitted file is totally valid.") - return json.dumps({"isValid": True}) + return ujson.dumps({"isValid": True}) except ParseError as error: reason = self._parse_error_reason(error) @@ -48,7 +48,7 @@ def resp_body(self) -> str: instance = re.sub(r"^.*?<", "<", line) # strip whitespaces LOG.info("Submitted file does not not contain valid XML syntax.") - return json.dumps({"isValid": False, "detail": {"reason": reason, "instance": instance}}) + return ujson.dumps({"isValid": False, "detail": {"reason": reason, "instance": instance}}) except XMLSchemaValidationError as error: # Parse reason and instance from the validation error message @@ -61,7 +61,7 @@ def resp_body(self) -> str: reason = re.sub("<[^>]*>", instance_parent + " ", reason) LOG.info("Submitted file is not valid against schema.") - return json.dumps({"isValid": False, "detail": {"reason": reason, "instance": instance}}) + return ujson.dumps({"isValid": False, "detail": {"reason": reason, "instance": instance}}) except URLError as error: reason = f"Faulty file was provided. {error.reason}." @@ -77,7 +77,7 @@ def _parse_error_reason(self, error: ParseError) -> str: @property def is_valid(self) -> bool: """Quick method for checking validation result.""" - resp = json.loads(self.resp_body) + resp = ujson.loads(self.resp_body) return resp["isValid"] diff --git a/metadata_backend/server.py b/metadata_backend/server.py index 49de9e973..c39d3364e 100644 --- a/metadata_backend/server.py +++ b/metadata_backend/server.py @@ -15,6 +15,7 @@ FolderAPIHandler, UserAPIHandler, ObjectAPIHandler, + TemplatesAPIHandler, ) from .api.auth import AccessHandler from .api.middlewares import http_error_handler, check_login @@ -64,35 +65,49 @@ async def init() -> web.Application: server.middlewares.append(http_error_handler) server.middlewares.append(check_login) - _handler = RESTAPIHandler() + _schema = RESTAPIHandler() _object = ObjectAPIHandler() _folder = FolderAPIHandler() _user = UserAPIHandler() _submission = SubmissionAPIHandler() + _template = TemplatesAPIHandler() api_routes = [ - web.get("/schemas", _handler.get_schema_types), - web.get("/schemas/{schema}", _handler.get_json_schema), - web.get("/objects/{schema}/{accessionId}", _object.get_object), - web.delete("/objects/{schema}/{accessionId}", _object.delete_object), + # retrieve schema and informations about it + web.get("/schemas", _schema.get_schema_types), + web.get("/schemas/{schema}", _schema.get_json_schema), + # metadata objects operations web.get("/objects/{schema}", _object.query_objects), web.post("/objects/{schema}", _object.post_object), + web.get("/objects/{schema}/{accessionId}", _object.get_object), web.put("/objects/{schema}/{accessionId}", _object.put_object), + web.patch("/objects/{schema}/{accessionId}", _object.patch_object), + web.delete("/objects/{schema}/{accessionId}", _object.delete_object), + # drafts objects operations + web.post("/drafts/{schema}", _object.post_object), web.get("/drafts/{schema}/{accessionId}", _object.get_object), web.put("/drafts/{schema}/{accessionId}", _object.put_object), web.patch("/drafts/{schema}/{accessionId}", _object.patch_object), - web.patch("/objects/{schema}/{accessionId}", _object.patch_object), web.delete("/drafts/{schema}/{accessionId}", _object.delete_object), - web.post("/drafts/{schema}", _object.post_object), + # template objects operations + web.post("/templates/{schema}", _template.post_template), + web.get("/templates/{schema}/{accessionId}", _template.get_template), + web.patch("/templates/{schema}/{accessionId}", _template.patch_template), + web.delete("/templates/{schema}/{accessionId}", _template.delete_template), + # folders/submissions operations web.get("/folders", _folder.get_folders), web.post("/folders", _folder.post_folder), web.get("/folders/{folderId}", _folder.get_folder), web.patch("/folders/{folderId}", _folder.patch_folder), web.delete("/folders/{folderId}", _folder.delete_folder), + # publish submissions web.patch("/publish/{folderId}", _folder.publish_folder), + # users operations web.get("/users/{userId}", _user.get_user), web.patch("/users/{userId}", _user.patch_user), web.delete("/users/{userId}", _user.delete_user), + # submit web.post("/submit", _submission.submit), + # validate web.post("/validate", _submission.validate), ] server.router.add_routes(api_routes) diff --git a/requirements.txt b/requirements.txt index 0fedf5ad1..cfced2e39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ python-dateutil==2.8.2 uvloop==0.16.0 xmlschema==1.8.0 Authlib==0.15.4 +ujson==4.1.0 diff --git a/tests/integration/run_tests.py b/tests/integration/run_tests.py index f1b56c7aa..a7ffc997c 100644 --- a/tests/integration/run_tests.py +++ b/tests/integration/run_tests.py @@ -12,7 +12,7 @@ import urllib import xml.etree.ElementTree as ET -import aiofiles +import aiofiles # type: ignore import aiohttp from aiohttp import FormData @@ -51,6 +51,7 @@ mock_auth_url = "http://localhost:8000" objects_url = f"{base_url}/objects" drafts_url = f"{base_url}/drafts" +templates_url = f"{base_url}/templates" folders_url = f"{base_url}/folders" users_url = f"{base_url}/users" submit_url = f"{base_url}/submit" @@ -196,7 +197,7 @@ async def post_draft_json(sess, schema, filename): return ans["accessionId"] -async def get_draft(sess, schema, draft_id): +async def get_draft(sess, schema, draft_id, expected_status=200): """Get and return a drafted metadata object. :param sess: HTTP session in which request call is made @@ -205,7 +206,7 @@ async def get_draft(sess, schema, draft_id): """ async with sess.get(f"{drafts_url}/{schema}/{draft_id}") as resp: LOG.debug(f"Checking that {draft_id} JSON exists") - assert resp.status == 200, "HTTP Status code error" + assert resp.status == expected_status, "HTTP Status code error" ans = await resp.json() return json.dumps(ans) @@ -287,6 +288,67 @@ async def delete_draft(sess, schema, draft_id): assert resp.status == 204, "HTTP Status code error" +async def post_template_json(sess, schema, filename): + """Post one metadata object within session, returns accessionId. + + :param sess: HTTP session in which request call is made + :param schema: name of the schema (folder) used for testing + :param filename: name of the file used for testing. + """ + request_data = await create_request_json_data(schema, filename) + async with sess.post(f"{templates_url}/{schema}", data=request_data) as resp: + LOG.debug(f"Adding new template object to {schema}, via JSON file {filename}") + assert resp.status == 201, "HTTP Status code error" + ans = await resp.json() + if isinstance(ans, list): + return ans + else: + return ans["accessionId"] + + +async def get_template(sess, schema, template_id): + """Get and return a drafted metadata object. + + :param sess: HTTP session in which request call is made + :param schema: name of the schema (folder) used for testing + :param template_id: id of the draft + """ + async with sess.get(f"{templates_url}/{schema}/{template_id}") as resp: + LOG.debug(f"Checking that {template_id} JSON exists") + assert resp.status == 200, "HTTP Status code error" + ans = await resp.json() + return json.dumps(ans) + + +async def patch_template(sess, schema, template_id, update_filename): + """Patch one metadata object within session, return accessionId. + + :param sess: HTTP session in which request call is made + :param schema: name of the schema (folder) used for testing + :param template_id: id of the draft + :param update_filename: name of the file used to use for updating data. + """ + request_data = await create_request_json_data(schema, update_filename) + async with sess.patch(f"{templates_url}/{schema}/{template_id}", data=request_data) as resp: + LOG.debug(f"Update draft object in {schema}") + assert resp.status == 200, "HTTP Status code error" + ans_put = await resp.json() + assert ans_put["accessionId"] == template_id, "accession ID error" + return ans_put["accessionId"] + + +async def delete_template(sess, schema, template_id): + """Delete metadata object within session. + + :param sess: HTTP session in which request call is made + :param schema: name of the schema (folder) used for testing + :param template_id: id of the draft + """ + async with sess.delete(f"{templates_url}/{schema}/{template_id}") as resp: + LOG.debug(f"Deleting template object {template_id} from {schema}") + assert resp.status == 204, "HTTP Status code error" + + async def post_folder(sess, data): """Post one object folder within session, returns folderId. @@ -662,6 +724,9 @@ async def test_crud_folders_works(sess): # Publish the folder folder_id = await publish_folder(sess, folder_id) + + await get_draft(sess, "sample", draft_id, 404) # checking the draft was deleted after publication + async with sess.get(f"{folders_url}/{folder_id}") as resp: LOG.debug(f"Checking that folder {folder_id} was patched") res = await resp.json() @@ -858,7 +923,7 @@ async def test_getting_paginated_folders(sess): async def test_getting_user_items(sess): - """Test querying user's drafts or folders in the user object with GET user request. + """Test querying user's templates or folders in the user object with GET user request. :param sess: HTTP session in which request call is made """ @@ -866,36 +931,30 @@ async def test_getting_user_items(sess): async with sess.get(f"{users_url}/{user_id}") as resp: LOG.debug(f"Reading user {user_id}") assert resp.status == 200, "HTTP Status code error" - response = await resp.json() - real_user_id = response["userId"] - # Patch user to have a draft - draft_id = await post_draft_json(sess, "study", "SRP000539.json") - patch_drafts_user = [ - {"op": "add", "path": "/drafts/-", "value": {"accessionId": draft_id, "schema": "draft-study"}} - ] - await patch_user(sess, user_id, real_user_id, patch_drafts_user) + # Add template to user + template_id = await post_template_json(sess, "study", "SRP000539_template.json") # Test querying for list of user draft templates - async with sess.get(f"{users_url}/{user_id}?items=drafts") as resp: - LOG.debug(f"Reading user {user_id} drafts") + async with sess.get(f"{users_url}/{user_id}?items=templates") as resp: + LOG.debug(f"Reading user {user_id} templates") assert resp.status == 200, "HTTP Status code error" ans = await resp.json() assert ans["page"]["page"] == 1 assert ans["page"]["size"] == 5 assert ans["page"]["totalPages"] == 1 - assert ans["page"]["totalDrafts"] == 1 - assert len(ans["drafts"]) == 1 + assert ans["page"]["totalTemplates"] == 1 + assert len(ans["templates"]) == 1 - async with sess.get(f"{users_url}/{user_id}?items=drafts&per_page=3") as resp: - LOG.debug(f"Reading user {user_id} drafts") + async with sess.get(f"{users_url}/{user_id}?items=templates&per_page=3") as resp: + LOG.debug(f"Reading user {user_id} templates") assert resp.status == 200, "HTTP Status code error" ans = await resp.json() assert ans["page"]["page"] == 1 assert ans["page"]["size"] == 3 - assert len(ans["drafts"]) == 1 + assert len(ans["templates"]) == 1 - await delete_draft(sess, "study", draft_id) # Future tests will assume the drafts key is empty + await delete_template(sess, "study", template_id) # Future tests will assume the templates key is empty # Test querying for the list of folder IDs async with sess.get(f"{users_url}/{user_id}?items=folders") as resp: @@ -929,14 +988,13 @@ async def test_crud_users_works(sess): # Add user to session and create a patch to add folder to user folder_not_published = {"name": "Mock User Folder", "description": "Mock folder for testing users"} folder_id = await post_folder(sess, folder_not_published) - patch_add_folder = [{"op": "add", "path": "/folders/-", "value": [folder_id]}] - await patch_user(sess, user_id, real_user_id, patch_add_folder) + async with sess.get(f"{users_url}/{user_id}") as resp: LOG.debug(f"Checking that folder {folder_id} was added") res = await resp.json() assert res["userId"] == real_user_id, "user id does not match" assert res["name"] == f"{test_user_given} {test_user_family}", "user name mismatch" - assert res["drafts"] == [], "user drafts content mismatch" + assert res["templates"] == [], "user templates content mismatch" assert folder_id in res["folders"], "folder added missing mismatch" folder_published = {"name": "Another test Folder", "description": "Test published folder does not get deleted"} @@ -950,6 +1008,7 @@ async def test_crud_users_works(sess): folder_not_published = {"name": "Delete Folder", "description": "Mock folder to delete while testing users"} delete_folder_id = await post_folder(sess, folder_not_published) patch_delete_folder = [{"op": "add", "path": "/folders/-", "value": [delete_folder_id]}] + await patch_user(sess, user_id, real_user_id, patch_delete_folder) async with sess.get(f"{users_url}/{user_id}") as resp: LOG.debug(f"Checking that folder {delete_folder_id} was added") @@ -961,22 +1020,43 @@ async def test_crud_users_works(sess): res = await resp.json() assert delete_folder_id not in res["folders"], "delete folder still exists at user" - draft_id = await post_draft_json(sess, "study", "SRP000539.json") - patch_drafts_user = [ - {"op": "add", "path": "/drafts/-", "value": {"accessionId": draft_id, "schema": "draft-study"}} + template_id = await post_template_json(sess, "study", "SRP000539_template.json") + await patch_template(sess, "study", template_id, "patch.json") + async with sess.get(f"{users_url}/{user_id}") as resp: + LOG.debug(f"Checking that template: {template_id} was added") + res = await resp.json() + assert res["templates"][0]["accessionId"] == template_id, "added template does not exists" + assert "tags" not in res["templates"][0] + + patch_change_tags_object = [ + { + "op": "add", + "path": "/templates/0/tags", + "value": {"displaTitle": "Test"}, + } ] - await patch_user(sess, user_id, real_user_id, patch_drafts_user) + await patch_user(sess, user_id, real_user_id, patch_change_tags_object) + + async with sess.get(f"{users_url}/{user_id}") as resp: + LOG.debug(f"Checking that template: {template_id} was added") + res = await resp.json() + assert res["templates"][0]["accessionId"] == template_id, "added template does not exists" + assert res["templates"][0]["tags"]["displaTitle"] == "Test" + + await delete_template(sess, "study", template_id) + async with sess.get(f"{users_url}/{user_id}") as resp: - LOG.debug(f"Checking that draft {draft_id} was added") + LOG.debug(f"Checking that template {template_id} was added") res = await resp.json() - assert res["drafts"][0]["accessionId"] == draft_id, "draft added does not exists" + assert len(res["templates"]) == 0, "template was not deleted from users" - await delete_draft(sess, "study", draft_id) + template_ids = await post_template_json(sess, "study", "SRP000539_list.json") + assert len(template_ids) == 2, "templates could not be added as batch" async with sess.get(f"{users_url}/{user_id}") as resp: - LOG.debug(f"Checking that draft {draft_id} was added") + LOG.debug(f"Checking that template {template_id} was added") res = await resp.json() - assert len(res["drafts"]) == 0, "draft was not deleted from users" + assert res["templates"][1]["tags"]["submissionType"] == "Form" # Delete user await delete_user(sess, user_id) diff --git a/tests/mockups.py b/tests/mockups.py index f3c77d6b3..905a4653d 100644 --- a/tests/mockups.py +++ b/tests/mockups.py @@ -3,7 +3,7 @@ import hashlib from os import urandom import yarl -import json +import ujson import cryptography.fernet from cryptography.hazmat.primitives import serialization @@ -112,7 +112,7 @@ def add_csrf_to_cookie(cookie, req, bad_sign=False): def encrypt_cookie(cookie, req): """Add encrypted cookie to request.""" - cookie_crypted = req.app["Crypt"].encrypt(json.dumps(cookie).encode("utf-8")).decode("utf-8") + cookie_crypted = req.app["Crypt"].encrypt(ujson.dumps(cookie).encode("utf-8")).decode("utf-8") req.cookies["MTD_SESSION"] = cookie_crypted diff --git a/tests/test_auth.py b/tests/test_auth.py index 4262efff6..d9df000c4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -16,7 +16,7 @@ jwt_data_bad_nonce, ) from unittest import IsolatedAsyncioTestCase -import json +import ujson class AccessHandlerFailTestCase(AioHTTPTestCase): @@ -127,11 +127,11 @@ async def test_jwk_key(self): "alg": "HS256", "k": "hJtXIZ2uSN5kbQfbtTNWbpdmhkV8FJG-Onbc6mxCcYg", } - resp = MockResponse(json.dumps(data), 200) + resp = MockResponse(ujson.dumps(data), 200) with patch("aiohttp.ClientSession.get", return_value=resp): result = await self.AccessHandler._get_key() - self.assertEqual(result, json.dumps(data)) + self.assertEqual(result, ujson.dumps(data)) async def test_set_user_fail(self): """Test set user raises exception.""" diff --git a/tests/test_files/study/SRP000539_list.json b/tests/test_files/study/SRP000539_list.json new file mode 100644 index 000000000..59b44201f --- /dev/null +++ b/tests/test_files/study/SRP000539_list.json @@ -0,0 +1,79 @@ +[ + { + "template": { + "centerName": "GEO", + "alias": "GSE10966", + "identifiers": { + "primaryId": "SRP000539", + "externalId": [ + { + "namespace": "BioProject", + "label": "primary", + "value": "PRJNA108793" + }, + { + "namespace": "GEO", + "value": "GSE10966" + } + ] + }, + "descriptor": { + "studyTitle": "Highly integrated epigenome maps in Arabidopsis - whole genome shotgun bisulfite sequencing", + "studyType": "Other", + "studyAbstract": "Part of a set of highly integrated epigenome maps for Arabidopsis thaliana. Keywords: Illumina high-throughput bisulfite sequencing Overall design: Whole genome shotgun bisulfite sequencing of wildtype Arabidopsis plants (Columbia-0), and met1, drm1 drm2 cmt3, and ros1 dml2 dml3 null mutants using the Illumina Genetic Analyzer.", + "centerProjectName": "GSE10966" + }, + "studyLinks": [ + { + "xrefDb": "pubmed", + "xrefId": "18423832" + } + ], + "studyAttributes": [ + { + "tag": "parent_bioproject", + "value": "PRJNA107265" + } + ] + }, + "tags": {"submissionType": "Form"} + }, + { + "template": { + "centerName": "GEO", + "alias": "GSE10967", + "identifiers": { + "primaryId": "SRP000538", + "externalId": [ + { + "namespace": "BioProject", + "label": "primary", + "value": "PRJNA108793" + }, + { + "namespace": "GEO", + "value": "GSE10966" + } + ] + }, + "descriptor": { + "studyTitle": "Highly integrated epigenome maps in Arabidopsis - whole genome shotgun bisulfite sequencing", + "studyType": "Other", + "studyAbstract": "Part of a set of highly integrated epigenome maps for Arabidopsis thaliana. Keywords: Illumina high-throughput bisulfite sequencing Overall design: Whole genome shotgun bisulfite sequencing of wildtype Arabidopsis plants (Columbia-0), and met1, drm1 drm2 cmt3, and ros1 dml2 dml3 null mutants using the Illumina Genetic Analyzer.", + "centerProjectName": "GSE10966" + }, + "studyLinks": [ + { + "xrefDb": "pubmed", + "xrefId": "18423832" + } + ], + "studyAttributes": [ + { + "tag": "parent_bioproject", + "value": "PRJNA107265" + } + ] + } + } +] diff --git a/tests/test_files/study/SRP000539_template.json b/tests/test_files/study/SRP000539_template.json new file mode 100644 index 000000000..79be5fe58 --- /dev/null +++ b/tests/test_files/study/SRP000539_template.json @@ -0,0 +1,38 @@ +{ + "template": { + "centerName": "GEO", + "alias": "GSE10966", + "identifiers": { + "primaryId": "SRP000539", + "externalId": [ + { + "namespace": "BioProject", + "label": "primary", + "value": "PRJNA108793" + }, + { + "namespace": "GEO", + "value": "GSE10966" + } + ] + }, + "descriptor": { + "studyTitle": "Highly integrated epigenome maps in Arabidopsis - whole genome shotgun bisulfite sequencing", + "studyType": "Other", + "studyAbstract": "Part of a set of highly integrated epigenome maps for Arabidopsis thaliana. Keywords: Illumina high-throughput bisulfite sequencing Overall design: Whole genome shotgun bisulfite sequencing of wildtype Arabidopsis plants (Columbia-0), and met1, drm1 drm2 cmt3, and ros1 dml2 dml3 null mutants using the Illumina Genetic Analyzer.", + "centerProjectName": "GSE10966" + }, + "studyLinks": [ + { + "xrefDb": "pubmed", + "xrefId": "18423832" + } + ], + "studyAttributes": [ + { + "tag": "parent_bioproject", + "value": "PRJNA107265" + } + ] + } +} diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 1bbe85595..6db123428 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -57,7 +57,7 @@ async def setUpAsync(self): self.test_user = { "userId": self.user_id, "name": "tester", - "drafts": [], + "templates": [], "folders": ["FOL12345678"], } @@ -499,7 +499,7 @@ async def test_validation_passes_for_valid_xml(self): data = self.create_submission_data(files) response = await self.client.post("/validate", data=data) self.assertEqual(response.status, 200) - self.assertIn('{"isValid": true}', await response.text()) + self.assertIn('{"isValid":true}', await response.text()) @unittest_run_loop async def test_validation_fails_bad_schema(self): @@ -719,7 +719,7 @@ async def test_get_user_works(self): @unittest_run_loop async def test_get_user_drafts_with_no_drafts(self): """Test getting user drafts when user has no drafts.""" - response = await self.client.get("/users/current?items=drafts") + response = await self.client.get("/users/current?items=templates") self.assertEqual(response.status, 200) self.MockedUserOperator().filter_user.assert_called_once() json_resp = await response.json() @@ -728,19 +728,19 @@ async def test_get_user_drafts_with_no_drafts(self): "page": 1, "size": 5, "totalPages": 0, - "totalDrafts": 0, + "totalTemplates": 0, }, - "drafts": [], + "templates": [], } self.assertEqual(json_resp, result) @unittest_run_loop - async def test_get_user_drafts_with_1_draft(self): - """Test getting user drafts when user has 1 draft.""" + async def test_get_user_templates_with_1_template(self): + """Test getting user templates when user has 1 draft.""" user = self.test_user - user["drafts"].append(self.metadata_json) - self.MockedUserOperator().filter_user.return_value = (user["drafts"], 1) - response = await self.client.get("/users/current?items=drafts") + user["templates"].append(self.metadata_json) + self.MockedUserOperator().filter_user.return_value = (user["templates"], 1) + response = await self.client.get("/users/current?items=templates") self.assertEqual(response.status, 200) self.MockedUserOperator().filter_user.assert_called_once() json_resp = await response.json() @@ -749,9 +749,9 @@ async def test_get_user_drafts_with_1_draft(self): "page": 1, "size": 5, "totalPages": 1, - "totalDrafts": 1, + "totalTemplates": 1, }, - "drafts": [self.metadata_json], + "templates": [self.metadata_json], } self.assertEqual(json_resp, result) @@ -776,12 +776,12 @@ async def test_get_user_folder_list(self): @unittest_run_loop async def test_get_user_items_with_bad_param(self): - """Test that error is raised if items parameter in query is not drafts or folders.""" + """Test that error is raised if items parameter in query is not templates or folders.""" response = await self.client.get("/users/current?items=wrong_thing") self.assertEqual(response.status, 400) json_resp = await response.json() self.assertEqual( - json_resp["detail"], "wrong_thing is a faulty item parameter. Should be either folders or drafts" + json_resp["detail"], "wrong_thing is a faulty item parameter. Should be either folders or templates" ) @unittest_run_loop @@ -807,7 +807,7 @@ async def test_update_user_fails_with_wrong_key(self): async def test_update_user_passes(self): """Test that user object would update with correct keys.""" self.MockedUserOperator().update_user.return_value = self.user_id - data = [{"op": "add", "path": "/drafts/-", "value": [{"accessionId": "3", "schema": "sample"}]}] + data = [{"op": "add", "path": "/templates/-", "value": [{"accessionId": "3", "schema": "sample"}]}] response = await self.client.patch("/users/current", json=data) self.MockedUserOperator().update_user.assert_called_once() self.assertEqual(response.status, 200) diff --git a/tests/test_parser.py b/tests/test_parser.py index 8b0f63544..3ce80b8b7 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -9,7 +9,7 @@ class ParserTestCase(unittest.TestCase): - """API endpoint class test cases.""" + """Parser Test Cases.""" TESTFILES_ROOT = Path(__file__).parent / "test_files" diff --git a/tests/test_server.py b/tests/test_server.py index dd3e29f8b..603dfcfbd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -43,7 +43,7 @@ async def test_init(self): async def test_api_routes_are_set(self): """Test correct amount of api (no frontend) routes is set.""" server = await self.get_application() - self.assertIs(len(server.router.resources()), 19) + self.assertIs(len(server.router.resources()), 18) @unittest_run_loop async def test_frontend_routes_are_set(self): diff --git a/tox.ini b/tox.ini index 8159d3ddd..e372f4aee 100644 --- a/tox.ini +++ b/tox.ini @@ -30,6 +30,7 @@ deps = -rrequirements.txt mypy types-python-dateutil + types-ujson # Mypy fails if 3rd party library doesn't have type hints configured. # Alternative to ignoring imports would be to write custom stub files, which # could be done at some point.