From a613169bf79a87cfd428d62dc4b1f38af101cd27 Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Fri, 3 Sep 2021 00:45:27 +0300 Subject: [PATCH 1/9] fix typos for operators.py and test_parser --- metadata_backend/api/operators.py | 2 +- tests/test_parser.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/metadata_backend/api/operators.py b/metadata_backend/api/operators.py index 9d5924b3a..7535b15cf 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) 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" From 539b15925450b87495e44d8ed5726fb0050238fe Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Fri, 3 Sep 2021 00:52:25 +0300 Subject: [PATCH 2/9] switch to ujson --- metadata_backend/api/handlers.py | 49 ++++++++++++++++----------- metadata_backend/api/health.py | 6 ++-- metadata_backend/api/middlewares.py | 11 +++--- metadata_backend/conf/conf.py | 4 +-- metadata_backend/helpers/logger.py | 4 +-- metadata_backend/helpers/validator.py | 10 +++--- tests/mockups.py | 4 +-- tests/test_auth.py | 6 ++-- tests/test_handlers.py | 2 +- 9 files changed, 54 insertions(+), 42 deletions(-) diff --git a/metadata_backend/api/handlers.py b/metadata_backend/api/handlers.py index a0ce381f0..58454e51d 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 @@ -179,7 +180,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 +198,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 +252,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 +261,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 +279,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 +300,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,7 +332,7 @@ 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}") LOG.info(f"POST object with accesssion ID {accession_id} in schema {collection} was successful.") @@ -427,7 +431,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,7 +472,7 @@ 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") @@ -557,7 +561,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 +570,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 +604,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 +629,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 +661,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 +693,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") @@ -797,7 +804,7 @@ async def get_user(self, req: Request) -> Response: # Return only list of drafts 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", @@ -808,7 +815,9 @@ async def get_user(self, req: Request) -> Response: 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 +840,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") @@ -991,7 +1000,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 +1076,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..657b8e96d 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 @@ -161,7 +161,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 +176,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 +229,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 +237,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/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/validator.py b/metadata_backend/helpers/validator.py index 4e969b2b7..beb919239 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 @@ -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 @@ -60,7 +60,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}." @@ -76,7 +76,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/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_handlers.py b/tests/test_handlers.py index 1bbe85595..0b8e1d536 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -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): From 293209b220c2c4bcdadffb42a889e45f51e02822 Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Fri, 3 Sep 2021 02:28:54 +0300 Subject: [PATCH 3/9] add templates api endpoint add information to specification add new template handlers adjust operators and interaction with drafts --- docs/specification.yml | 132 ++++++++++++++++++- metadata_backend/api/handlers.py | 134 ++++++++++++++++---- metadata_backend/api/middlewares.py | 1 + metadata_backend/api/operators.py | 14 +- metadata_backend/helpers/schemas/users.json | 3 +- metadata_backend/server.py | 28 +++- tests/integration/run_tests.py | 92 ++++++++++---- tests/test_handlers.py | 28 ++-- tests/test_server.py | 2 +- tox.ini | 1 + 10 files changed, 355 insertions(+), 80 deletions(-) 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 58454e51d..f0bebf4ea 100644 --- a/metadata_backend/api/handlers.py +++ b/metadata_backend/api/handlers.py @@ -90,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: @@ -334,7 +335,7 @@ async def post_object(self, req: Request) -> Response: 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, @@ -381,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) @@ -477,6 +472,101 @@ async def patch_object(self, req: Request) -> Response: 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) + + operator = Operator(db_client) + + accession_id = await operator.create_metadata_object(collection, content) + + 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 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.""" @@ -744,9 +834,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") @@ -769,7 +859,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): @@ -790,7 +880,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": @@ -801,7 +891,7 @@ 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=ujson.dumps(result, escape_forward_slashes=False), @@ -810,7 +900,7 @@ async def get_user(self, req: Request) -> Response: 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) @@ -870,7 +960,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) @@ -896,13 +986,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) diff --git a/metadata_backend/api/middlewares.py b/metadata_backend/api/middlewares.py index 657b8e96d..beaae4127 100644 --- a/metadata_backend/api/middlewares.py +++ b/metadata_backend/api/middlewares.py @@ -84,6 +84,7 @@ async def check_login(request: Request, handler: Callable) -> StreamResponse: controlled_paths = [ "/schemas", "/drafts", + "/templates", "/validate", "/publish", "/submit", diff --git a/metadata_backend/api/operators.py b/metadata_backend/api/operators.py index 7535b15cf..dade951fd 100644 --- a/metadata_backend/api/operators.py +++ b/metadata_backend/api/operators.py @@ -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 @@ -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/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/server.py b/metadata_backend/server.py index 49de9e973..5fa1ec96c 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,48 @@ 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.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/tests/integration/run_tests.py b/tests/integration/run_tests.py index f1b56c7aa..5d87bd695 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" @@ -287,6 +288,47 @@ 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() + 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 draft_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 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 draft_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. @@ -858,7 +900,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 """ @@ -869,33 +911,33 @@ async def test_getting_user_items(sess): 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"}} + # Patch user to have a templates + template_id = await post_template_json(sess, "study", "SRP000539.json") + patch_templates_user = [ + {"op": "add", "path": "/templates/-", "value": {"accessionId": template_id, "schema": "template-study"}} ] - await patch_user(sess, user_id, real_user_id, patch_drafts_user) + await patch_user(sess, user_id, real_user_id, patch_templates_user) # 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: @@ -936,7 +978,7 @@ async def test_crud_users_works(sess): 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"} @@ -961,22 +1003,22 @@ 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.json") + patch_templates_user = [ + {"op": "add", "path": "/templates/-", "value": {"accessionId": template_id, "schema": "template-study"}} ] - await patch_user(sess, user_id, real_user_id, patch_drafts_user) + await patch_user(sess, user_id, real_user_id, patch_templates_user) 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 res["templates"][0]["accessionId"] == template_id, "added template does not exists" - await delete_draft(sess, "study", draft_id) + 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 len(res["drafts"]) == 0, "draft was not deleted from users" + assert len(res["templates"]) == 0, "template was not deleted from users" # Delete user await delete_user(sess, user_id) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 0b8e1d536..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"], } @@ -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_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 b724108db..1f1e93206 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. From c69fc8aea0fdd8991eb7e5f13a82eaae44870ae7 Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Fri, 3 Sep 2021 02:29:12 +0300 Subject: [PATCH 4/9] add requirements for ujson build --- Dockerfile | 4 ++-- requirements.txt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) 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/requirements.txt b/requirements.txt index 191c5e38a..3f19aaf6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ python-dateutil==2.8.2 uvloop==0.16.0 xmlschema==1.7.0 Authlib==0.15.4 +ujson==4.1.0 From 1d9d4d67a12f717e01e3eddbd1e7aeca05b9b050 Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Fri, 3 Sep 2021 02:47:57 +0300 Subject: [PATCH 5/9] checking the draft was deleted after publication --- tests/integration/run_tests.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/integration/run_tests.py b/tests/integration/run_tests.py index 5d87bd695..fa600b265 100644 --- a/tests/integration/run_tests.py +++ b/tests/integration/run_tests.py @@ -197,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 @@ -206,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) @@ -704,6 +704,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() From 3743f1fdf057c584402f6ded9514efce7c15edbd Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Fri, 3 Sep 2021 16:30:58 +0300 Subject: [PATCH 6/9] add patch & handling of multiple obje in templates --- metadata_backend/api/handlers.py | 50 ++++++++++++++- metadata_backend/api/operators.py | 4 +- metadata_backend/server.py | 1 + tests/integration/run_tests.py | 45 ++++++++----- tests/test_files/study/SRP000539_list.json | 73 ++++++++++++++++++++++ 5 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 tests/test_files/study/SRP000539_list.json diff --git a/metadata_backend/api/handlers.py b/metadata_backend/api/handlers.py index f0bebf4ea..47837016c 100644 --- a/metadata_backend/api/handlers.py +++ b/metadata_backend/api/handlers.py @@ -517,11 +517,29 @@ async def post_template(self, req: Request) -> Response: 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) - accession_id = await operator.create_metadata_object(collection, content) + if isinstance(content, list): + tmpl_list = [] + for tmpl in content: + accession_id = await operator.create_metadata_object(collection, tmpl) + await user_op.assign_objects( + current_user, "templates", [{"accessionId": accession_id, "schema": collection}] + ) + tmpl_list.append({"accessionId": accession_id}) + + body = ujson.dumps(tmpl_list, escape_forward_slashes=False) + else: + accession_id = await operator.create_metadata_object(collection, content) + await user_op.assign_objects( + current_user, "templates", [{"accessionId": accession_id, "schema": collection}] + ) + + body = ujson.dumps({"accessionId": accession_id}, escape_forward_slashes=False) - 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.") @@ -532,6 +550,34 @@ async def post_template(self, req: Request) -> Response: 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. diff --git a/metadata_backend/api/operators.py b/metadata_backend/api/operators.py index dade951fd..8c3d014eb 100644 --- a/metadata_backend/api/operators.py +++ b/metadata_backend/api/operators.py @@ -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: diff --git a/metadata_backend/server.py b/metadata_backend/server.py index 5fa1ec96c..c39d3364e 100644 --- a/metadata_backend/server.py +++ b/metadata_backend/server.py @@ -91,6 +91,7 @@ async def init() -> web.Application: # 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), diff --git a/tests/integration/run_tests.py b/tests/integration/run_tests.py index fa600b265..e351f35c7 100644 --- a/tests/integration/run_tests.py +++ b/tests/integration/run_tests.py @@ -300,7 +300,10 @@ async def post_template_json(sess, schema, filename): 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() - return ans["accessionId"] + if isinstance(ans, list): + return ans + else: + return ans["accessionId"] async def get_template(sess, schema, template_id): @@ -308,7 +311,7 @@ async def get_template(sess, schema, template_id): :param sess: HTTP session in which request call is made :param schema: name of the schema (folder) used for testing - :param draft_id: id of the draft + :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") @@ -317,12 +320,29 @@ async def get_template(sess, schema, template_id): 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 draft_id: id of the draft + :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}") @@ -911,15 +931,9 @@ 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 templates + # Add template to user template_id = await post_template_json(sess, "study", "SRP000539.json") - patch_templates_user = [ - {"op": "add", "path": "/templates/-", "value": {"accessionId": template_id, "schema": "template-study"}} - ] - await patch_user(sess, user_id, real_user_id, patch_templates_user) # Test querying for list of user draft templates async with sess.get(f"{users_url}/{user_id}?items=templates") as resp: @@ -974,8 +988,7 @@ 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() @@ -1007,10 +1020,7 @@ async def test_crud_users_works(sess): assert delete_folder_id not in res["folders"], "delete folder still exists at user" template_id = await post_template_json(sess, "study", "SRP000539.json") - patch_templates_user = [ - {"op": "add", "path": "/templates/-", "value": {"accessionId": template_id, "schema": "template-study"}} - ] - await patch_user(sess, user_id, real_user_id, patch_templates_user) + 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() @@ -1023,6 +1033,9 @@ async def test_crud_users_works(sess): res = await resp.json() assert len(res["templates"]) == 0, "template was not deleted from users" + template_ids = await post_template_json(sess, "study", "SRP000539_list.json") + assert len(template_ids) == 2, "templates could not be added as batch" + # Delete user await delete_user(sess, user_id) # 401 means API is innacessible thus session ended diff --git a/tests/test_files/study/SRP000539_list.json b/tests/test_files/study/SRP000539_list.json new file mode 100644 index 000000000..b852eed88 --- /dev/null +++ b/tests/test_files/study/SRP000539_list.json @@ -0,0 +1,73 @@ +[{ + "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" + } + ] + }, { + "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" + } + ] + } + + ] From d0bbb944aa8f895d67ee1ec91531490496698204 Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Mon, 4 Oct 2021 16:29:28 +0300 Subject: [PATCH 7/9] add test for template tags --- tests/integration/run_tests.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/integration/run_tests.py b/tests/integration/run_tests.py index e351f35c7..fe840450d 100644 --- a/tests/integration/run_tests.py +++ b/tests/integration/run_tests.py @@ -1008,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") @@ -1025,6 +1026,22 @@ async def test_crud_users_works(sess): 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_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) From 15b93b914d539bac996181b376a17b4c5496ed44 Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Mon, 4 Oct 2021 17:30:38 +0300 Subject: [PATCH 8/9] add option for tags when POSTing templates --- metadata_backend/api/handlers.py | 28 ++-- tests/integration/run_tests.py | 4 +- tests/test_files/study/SRP000539_list.json | 122 +++++++++--------- .../test_files/study/SRP000539_template.json | 38 ++++++ 4 files changed, 123 insertions(+), 69 deletions(-) create mode 100644 tests/test_files/study/SRP000539_template.json diff --git a/metadata_backend/api/handlers.py b/metadata_backend/api/handlers.py index 47837016c..2f68b18d8 100644 --- a/metadata_backend/api/handlers.py +++ b/metadata_backend/api/handlers.py @@ -524,19 +524,29 @@ async def post_template(self, req: Request) -> Response: if isinstance(content, list): tmpl_list = [] - for tmpl in content: - accession_id = await operator.create_metadata_object(collection, tmpl) - await user_op.assign_objects( - current_user, "templates", [{"accessionId": accession_id, "schema": collection}] - ) + 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: - accession_id = await operator.create_metadata_object(collection, content) - await user_op.assign_objects( - current_user, "templates", [{"accessionId": accession_id, "schema": collection}] - ) + 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) diff --git a/tests/integration/run_tests.py b/tests/integration/run_tests.py index fe840450d..45ed1bc11 100644 --- a/tests/integration/run_tests.py +++ b/tests/integration/run_tests.py @@ -933,7 +933,7 @@ async def test_getting_user_items(sess): assert resp.status == 200, "HTTP Status code error" # Add template to user - template_id = await post_template_json(sess, "study", "SRP000539.json") + 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=templates") as resp: @@ -1020,7 +1020,7 @@ 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" - template_id = await post_template_json(sess, "study", "SRP000539.json") + 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") diff --git a/tests/test_files/study/SRP000539_list.json b/tests/test_files/study/SRP000539_list.json index b852eed88..80f55400a 100644 --- a/tests/test_files/study/SRP000539_list.json +++ b/tests/test_files/study/SRP000539_list.json @@ -1,73 +1,79 @@ -[{ - "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": [ +[ + { + "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" - } - ] - }, { - "centerName": "GEO", - "alias": "GSE10967", - "identifiers": { - "primaryId": "SRP000538", - "externalId": [ + "studyAttributes": [ { - "namespace": "BioProject", - "label": "primary", - "value": "PRJNA108793" - }, - { - "namespace": "GEO", - "value": "GSE10966" + "tag": "parent_bioproject", + "value": "PRJNA107265" } ] }, - "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": [ + "tags": {"Submission": "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" - } - ] + "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" + } + ] + } +} From 9b126b2fd1c69f653519ea2cb17e4ef66074d67e Mon Sep 17 00:00:00 2001 From: Stefan Negru Date: Mon, 4 Oct 2021 17:36:57 +0300 Subject: [PATCH 9/9] test tags for list of templates --- tests/integration/run_tests.py | 5 +++++ tests/test_files/study/SRP000539_list.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/integration/run_tests.py b/tests/integration/run_tests.py index 45ed1bc11..a7ffc997c 100644 --- a/tests/integration/run_tests.py +++ b/tests/integration/run_tests.py @@ -1053,6 +1053,11 @@ async def test_crud_users_works(sess): 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 template {template_id} was added") + res = await resp.json() + assert res["templates"][1]["tags"]["submissionType"] == "Form" + # Delete user await delete_user(sess, user_id) # 401 means API is innacessible thus session ended diff --git a/tests/test_files/study/SRP000539_list.json b/tests/test_files/study/SRP000539_list.json index 80f55400a..59b44201f 100644 --- a/tests/test_files/study/SRP000539_list.json +++ b/tests/test_files/study/SRP000539_list.json @@ -36,7 +36,7 @@ } ] }, - "tags": {"Submission": "Form"} + "tags": {"submissionType": "Form"} }, { "template": {