From 6ff89f227acf2f989f34154a11cda4f27d12e5eb Mon Sep 17 00:00:00 2001 From: ninsch3000 <36634279+ninsch3000@users.noreply.github.com> Date: Fri, 23 Oct 2020 17:02:13 +0200 Subject: [PATCH 1/4] feat: add 'POST /service-info' (#39) --- tests/test_client.py | 58 ++++++++++++++++++++++++-- trs_cli/client.py | 99 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 132 insertions(+), 25 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 74db83c..1d845fd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -37,6 +37,20 @@ MOCK_DESCRIPTOR = "CWL" MOCK_RESPONSE_INVALID = {"not": "valid"} MOCK_DIR = "/mock/directory" +MOCK_SERVICE_INFO = { + "id": "TEMPID1", + "name": "TEMP_STUB", + "type": { + "group": "TEMP_GROUP", + "artifact": "TEMP_ARTIFACT", + "version": "v1" + }, + "organization": { + "name": "Parent organization", + "url": "https://parent.abc" + }, + "version": "0.0.0" +} MOCK_ERROR = { "code": 400, "message": "BadRequest", @@ -194,6 +208,33 @@ def test_trs_uri_http(self): assert cli.uri == f"http://{MOCK_DOMAIN}:80/ga4gh/trs/v2" +class TestPostServiceInfo: + """Test poster for service info.""" + + cli = TRSClient( + uri=MOCK_TRS_URI, + token=MOCK_TOKEN, + ) + endpoint = ( + f"{cli.uri}/service-info" + ) + + def test_success(self, requests_mock): + """Returns 200 response.""" + requests_mock.post(self.endpoint) + r = self.cli.post_service_info( + payload=MOCK_SERVICE_INFO + ) + assert r is None + + def test_success_ValidationError(self): + """Raises validation error when incorrect input is provided""" + with pytest.raises(ValidationError): + self.cli.post_service_info( + payload=MOCK_RESPONSE_INVALID + ) + + class TestPostToolClass: """Test poster for tool classes.""" @@ -896,15 +937,25 @@ def test_get_str_validation(self, requests_mock): requests_mock.get(self.endpoint, json=MOCK_ID) response = self.cli._send_request_and_validate_response( url=MOCK_API, + validation_class_ok=str, ) assert response == MOCK_ID + def test_get_none_validation(self, requests_mock): + """Test for getter with `None` response.""" + requests_mock.get(self.endpoint, json=MOCK_ID) + response = self.cli._send_request_and_validate_response( + url=MOCK_API, + validation_class_ok=None, + ) + assert response is None + def test_get_model_validation(self, requests_mock): """Test for getter with model response.""" requests_mock.get(self.endpoint, json=MOCK_TOOL) response = self.cli._send_request_and_validate_response( url=MOCK_API, - validation_class_200=Tool, + validation_class_ok=Tool, ) assert response == MOCK_TOOL @@ -913,7 +964,7 @@ def test_get_list_validation(self, requests_mock): requests_mock.get(self.endpoint, json=[MOCK_TOOL]) response = self.cli._send_request_and_validate_response( url=MOCK_API, - validation_class_200=(Tool, ), + validation_class_ok=(Tool, ), ) assert response == [MOCK_TOOL] @@ -924,6 +975,7 @@ def test_post_validation(self, requests_mock): url=MOCK_API, method='post', payload=self.payload, + validation_class_ok=str, ) assert response == MOCK_ID @@ -933,7 +985,7 @@ def test_get_success_invalid(self, requests_mock): with pytest.raises(InvalidResponseError): self.cli._send_request_and_validate_response( url=MOCK_API, - validation_class_200=Error, + validation_class_ok=Error, ) def test_error_response(self, requests_mock): diff --git a/trs_cli/client.py b/trs_cli/client.py index 9e08f70..6f8cdf8 100644 --- a/trs_cli/client.py +++ b/trs_cli/client.py @@ -6,7 +6,7 @@ import re import socket import sys -from typing import (Dict, List, Optional, Tuple, Union) +from typing import (Dict, List, Optional, Tuple, Type, Union) import urllib3 from urllib.parse import quote @@ -26,6 +26,7 @@ Error, FileType, FileWrapper, + ServiceRegister, Tool, ToolClass, ToolClassRegister, @@ -103,6 +104,51 @@ def __init__( self.headers = {} logger.info(f"Instantiated client for: {self.uri}") + def post_service_info( + self, + payload: Dict, + token: Optional[str] = None, + ) -> None: + """Register service info. + + Arguments: + payload: Service info data. + token: Bearer token for authentication. Set if required by TRS + implementation and if not provided when instatiating client or + if expired. + + Raises: + requests.exceptions.ConnectionError: A connection to the provided + TRS instance could not be established. + pydantic.ValidationError: The object data payload could not + be validated against the API schema. + trs_cli.errors.InvalidResponseError: The response could not be + validated against the API schema. + """ + # validate requested content type and get request headers + self._get_headers( + content_type='application/json', + token=token, + ) + + # build request URL + url = f"{self.uri}/service-info" + logger.info(f"Connecting to '{url}'...") + + # validate payload + ServiceRegister(**payload).dict() + + # send request + response = self._send_request_and_validate_response( + url=url, + method='post', + payload=payload, + ) + logger.info( + "Registered service info" + ) + return response # type: ignore + def post_tool_class( self, payload: Dict, @@ -152,6 +198,7 @@ def post_tool_class( url=url, method='post', payload=payload, + validation_class_ok=str, ) logger.info( "Registered tool class" @@ -201,6 +248,7 @@ def delete_tool_class( response = self._send_request_and_validate_response( url=url, method='delete', + validation_class_ok=str, ) logger.info( "Deleted tool class" @@ -256,6 +304,7 @@ def post_tool( url=url, method='post', payload=payload, + validation_class_ok=str, ) logger.info( "Registered tool" @@ -306,6 +355,7 @@ def delete_tool( response = self._send_request_and_validate_response( url=url, method='delete', + validation_class_ok=str, ) logger.info( "Deleted tool" @@ -363,6 +413,7 @@ def post_version( url=url, method='post', payload=payload, + validation_class_ok=str, ) logger.info( "Registered tool version" @@ -410,7 +461,7 @@ def get_tool_classes( # send request response = self._send_request_and_validate_response( url=url, - validation_class_200=(ToolClass, ), + validation_class_ok=(ToolClass, ), ) logger.info( "Retrieved tool classes" @@ -510,7 +561,7 @@ def get_tools( # send request response = self._send_request_and_validate_response( url=url, - validation_class_200=(Tool, ), + validation_class_ok=(Tool, ), ) logger.info( "Retrieved tools" @@ -564,7 +615,7 @@ def get_tool( # send request response = self._send_request_and_validate_response( url=url, - validation_class_200=Tool, + validation_class_ok=Tool, ) logger.info( "Retrieved tool" @@ -619,7 +670,7 @@ def get_versions( # send request response = self._send_request_and_validate_response( url=url, - validation_class_200=(ToolVersion, ), + validation_class_ok=(ToolVersion, ), ) logger.info( "Retrieved tool versions" @@ -684,7 +735,7 @@ def get_version( # send request response = self._send_request_and_validate_response( url=url, - validation_class_200=ToolVersion, + validation_class_ok=ToolVersion, ) logger.info( "Retrieved tool version" @@ -759,7 +810,7 @@ def get_descriptor( # send request response = self._send_request_and_validate_response( url=url, - validation_class_200=FileWrapper, + validation_class_ok=FileWrapper, ) logger.info( "Retrieved descriptor" @@ -840,7 +891,7 @@ def get_descriptor_by_path( # send request response = self._send_request_and_validate_response( url=url, - validation_class_200=FileWrapper, + validation_class_ok=FileWrapper, ) logger.info( "Retrieved descriptor" @@ -921,7 +972,7 @@ def get_files( # send request response = self._send_request_and_validate_response( url=url, - validation_class_200=(ToolFile, ), + validation_class_ok=(ToolFile, ), ) logger.info( "Retrieved files" @@ -1166,22 +1217,22 @@ def _validate_content_type( def _send_request_and_validate_response( self, url: str, - validation_class_200: Optional[ - Union[ModelMetaclass, Tuple[ModelMetaclass]] + validation_class_ok: Optional[ + Union[ModelMetaclass, Tuple[ModelMetaclass], Type[str]] ] = None, validation_class_error: ModelMetaclass = Error, method: str = 'get', payload: Optional[Dict] = None, - ) -> Union[str, ModelMetaclass, List[ModelMetaclass]]: + ) -> Optional[Union[str, ModelMetaclass, List[ModelMetaclass]]]: """Send a HTTP equest, validate the response and handle potential exceptions. Arguments: url: The URL to send the request to. - validation_class_200: Type/class to be used to validate a 200 + validation_class_ok: Type/class to be used to validate a 200 response. Either a Pydantic model, a tuple with a Pydantic - model as the only item (for list responses), or `None` (for - string responses). + model as the only item (for list responses), `str` (for + string responses), or `None` for no content responses. validation_class_error: Pydantic model to be used to validate non-200 responses. method: HTTP method to use for the request. @@ -1191,10 +1242,12 @@ def _send_request_and_validate_response( """ # Process parameters validation_type = "model" - if isinstance(validation_class_200, tuple): - validation_class_200 = validation_class_200[0] + if isinstance(validation_class_ok, tuple): + validation_class_ok = validation_class_ok[0] validation_type = "list" - elif validation_class_200 is None: + elif validation_class_ok is None: + validation_type = None + elif validation_class_ok is str: validation_type = "str" try: request_func = eval('.'.join(['requests', method])) @@ -1220,7 +1273,7 @@ def _send_request_and_validate_response( raise requests.exceptions.ConnectionError( "Could not connect to API endpoint" ) - if not response.status_code == 200: + if response.status_code not in [200, 201]: try: logger.warning("Received error response") return validation_class_error(**response.json()) @@ -1235,12 +1288,14 @@ def _send_request_and_validate_response( try: if validation_type == "list": return [ - validation_class_200(**obj) for obj in response.json() - ] + validation_class_ok(**obj) for obj in response.json() + ] # type: ignore elif validation_type == "str": return str(response.json()) + elif validation_type is None: + return None else: - return validation_class_200(**response.json()) + return validation_class_ok(**response.json()) except ( json.decoder.JSONDecodeError, pydantic.ValidationError, From bd504c5c36455edb0d8aca9666fef9932e5ca3a3 Mon Sep 17 00:00:00 2001 From: ninsch3000 <36634279+ninsch3000@users.noreply.github.com> Date: Fri, 23 Oct 2020 21:09:49 +0200 Subject: [PATCH 2/4] feat: add 'GET /service-info' (#40) --- tests/test_client.py | 16 +++++++++++++++ trs_cli/client.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 1d845fd..98d683c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -235,6 +235,22 @@ def test_success_ValidationError(self): ) +class TestGetServiceInfo: + """Test getter for service with a given id.""" + + cli = TRSClient( + uri=MOCK_TRS_URI, + token=MOCK_TOKEN, + ) + endpoint = f"{cli.uri}/service-info" + + def test_success(self, requests_mock): + """Returns 200 response.""" + requests_mock.get(self.endpoint, json=MOCK_SERVICE_INFO) + r = self.cli.get_service_info() + assert r.dict()['id'] == MOCK_SERVICE_INFO['id'] + + class TestPostToolClass: """Test poster for tool classes.""" diff --git a/trs_cli/client.py b/trs_cli/client.py index 6f8cdf8..9ddb2fc 100644 --- a/trs_cli/client.py +++ b/trs_cli/client.py @@ -26,6 +26,7 @@ Error, FileType, FileWrapper, + Service, ServiceRegister, Tool, ToolClass, @@ -149,6 +150,54 @@ def post_service_info( ) return response # type: ignore + def get_service_info( + self, + accept: str = 'application/json', + token: Optional[str] = None, + ) -> Union[Service, Error]: + """Retrieve service info. + + Arguments: + accept: Requested content type. + token: Bearer token for authentication. Set if required by TRS + implementation and if not provided when instatiating client or + if expired. + + Returns: + Unmarshalled TRS response as either an instance of `Service` + in case of a `200` response, or an instance of `Error` for all + other JSON reponses. + + Raises: + requests.exceptions.ConnectionError: A connection to the provided + TRS instance could not be established. + trs_cli.errors.InvalidResponseError: The response could not be + validated against the API schema. + """ + # validate requested content type and get request headers + self._validate_content_type( + requested_type=accept, + available_types=['application/json', 'text/plain'], + ) + self._get_headers( + content_accept=accept, + token=token, + ) + + # build request URL + url = f"{self.uri}/service-info" + logger.info(f"Connecting to '{url}'...") + + # send request + response = self._send_request_and_validate_response( + url=url, + validation_class_ok=Service, + ) + logger.info( + "Retrieved service info" + ) + return response # type: ignore + def post_tool_class( self, payload: Dict, From 6a93ce3581a0ca97be4264e5ec492fa4f44d0753 Mon Sep 17 00:00:00 2001 From: Krish Agarwal Date: Mon, 26 Oct 2020 04:21:21 +0530 Subject: [PATCH 3/4] feat: add "DELETE .../versions/{version_id}" (#30) Co-authored-by: Alex Kanitz --- tests/test_client.py | 23 +++++++++++++- trs_cli/client.py | 72 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 98d683c..f596519 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -345,7 +345,7 @@ def test_success(self, requests_mock): assert r == MOCK_ID -class TestPostToolVersion: +class TestPostVersion: """Test poster for tool versions.""" cli = TRSClient( @@ -374,6 +374,27 @@ def test_success_ValidationError(self): ) +class TestDeleteVersion: + """Test delete for tool version.""" + + cli = TRSClient( + uri=MOCK_TRS_URI, + token=MOCK_TOKEN, + ) + endpoint = ( + f"{cli.uri}/tools/{MOCK_ID}/versions/{MOCK_ID}" + ) + + def test_success(self, monkeypatch, requests_mock): + """Returns 200 response.""" + requests_mock.delete(self.endpoint, json=MOCK_ID) + r = self.cli.delete_version( + id=MOCK_ID, + version_id=MOCK_ID + ) + assert r == MOCK_ID + + class TestGetToolClasses: """Test getter for tool classes.""" diff --git a/trs_cli/client.py b/trs_cli/client.py index 9ddb2fc..720c6b7 100644 --- a/trs_cli/client.py +++ b/trs_cli/client.py @@ -367,7 +367,10 @@ def delete_tool( token: Optional[str] = None, ) -> str: """Delete a tool. - TRS URI pointing to a given tool to be deleted + + id: A unique identifier of the tool to be deleted, scoped to this + registry OR a TRS URI. For more information on TRS URIs, cf. + https://ga4gh.github.io/tool-registry-service-schemas/DataModel/#trs_uris accept: Requested content type. token: Bearer token for authentication. Set if required by TRS implementation and if not provided when instatiating client or @@ -469,6 +472,72 @@ def post_version( ) return response # type: ignore + def delete_version( + self, + id: str, + version_id: Optional[str] = None, + accept: str = 'application/json', + token: Optional[str] = None, + ) -> str: + """Delete a tool version. + + Arguments: + id: A unique identifier of the tool whose version is to be deleted, + scoped to this registry OR a TRS URI. If a TRS URI is passed + and includes the version identifier, passing a `version_id` is + optional. For more information on TRS URIs, cf. + https://ga4gh.github.io/tool-registry-service-schemas/DataModel/#trs_uris + version_id: Identifier of the tool version to be deleted, scoped to + this registry. It is optional if a TRS URI is passed and + includes version information. If provided nevertheless, then + the `version_id` retrieved from the TRS URI is overridden. + accept: Requested content type. + token: Bearer token for authentication. Set if required by TRS + implementation and if not provided when instatiating client or + if expired. + + Returns: + ID of deleted TRS tool version in case of a `200` response, or an + instance of `Error` for all other responses. + + Raises: + requests.exceptions.ConnectionError: A connection to the provided + TRS instance could not be established. + trs_cli.errors.InvalidResponseError: The response could not be + validated against the API schema. + """ + # validate requested content type and get request headers + self._validate_content_type( + requested_type=accept, + available_types=['application/json'], + ) + self._get_headers( + content_accept=accept, + content_type='application/json', + token=token, + ) + + # get/sanitize tool and version identifiers + _id, _version_id = self._get_tool_id_version_id( + tool_id=id, + version_id=version_id, + ) + + # build request URL + url = f"{self.uri}/tools/{_id}/versions/{_version_id}" + logger.info(f"Connecting to '{url}'...") + + # send request + response = self._send_request_and_validate_response( + url=url, + method='delete', + validation_class_ok=str, + ) + logger.info( + "Deleted tool version" + ) + return response # type: ignore + def get_tool_classes( self, accept: str = 'application/json', @@ -627,6 +696,7 @@ def get_tool( Arguments: id: A unique identifier of the tool, scoped to this registry OR + a TRS URI. For more information on TRS URIs, cf. https://ga4gh.github.io/tool-registry-service-schemas/DataModel/#trs_uris accept: Requested content type. token: Bearer token for authentication. Set if required by TRS From 9d01e324fb8aa04b6549884ad983d09845471539 Mon Sep 17 00:00:00 2001 From: Krish Agarwal Date: Mon, 26 Oct 2020 07:01:03 +0530 Subject: [PATCH 4/4] feat: add "PUT /toolClasses/{id}" (#34) Co-authored-by: Alex Kanitz --- tests/test_client.py | 29 +++++++++++++++++++++ trs_cli/client.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index f596519..2d90542 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -278,6 +278,35 @@ def test_success_ValidationError(self): ) +class TestPutToolClass: + """Test putter for tool classes.""" + + cli = TRSClient( + uri=MOCK_TRS_URI, + token=MOCK_TOKEN, + ) + endpoint = ( + f"{cli.uri}/toolClasses/{MOCK_ID}" + ) + + def test_success(self, requests_mock): + """Returns 200 response.""" + requests_mock.put(self.endpoint, json=MOCK_ID) + r = self.cli.put_tool_class( + id=MOCK_ID, + payload=MOCK_TOOL_CLASS_POST + ) + assert r == MOCK_ID + + def test_success_ValidationError(self): + """Raises validation error when incorrect input is provided""" + with pytest.raises(ValidationError): + self.cli.put_tool_class( + id=MOCK_ID, + payload=MOCK_RESPONSE_INVALID + ) + + class TestDeleteToolClass: """Test delete for tool classes.""" diff --git a/trs_cli/client.py b/trs_cli/client.py index 720c6b7..a534e7c 100644 --- a/trs_cli/client.py +++ b/trs_cli/client.py @@ -254,6 +254,67 @@ def post_tool_class( ) return response # type: ignore + def put_tool_class( + self, + id: str, + payload: Dict, + accept: str = 'application/json', + token: Optional[str] = None, + ) -> str: + """ + Create a tool class with a predefined unique ID. + Overwrites any existing tool object with the same ID. + + Arguments: + id: Identifier of tool class to be created/overwritten. + payload: Tool class data. + accept: Requested content type. + token: Bearer token for authentication. Set if required by TRS + implementation and if not provided when instatiating client or + if expired. + + Returns: + ID of registered TRS toolClass in case of a `200` response, or an + instance of `Error` for all other responses. + + Raises: + requests.exceptions.ConnectionError: A connection to the provided + TRS instance could not be established. + pydantic.ValidationError: The object data payload could not + be validated against the API schema. + trs_cli.errors.InvalidResponseError: The response could not be + validated against the API schema. + """ + # validate requested content type and get request headers + self._validate_content_type( + requested_type=accept, + available_types=['application/json'], + ) + self._get_headers( + content_accept=accept, + content_type='application/json', + token=token, + ) + + # build request URL + url = f"{self.uri}/toolClasses/{id}" + logger.info(f"Connecting to '{url}'...") + + # validate payload + ToolClassRegister(**payload).dict() + + # send request + response = self._send_request_and_validate_response( + url=url, + method='put', + payload=payload, + validation_class_ok=str, + ) + logger.info( + f"Registered tool class with id : {id}" + ) + return response # type: ignore + def delete_tool_class( self, id: str,