From a5803bd8d3c9a14a67234f18077e49e1f5b0971a Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Fri, 9 Aug 2024 15:26:37 +0300 Subject: [PATCH 01/23] feat: added project v1 to rest conversion class --- snyk/client.py | 58 ++++++++++++++++++++++++++++---------------------- snyk/models.py | 30 ++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 30 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index 3b3a3d3..639c9ce 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -1,6 +1,6 @@ import logging import urllib.parse -from typing import Any, List, Optional +from typing import Any, List, Optional, Dict from urllib.parse import parse_qs, urlparse import requests @@ -21,17 +21,17 @@ class SnykClient(object): USER_AGENT = "pysnyk/%s" % __version__ def __init__( - self, - token: str, - url: Optional[str] = None, - rest_api_url: Optional[str] = None, - user_agent: Optional[str] = USER_AGENT, - debug: bool = False, - tries: int = 1, - delay: int = 1, - backoff: int = 2, - verify: bool = True, - version: Optional[str] = None, + self, + token: str, + url: Optional[str] = None, + rest_api_url: Optional[str] = None, + user_agent: Optional[str] = USER_AGENT, + debug: bool = False, + tries: int = 1, + delay: int = 1, + backoff: int = 2, + verify: bool = True, + version: Optional[str] = None, ): self.api_token = token self.api_url = url or self.API_URL @@ -58,12 +58,12 @@ def __init__( logging.basicConfig(level=logging.DEBUG) def request( - self, - method, - url: str, - headers: object, - params: object = None, - json: object = None, + self, + method, + url: str, + headers: object, + params: object = None, + json: object = None, ) -> requests.Response: if params and json: @@ -123,12 +123,12 @@ def put(self, path: str, body: Any, headers: dict = {}) -> requests.Response: return resp def get( - self, - path: str, - params: dict = None, - version: str = None, - exclude_version: bool = False, - exclude_params: bool = False, + self, + path: str, + params: dict = None, + version: str = None, + exclude_version: bool = False, + exclude_params: bool = False, ) -> requests.Response: """ Rest (formerly v3) Compatible Snyk Client, assumes the presence of Version, either set in the client @@ -236,8 +236,8 @@ def get_rest_pages(self, path: str, params: dict = {}) -> List: if "next" in page_data["links"]: # If the next url is the same as the current url, break out of the loop if ( - "self" in page_data["links"] - and page_data["links"]["next"] == page_data["links"]["self"] + "self" in page_data["links"] + and page_data["links"]["next"] == page_data["links"]["self"] ): break else: @@ -293,3 +293,9 @@ def groups(self): # https://snyk.docs.apiary.io/#reference/reporting-api/issues/get-list-of-issues def issues(self): raise SnykNotImplementedError # pragma: no cover + + + def __convert_v1_to_rest_endpoint(self, path: str) -> str: + uuid_pattern = r'[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' + + diff --git a/snyk/models.py b/snyk/models.py index 5b0e8df..3030fad 100644 --- a/snyk/models.py +++ b/snyk/models.py @@ -288,7 +288,7 @@ def _test(self, path, contents=None, additional=None): return IssueSet.from_dict(resp.json()) def test_maven( - self, package_group_id: str, package_artifact_id: str, version: str + self, package_group_id: str, package_artifact_id: str, version: str ) -> IssueSet: path = "test/maven/%s/%s/%s?org=%s" % ( package_group_id, @@ -394,7 +394,7 @@ def _import(self, payload) -> bool: return bool(self.organization.client.post(path, payload)) def import_git( - self, owner: str, name: str, branch: str = "master", files: List[str] = [] + self, owner: str, name: str, branch: str = "master", files: List[str] = [] ): return self._import( { @@ -418,7 +418,7 @@ def import_gitlab(self, id: str, branch: str = "master", files: List[str] = []): ) def import_bitbucket( - self, project_key: str, name: str, repo_slug: str, files: List[str] = [] + self, project_key: str, name: str, repo_slug: str, files: List[str] = [] ): return self._import( { @@ -753,7 +753,7 @@ def notification_settings(self): raise SnykNotImplementedError # pragma: no cover def _aggregated_issue_to_vulnerabily( - self, issue: AggregatedIssue + self, issue: AggregatedIssue ) -> List[Vulnerability]: issue_paths = Manager.factory( IssuePaths, @@ -805,3 +805,25 @@ def _aggregated_issue_to_vulnerabily( # versions, emulate that here to preserve upstream api for version in issue.pkgVersions ] + + +class V1ToRestConversion: + def __init__(self, v1_path, rest_path, v1_verb, rest_verb): + self.v1_path = v1_path + self.rest_path = rest_path + self.v1_verb = v1_verb + self.rest_verb = rest_verb + + +class ProjectV1ToRestConversion(V1ToRestConversion): + def convert_delete_request(self): + pass + + def convert_add_tag_request(self, body: Dict[str, Any]): + pass + + def convert_delete_tag_request(self, body: Dict[str, Any]): + pass + + def convert_apply_attributes_request(self, body: Dict[str, Any]): + pass From 5cee33b3a38ee779d03f25be54dbd50b78304d72 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Fri, 9 Aug 2024 17:01:47 +0300 Subject: [PATCH 02/23] feat: added method to check if a path is project v1 --- snyk/client.py | 15 ++++++++++++--- snyk/models.py | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index 639c9ce..900ee78 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -1,4 +1,5 @@ import logging +import re import urllib.parse from typing import Any, List, Optional, Dict from urllib.parse import parse_qs, urlparse @@ -47,6 +48,7 @@ def __init__( self.delay = delay self.verify = verify self.version = version + self.__uuid_pattern = r'[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' # Ensure we don't have a trailing / if self.api_url[-1] == "/": @@ -197,6 +199,8 @@ def get( return resp def delete(self, path: str) -> requests.Response: + is_v1_project_path: bool = self.__is_v1_project_path(path) + url = f"{self.api_url}/{path}" logger.debug(f"DELETE: {url}") @@ -294,8 +298,13 @@ def groups(self): def issues(self): raise SnykNotImplementedError # pragma: no cover + def __is_v1_project_path(self, path: str) -> bool: + v1_to_rest_paths: List[str] = [ + rf"org/{self.__uuid_pattern}/project/{self.__uuid_pattern}/?" + ] - def __convert_v1_to_rest_endpoint(self, path: str) -> str: - uuid_pattern = r'[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' - + for v1_path in v1_to_rest_paths: + if re.match(v1_path, path): + return True + return False diff --git a/snyk/models.py b/snyk/models.py index 3030fad..1851b6f 100644 --- a/snyk/models.py +++ b/snyk/models.py @@ -815,7 +815,7 @@ def __init__(self, v1_path, rest_path, v1_verb, rest_verb): self.rest_verb = rest_verb -class ProjectV1ToRestConversion(V1ToRestConversion): +class ProjectV1ToRestConversion: def convert_delete_request(self): pass From 39d94867dd4522ec1172b568865d260623907ec0 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Sun, 11 Aug 2024 14:29:22 +0300 Subject: [PATCH 03/23] fix: updated delete project tests to mock rest route --- snyk/client.py | 16 +++++++++++----- snyk/managers.py | 7 +++---- snyk/models.py | 3 +-- snyk/test_models.py | 20 ++++++++++++++++---- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index 900ee78..a920aea 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -1,7 +1,7 @@ import logging import re import urllib.parse -from typing import Any, List, Optional, Dict +from typing import Any, List, Optional from urllib.parse import parse_qs, urlparse import requests @@ -200,8 +200,14 @@ def get( def delete(self, path: str) -> requests.Response: is_v1_project_path: bool = self.__is_v1_project_path(path) - - url = f"{self.api_url}/{path}" + if is_v1_project_path: + ids = re.findall(rf"{self.__uuid_pattern}", path) + path = f"orgs/{ids[0]}/projects/{ids[1]}?version={self.version if self.version else '2024-06-21'}" + url = f"{self.rest_api_url}/{path}" + else: + url = f"{self.api_url}/{path}" + # url = f"{self.api_url}/{path}" + logger.debug(f"DELETE: {url}") resp = retry_call( @@ -299,11 +305,11 @@ def issues(self): raise SnykNotImplementedError # pragma: no cover def __is_v1_project_path(self, path: str) -> bool: - v1_to_rest_paths: List[str] = [ + v1_paths: List[str] = [ rf"org/{self.__uuid_pattern}/project/{self.__uuid_pattern}/?" ] - for v1_path in v1_to_rest_paths: + for v1_path in v1_paths: if re.match(v1_path, path): return True diff --git a/snyk/managers.py b/snyk/managers.py index 0349504..812aa7e 100644 --- a/snyk/managers.py +++ b/snyk/managers.py @@ -1,5 +1,4 @@ import abc -import json from typing import Any, Dict, List from deprecation import deprecated # type: ignore @@ -411,9 +410,9 @@ def create(self, issue_id: str, fields: Any) -> Dict[str, str]: # The response we get is not following the schema as specified by the api # https://snyk.docs.apiary.io/#reference/projects/project-jira-issues-/create-jira-issue if ( - issue_id in response_data - and len(response_data[issue_id]) > 0 - and "jiraIssue" in response_data[issue_id][0] + issue_id in response_data + and len(response_data[issue_id]) > 0 + and "jiraIssue" in response_data[issue_id][0] ): return response_data[issue_id][0]["jiraIssue"] raise SnykError diff --git a/snyk/models.py b/snyk/models.py index 1851b6f..78cfd5c 100644 --- a/snyk/models.py +++ b/snyk/models.py @@ -3,7 +3,6 @@ from dataclasses import InitVar, dataclass, field from typing import Any, Dict, List, Optional, Union -import requests from deprecation import deprecated # type: ignore from mashumaro.mixins.json import DataClassJSONMixin # type: ignore @@ -245,7 +244,7 @@ def import_project(self, url, files: Optional[List[str]] = None) -> bool: # https://snyk.docs.apiary.io/#reference/users/user-organisation-notification-settings/modify-org-notification-settings # https://snyk.docs.apiary.io/#reference/users/user-organisation-notification-settings/get-org-notification-settings def notification_settings(self): - raise SnykNotImplemented # pragma: no cover + raise SnykNotImplementedError # pragma: no cover # https://snyk.docs.apiary.io/#reference/organisations/the-snyk-organisation-for-a-request/invite-users def invite(self, email: str, admin: bool = False) -> bool: diff --git a/snyk/test_models.py b/snyk/test_models.py index fd67ea5..1ebafe2 100644 --- a/snyk/test_models.py +++ b/snyk/test_models.py @@ -23,10 +23,18 @@ def organization(self): def base_url(self): return "https://api.snyk.io/v1" + @pytest.fixture + def rest_base_url(self): + return "https://api.snyk.io/rest" + @pytest.fixture def organization_url(self, base_url, organization): return "%s/org/%s" % (base_url, organization.id) + @pytest.fixture + def organization_rest_url(self, rest_base_url, organization): + return "%s/orgs/%s" % (rest_base_url, organization.id) + class TestOrganization(TestModels): @pytest.fixture @@ -358,12 +366,16 @@ def project(self, organization): def project_url(self, organization_url, project): return "%s/project/%s" % (organization_url, project.id) - def test_delete(self, project, project_url, requests_mock): - requests_mock.delete(project_url) + @pytest.fixture + def project_rest_url(self, organization_rest_url, project): + return "%s/projects/%s" % (organization_rest_url, project.id) + + def test_delete(self, project, project_rest_url, requests_mock): + requests_mock.delete(project_rest_url) assert project.delete() - def test_failed_delete(self, project, project_url, requests_mock): - requests_mock.delete(project_url, status_code=500) + def test_failed_delete(self, project, project_rest_url, requests_mock): + requests_mock.delete(project_rest_url, status_code=500) with pytest.raises(SnykError): project.delete() From 62e3c3bc08af1cc54a9a173ebb1f9584ef7e903c Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Sun, 11 Aug 2024 14:39:11 +0300 Subject: [PATCH 04/23] chore: black formatting of client, managers and models files --- snyk/client.py | 54 +++++++++++++++++++++++++----------------------- snyk/managers.py | 6 +++--- snyk/models.py | 8 +++---- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index a920aea..e99a26d 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -22,17 +22,17 @@ class SnykClient(object): USER_AGENT = "pysnyk/%s" % __version__ def __init__( - self, - token: str, - url: Optional[str] = None, - rest_api_url: Optional[str] = None, - user_agent: Optional[str] = USER_AGENT, - debug: bool = False, - tries: int = 1, - delay: int = 1, - backoff: int = 2, - verify: bool = True, - version: Optional[str] = None, + self, + token: str, + url: Optional[str] = None, + rest_api_url: Optional[str] = None, + user_agent: Optional[str] = USER_AGENT, + debug: bool = False, + tries: int = 1, + delay: int = 1, + backoff: int = 2, + verify: bool = True, + version: Optional[str] = None, ): self.api_token = token self.api_url = url or self.API_URL @@ -48,7 +48,9 @@ def __init__( self.delay = delay self.verify = verify self.version = version - self.__uuid_pattern = r'[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' + self.__uuid_pattern = ( + r"[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" + ) # Ensure we don't have a trailing / if self.api_url[-1] == "/": @@ -60,12 +62,12 @@ def __init__( logging.basicConfig(level=logging.DEBUG) def request( - self, - method, - url: str, - headers: object, - params: object = None, - json: object = None, + self, + method, + url: str, + headers: object, + params: object = None, + json: object = None, ) -> requests.Response: if params and json: @@ -125,12 +127,12 @@ def put(self, path: str, body: Any, headers: dict = {}) -> requests.Response: return resp def get( - self, - path: str, - params: dict = None, - version: str = None, - exclude_version: bool = False, - exclude_params: bool = False, + self, + path: str, + params: dict = None, + version: str = None, + exclude_version: bool = False, + exclude_params: bool = False, ) -> requests.Response: """ Rest (formerly v3) Compatible Snyk Client, assumes the presence of Version, either set in the client @@ -246,8 +248,8 @@ def get_rest_pages(self, path: str, params: dict = {}) -> List: if "next" in page_data["links"]: # If the next url is the same as the current url, break out of the loop if ( - "self" in page_data["links"] - and page_data["links"]["next"] == page_data["links"]["self"] + "self" in page_data["links"] + and page_data["links"]["next"] == page_data["links"]["self"] ): break else: diff --git a/snyk/managers.py b/snyk/managers.py index 812aa7e..f9c4d47 100644 --- a/snyk/managers.py +++ b/snyk/managers.py @@ -410,9 +410,9 @@ def create(self, issue_id: str, fields: Any) -> Dict[str, str]: # The response we get is not following the schema as specified by the api # https://snyk.docs.apiary.io/#reference/projects/project-jira-issues-/create-jira-issue if ( - issue_id in response_data - and len(response_data[issue_id]) > 0 - and "jiraIssue" in response_data[issue_id][0] + issue_id in response_data + and len(response_data[issue_id]) > 0 + and "jiraIssue" in response_data[issue_id][0] ): return response_data[issue_id][0]["jiraIssue"] raise SnykError diff --git a/snyk/models.py b/snyk/models.py index 78cfd5c..bf4b1ac 100644 --- a/snyk/models.py +++ b/snyk/models.py @@ -287,7 +287,7 @@ def _test(self, path, contents=None, additional=None): return IssueSet.from_dict(resp.json()) def test_maven( - self, package_group_id: str, package_artifact_id: str, version: str + self, package_group_id: str, package_artifact_id: str, version: str ) -> IssueSet: path = "test/maven/%s/%s/%s?org=%s" % ( package_group_id, @@ -393,7 +393,7 @@ def _import(self, payload) -> bool: return bool(self.organization.client.post(path, payload)) def import_git( - self, owner: str, name: str, branch: str = "master", files: List[str] = [] + self, owner: str, name: str, branch: str = "master", files: List[str] = [] ): return self._import( { @@ -417,7 +417,7 @@ def import_gitlab(self, id: str, branch: str = "master", files: List[str] = []): ) def import_bitbucket( - self, project_key: str, name: str, repo_slug: str, files: List[str] = [] + self, project_key: str, name: str, repo_slug: str, files: List[str] = [] ): return self._import( { @@ -752,7 +752,7 @@ def notification_settings(self): raise SnykNotImplementedError # pragma: no cover def _aggregated_issue_to_vulnerabily( - self, issue: AggregatedIssue + self, issue: AggregatedIssue ) -> List[Vulnerability]: issue_paths = Manager.factory( IssuePaths, From 0ee912dbaaf4c862d319cc030a9709c7ab3c4f6b Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 10:07:08 +0300 Subject: [PATCH 05/23] feat: moved get project by id from v1 to rest --- examples/api-demo-1-list-projects.py | 8 ++++++- snyk/managers.py | 35 +++++++++++++++------------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/examples/api-demo-1-list-projects.py b/examples/api-demo-1-list-projects.py index 2ffbfc2..54e12b6 100755 --- a/examples/api-demo-1-list-projects.py +++ b/examples/api-demo-1-list-projects.py @@ -17,10 +17,16 @@ def parse_command_line_args(): args = parse_command_line_args() org_id = args.orgId -client = SnykClient(token=snyk_token) +client = SnykClient(token=snyk_token, debug=True) for proj in client.organizations.get(org_id).projects.all(): print("\nProject name: %s" % proj.name) + print("Project id: %s" % proj.id) print(" Issues Found:") print(" High : %s" % proj.issueCountsBySeverity.high) print(" Medium: %s" % proj.issueCountsBySeverity.medium) print(" Low : %s" % proj.issueCountsBySeverity.low) + +proj1 = client.projects.get("df7667d2-a79f-47ce-875b-99c93bf45426") +proj2 = client.organizations.get(org_id).projects.get(proj1.id) +print(proj1) +print(proj2) \ No newline at end of file diff --git a/snyk/managers.py b/snyk/managers.py index f9c4d47..c188c2f 100644 --- a/snyk/managers.py +++ b/snyk/managers.py @@ -255,22 +255,25 @@ def filter(self, tags: List[Dict[str, str]] = [], **kwargs: Any): def get(self, id: str): if self.instance: - path = "org/%s/project/%s" % (self.instance.id, id) - resp = self.client.get(path) - project_data = resp.json() - project_data["organization"] = self.instance.to_dict() - # We move tags to _tags as a cache, to avoid the need for additional requests - # when working with tags. We want tags to be the manager - try: - project_data["_tags"] = project_data["tags"] - del project_data["tags"] - except KeyError: - pass - if project_data.get("totalDependencies") is None: - project_data["totalDependencies"] = 0 - project_klass = self.klass.from_dict(project_data) - project_klass.organization = self.instance - return project_klass + path = "orgs/%s/projects/%s" % (self.instance.id, id) + params = {"expand": "target", "meta.latest_issue_counts": "true"} + resp = self.client.get(path, version="2024-06-21", params=params) + response_json = resp.json() + if "data" in response_json: + project_data = self._rest_to_v1_response_format(response_json["data"]) + project_data["organization"] = self.instance.to_dict() + # We move tags to _tags as a cache, to avoid the need for additional requests + # when working with tags. We want tags to be the manager + try: + project_data["_tags"] = project_data["tags"] + del project_data["tags"] + except KeyError: + pass + if project_data.get("totalDependencies") is None: + project_data["totalDependencies"] = 0 + project_klass = self.klass.from_dict(project_data) + project_klass.organization = self.instance + return project_klass else: return super().get(id) From 8fc8a365f166ff1c04f169aaaeb4639bb509f395 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 11:32:18 +0300 Subject: [PATCH 06/23] fix: replace old route with new route for projects in tests --- snyk/test_models.py | 101 ++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 51 deletions(-) diff --git a/snyk/test_models.py b/snyk/test_models.py index 1ebafe2..0f92e92 100644 --- a/snyk/test_models.py +++ b/snyk/test_models.py @@ -46,22 +46,28 @@ def members(self): @pytest.fixture def project(self): return { - "name": "atokeneduser/goof", - "id": "6d5813be-7e6d-4ab8-80c2-1e3e2a454545", - "created": "2018-10-29T09:50:54.014Z", - "origin": "cli", - "type": "npm", - "readOnly": "false", - "testFrequency": "daily", - "lastTestedDate": "2023-01-13T09:50:54.014Z", - "isMonitored": "true", - "issueCountsBySeverity": { - "critical": 1, - "low": 8, - "high": 13, - "medium": 15, - }, - "tags": [{"key": "some-key", "value": "some-value"}], + "data": { + "id": "6d5813be-7e6d-4ab8-80c2-1e3e2a454545", + "attributes": { + "name": "atokeneduser/goof", + "created": "2018-10-29T09:50:54.014Z", + "origin": "cli", + "type": "npm", + "readOnly": "false", + "testFrequency": "daily", + "lastTestedDate": "2023-01-13T09:50:54.014Z", + "isMonitored": "true", + "tags": [{"key": "some-key", "value": "some-value"}], + }, + "meta": { + "latest_issue_counts": { + "critical": 1, + "low": 8, + "high": 13, + "medium": 15, + } + } + } } @pytest.fixture @@ -147,69 +153,62 @@ def test_npm_test(self, organization, base_url, blank_test, requests_mock): assert organization.test_npm("snyk", "1.7.100") def test_pipfile_test_with_string( - self, organization, base_url, blank_test, requests_mock + self, organization, base_url, blank_test, requests_mock ): requests_mock.post("%s/test/pip" % base_url, json=blank_test) assert organization.test_pipfile("django==4.0.0") def test_pipfile_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/pip" % base_url, json=blank_test) assert organization.test_pipfile(fake_file) def test_gemfilelock_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/rubygems" % base_url, json=blank_test) assert organization.test_gemfilelock(fake_file) def test_packagejson_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): - requests_mock.post("%s/test/npm" % base_url, json=blank_test) assert organization.test_packagejson(fake_file) def test_packagejson_test_with_files( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): - requests_mock.post("%s/test/npm" % base_url, json=blank_test) assert organization.test_packagejson(fake_file, fake_file) def test_gradlefile_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): - requests_mock.post("%s/test/gradle" % base_url, json=blank_test) assert organization.test_gradlefile(fake_file) def test_sbt_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): - requests_mock.post("%s/test/sbt" % base_url, json=blank_test) assert organization.test_sbt(fake_file) def test_pom_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): - requests_mock.post("%s/test/maven" % base_url, json=blank_test) assert organization.test_pom(fake_file) def test_composer_with_files( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): - requests_mock.post("%s/test/composer" % base_url, json=blank_test) assert organization.test_composer(fake_file, fake_file) def test_yarn_with_files( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): - requests_mock.post("%s/test/yarn" % base_url, json=blank_test) assert organization.test_yarn(fake_file, fake_file) @@ -282,23 +281,23 @@ def test_invite_admin(self, organization, requests_mock): assert organization.invite("example@example.com", admin=True) def test_get_project(self, organization, project, requests_mock): - matcher = re.compile("project/6d5813be-7e6d-4ab8-80c2-1e3e2a454545$") + matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") requests_mock.get(matcher, json=project) assert ( - "atokeneduser/goof" - == organization.projects.get("6d5813be-7e6d-4ab8-80c2-1e3e2a454545").name + "atokeneduser/goof" + == organization.projects.get("6d5813be-7e6d-4ab8-80c2-1e3e2a454545").name ) def test_get_project_organization_has_client( - self, organization, project, requests_mock + self, organization, project, requests_mock ): - matcher = re.compile("project/6d5813be-7e6d-4ab8-80c2-1e3e2a454545$") + matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") requests_mock.get(matcher, json=project) assert ( - organization.projects.get( - "6d5813be-7e6d-4ab8-80c2-1e3e2a454545" - ).organization.client - is not None + organization.projects.get( + "6d5813be-7e6d-4ab8-80c2-1e3e2a454545" + ).organization.client + is not None ) def test_filter_projects_by_tag_missing_value(self, organization, requests_mock): @@ -329,16 +328,16 @@ def test_filter_projects_not_by_tag(self, organization, requests_mock): assert organization.projects.filter() == [] def test_tags_cache(self, organization, project, requests_mock): - matcher = re.compile("project/6d5813be-7e6d-4ab8-80c2-1e3e2a454545$") + matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") requests_mock.get(matcher, json=project) assert organization.projects.get( "6d5813be-7e6d-4ab8-80c2-1e3e2a454545" )._tags == [{"key": "some-key", "value": "some-value"}] def test_get_organization_project_has_tags( - self, organization, project, requests_mock + self, organization, project, requests_mock ): - matcher = re.compile("project/6d5813be-7e6d-4ab8-80c2-1e3e2a454545$") + matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") requests_mock.get(matcher, json=project) assert organization.projects.get( "6d5813be-7e6d-4ab8-80c2-1e3e2a454545" @@ -447,13 +446,13 @@ def test_missing_ignores(self, project, project_url, requests_mock): project.ignores.get("not-present") def test_filter_not_implemented_on_dict_managers( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): with pytest.raises(SnykNotImplementedError): project.ignores.filter(key="value") def test_first_fails_on_empty_dict_managers( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): requests_mock.get("%s/ignores" % project_url, json={}) with pytest.raises(SnykNotFoundError): @@ -661,7 +660,7 @@ def test_vulnerabilities(self, project, project_url, requests_mock): assert expected == project.vulnerabilities def test_aggregated_issues_missing_optional_fields( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): requests_mock.post( "%s/aggregated-issues" % project_url, @@ -763,7 +762,7 @@ def test_filtering_empty_issues(self, project, project_url, requests_mock): assert project.issueset.filter(ignored=True).ok def test_filtering_empty_issues_aggregated( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): requests_mock.post( "%s/aggregated-issues" % project_url, @@ -791,7 +790,7 @@ def test_empty_licenses(self, project, organization_url, requests_mock): assert [] == project.licenses.all() def test_empty_license_severity( - self, organization, organization_url, requests_mock + self, organization, organization_url, requests_mock ): requests_mock.post( "%s/licenses" % organization_url, @@ -821,7 +820,7 @@ def test_empty_license_severity( assert licenses.severity is None def test_missing_package_version_in_dep_graph( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): requests_mock.get( "%s/dep-graph" % project_url, From cfe46fee0b6d8c6c9100349eb113e2b6743637f6 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 11:52:32 +0300 Subject: [PATCH 07/23] feat: patch method in client --- snyk/client.py | 24 +++++++++++++++++++++- snyk/test_models.py | 50 ++++++++++++++++++++++----------------------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index e99a26d..03c8dc8 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -1,7 +1,7 @@ import logging import re import urllib.parse -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from urllib.parse import parse_qs, urlparse import requests @@ -107,6 +107,28 @@ def post(self, path: str, body: Any, headers: dict = {}) -> requests.Response: return resp + def patch( + self, path: str, body: Dict[str, Any], headers: Dict[str, str] = {} + ) -> requests.Response: + url = f"{self.rest_api_url}/{path}" + logger.debug(f"PATCH: {url}") + + resp = retry_call( + self.request, + fargs=[requests.patch, url], + fkwargs={"json": body, "headers": {**self.api_post_headers, **headers}}, + tries=self.tries, + delay=self.delay, + backoff=self.backoff, + logger=logger, + ) + + if not resp.ok: + logger.error(resp.text) + raise SnykHTTPError(resp) + + return resp + def put(self, path: str, body: Any, headers: dict = {}) -> requests.Response: url = "%s/%s" % (self.api_url, path) logger.debug("PUT: %s" % url) diff --git a/snyk/test_models.py b/snyk/test_models.py index 0f92e92..912ecef 100644 --- a/snyk/test_models.py +++ b/snyk/test_models.py @@ -66,7 +66,7 @@ def project(self): "high": 13, "medium": 15, } - } + }, } } @@ -153,61 +153,61 @@ def test_npm_test(self, organization, base_url, blank_test, requests_mock): assert organization.test_npm("snyk", "1.7.100") def test_pipfile_test_with_string( - self, organization, base_url, blank_test, requests_mock + self, organization, base_url, blank_test, requests_mock ): requests_mock.post("%s/test/pip" % base_url, json=blank_test) assert organization.test_pipfile("django==4.0.0") def test_pipfile_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/pip" % base_url, json=blank_test) assert organization.test_pipfile(fake_file) def test_gemfilelock_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/rubygems" % base_url, json=blank_test) assert organization.test_gemfilelock(fake_file) def test_packagejson_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/npm" % base_url, json=blank_test) assert organization.test_packagejson(fake_file) def test_packagejson_test_with_files( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/npm" % base_url, json=blank_test) assert organization.test_packagejson(fake_file, fake_file) def test_gradlefile_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/gradle" % base_url, json=blank_test) assert organization.test_gradlefile(fake_file) def test_sbt_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/sbt" % base_url, json=blank_test) assert organization.test_sbt(fake_file) def test_pom_test_with_file( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/maven" % base_url, json=blank_test) assert organization.test_pom(fake_file) def test_composer_with_files( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/composer" % base_url, json=blank_test) assert organization.test_composer(fake_file, fake_file) def test_yarn_with_files( - self, organization, base_url, blank_test, fake_file, requests_mock + self, organization, base_url, blank_test, fake_file, requests_mock ): requests_mock.post("%s/test/yarn" % base_url, json=blank_test) assert organization.test_yarn(fake_file, fake_file) @@ -284,20 +284,20 @@ def test_get_project(self, organization, project, requests_mock): matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") requests_mock.get(matcher, json=project) assert ( - "atokeneduser/goof" - == organization.projects.get("6d5813be-7e6d-4ab8-80c2-1e3e2a454545").name + "atokeneduser/goof" + == organization.projects.get("6d5813be-7e6d-4ab8-80c2-1e3e2a454545").name ) def test_get_project_organization_has_client( - self, organization, project, requests_mock + self, organization, project, requests_mock ): matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") requests_mock.get(matcher, json=project) assert ( - organization.projects.get( - "6d5813be-7e6d-4ab8-80c2-1e3e2a454545" - ).organization.client - is not None + organization.projects.get( + "6d5813be-7e6d-4ab8-80c2-1e3e2a454545" + ).organization.client + is not None ) def test_filter_projects_by_tag_missing_value(self, organization, requests_mock): @@ -335,7 +335,7 @@ def test_tags_cache(self, organization, project, requests_mock): )._tags == [{"key": "some-key", "value": "some-value"}] def test_get_organization_project_has_tags( - self, organization, project, requests_mock + self, organization, project, requests_mock ): matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") requests_mock.get(matcher, json=project) @@ -446,13 +446,13 @@ def test_missing_ignores(self, project, project_url, requests_mock): project.ignores.get("not-present") def test_filter_not_implemented_on_dict_managers( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): with pytest.raises(SnykNotImplementedError): project.ignores.filter(key="value") def test_first_fails_on_empty_dict_managers( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): requests_mock.get("%s/ignores" % project_url, json={}) with pytest.raises(SnykNotFoundError): @@ -660,7 +660,7 @@ def test_vulnerabilities(self, project, project_url, requests_mock): assert expected == project.vulnerabilities def test_aggregated_issues_missing_optional_fields( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): requests_mock.post( "%s/aggregated-issues" % project_url, @@ -762,7 +762,7 @@ def test_filtering_empty_issues(self, project, project_url, requests_mock): assert project.issueset.filter(ignored=True).ok def test_filtering_empty_issues_aggregated( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): requests_mock.post( "%s/aggregated-issues" % project_url, @@ -790,7 +790,7 @@ def test_empty_licenses(self, project, organization_url, requests_mock): assert [] == project.licenses.all() def test_empty_license_severity( - self, organization, organization_url, requests_mock + self, organization, organization_url, requests_mock ): requests_mock.post( "%s/licenses" % organization_url, @@ -820,7 +820,7 @@ def test_empty_license_severity( assert licenses.severity is None def test_missing_package_version_in_dep_graph( - self, project, project_url, requests_mock + self, project, project_url, requests_mock ): requests_mock.get( "%s/dep-graph" % project_url, From eeddafea0c60083802c099ddd3e7bd3f96b463b2 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 13:09:56 +0300 Subject: [PATCH 08/23] chore: removed debug from examples --- examples/api-demo-1-list-projects.py | 10 ++-------- snyk/client.py | 1 - 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/examples/api-demo-1-list-projects.py b/examples/api-demo-1-list-projects.py index 54e12b6..ddab630 100755 --- a/examples/api-demo-1-list-projects.py +++ b/examples/api-demo-1-list-projects.py @@ -17,16 +17,10 @@ def parse_command_line_args(): args = parse_command_line_args() org_id = args.orgId -client = SnykClient(token=snyk_token, debug=True) +client = SnykClient(token=snyk_token) for proj in client.organizations.get(org_id).projects.all(): print("\nProject name: %s" % proj.name) - print("Project id: %s" % proj.id) print(" Issues Found:") print(" High : %s" % proj.issueCountsBySeverity.high) print(" Medium: %s" % proj.issueCountsBySeverity.medium) - print(" Low : %s" % proj.issueCountsBySeverity.low) - -proj1 = client.projects.get("df7667d2-a79f-47ce-875b-99c93bf45426") -proj2 = client.organizations.get(org_id).projects.get(proj1.id) -print(proj1) -print(proj2) \ No newline at end of file + print(" Low : %s" % proj.issueCountsBySeverity.low) \ No newline at end of file diff --git a/snyk/client.py b/snyk/client.py index 03c8dc8..1338de2 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -230,7 +230,6 @@ def delete(self, path: str) -> requests.Response: url = f"{self.rest_api_url}/{path}" else: url = f"{self.api_url}/{path}" - # url = f"{self.api_url}/{path}" logger.debug(f"DELETE: {url}") From 956394ec69ea5c0062dec487f40bad3b1f54f622 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 13:19:24 +0300 Subject: [PATCH 09/23] chore: removed ununsed class --- snyk/models.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/snyk/models.py b/snyk/models.py index bf4b1ac..f5019d6 100644 --- a/snyk/models.py +++ b/snyk/models.py @@ -804,25 +804,3 @@ def _aggregated_issue_to_vulnerabily( # versions, emulate that here to preserve upstream api for version in issue.pkgVersions ] - - -class V1ToRestConversion: - def __init__(self, v1_path, rest_path, v1_verb, rest_verb): - self.v1_path = v1_path - self.rest_path = rest_path - self.v1_verb = v1_verb - self.rest_verb = rest_verb - - -class ProjectV1ToRestConversion: - def convert_delete_request(self): - pass - - def convert_add_tag_request(self, body: Dict[str, Any]): - pass - - def convert_delete_tag_request(self, body: Dict[str, Any]): - pass - - def convert_apply_attributes_request(self, body: Dict[str, Any]): - pass From 659827e0c190abbe376249347a586b9daae6ffca Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 14:27:10 +0300 Subject: [PATCH 10/23] chore: replaced old api version with new one --- snyk/client.py | 15 +++++++++++++-- snyk/test_client.py | 10 ++++++---- snyk/test_data/rest_targets_page1.json | 2 +- snyk/test_data/rest_targets_page2.json | 4 ++-- snyk/test_data/rest_targets_page3.json | 2 +- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index 1338de2..f562fe3 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -108,15 +108,26 @@ def post(self, path: str, body: Any, headers: dict = {}) -> requests.Response: return resp def patch( - self, path: str, body: Dict[str, Any], headers: Dict[str, str] = {} + self, + path: str, + body: Dict[str, Any], + headers: Dict[str, str] = {}, + params: Dict[str, Any] = {}, ) -> requests.Response: url = f"{self.rest_api_url}/{path}" logger.debug(f"PATCH: {url}") + if "version" not in params: + params["version"] = self.version if self.version else "2024-06-21" + resp = retry_call( self.request, fargs=[requests.patch, url], - fkwargs={"json": body, "headers": {**self.api_post_headers, **headers}}, + fkwargs={ + "json": body, + "headers": {**self.api_post_headers, **headers}, + "params": params, + }, tries=self.tries, delay=self.delay, backoff=self.backoff, diff --git a/snyk/test_client.py b/snyk/test_client.py index ec0bb93..7865679 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -13,7 +13,7 @@ REST_ORG = "39ddc762-b1b9-41ce-ab42-defbe4575bd6" REST_URL = "https://api.snyk.io/rest" -REST_VERSION = "2022-02-16~experimental" +REST_VERSION = "2024-06-21" V3_ORG = "39ddc762-b1b9-41ce-ab42-defbe4575bd6" V3_URL = "https://api.snyk.io/v3" @@ -207,9 +207,7 @@ def test_non_existent_project(self, requests_mock, client, organizations, projec @pytest.fixture def rest_client(self): - return SnykClient( - "token", version="2022-02-16~experimental", url="https://api.snyk.io/rest" - ) + return SnykClient("token", version="2024-06-21", url="https://api.snyk.io/rest") @pytest.fixture def v3_client(self): @@ -329,3 +327,7 @@ def test_rest_limit_deduplication(self, requests_mock, rest_client): ) params = {"limit": 10} rest_client.get(f"orgs/{REST_ORG}/projects?limit=100", params) + + def test_patch_update_project_and_raises_error(self, requests_mock, rest_client): + matcher = "projects/f9fec29a-d288-40d9-a019-cedf825e6efb" + requests_mock.patch("") diff --git a/snyk/test_data/rest_targets_page1.json b/snyk/test_data/rest_targets_page1.json index 67359ef..2311d86 100644 --- a/snyk/test_data/rest_targets_page1.json +++ b/snyk/test_data/rest_targets_page1.json @@ -115,6 +115,6 @@ } ], "links": { - "next": "/orgs/39ddc762-b1b9-41ce-ab42-defbe4575bd6/targets?limit=10&version=2022-02-16~experimental&excludeEmpty=true&starting_after=v1.eyJpZCI6IjMyODE4ODAifQ%3D%3D" + "next": "/orgs/39ddc762-b1b9-41ce-ab42-defbe4575bd6/targets?limit=10&version=2024-06-21&excludeEmpty=true&starting_after=v1.eyJpZCI6IjMyODE4ODAifQ%3D%3D" } } \ No newline at end of file diff --git a/snyk/test_data/rest_targets_page2.json b/snyk/test_data/rest_targets_page2.json index 0008af9..5f1eb6b 100644 --- a/snyk/test_data/rest_targets_page2.json +++ b/snyk/test_data/rest_targets_page2.json @@ -115,7 +115,7 @@ } ], "links": { - "next": "/orgs/39ddc762-b1b9-41ce-ab42-defbe4575bd6/targets?limit=10&version=2022-02-16~experimental&excludeEmpty=true&starting_after=v1.eyJpZCI6IjI5MTk1NjgifQ%3D%3D", - "prev": "/orgs/39ddc762-b1b9-41ce-ab42-defbe4575bd6/targets?limit=10&version=2022-02-16~experimental&excludeEmpty=true&ending_before=v1.eyJpZCI6IjMyODE4NzkifQ%3D%3D" + "next": "/orgs/39ddc762-b1b9-41ce-ab42-defbe4575bd6/targets?limit=10&version=2024-06-21&excludeEmpty=true&starting_after=v1.eyJpZCI6IjI5MTk1NjgifQ%3D%3D", + "prev": "/orgs/39ddc762-b1b9-41ce-ab42-defbe4575bd6/targets?limit=10&version=2024-06-21&excludeEmpty=true&ending_before=v1.eyJpZCI6IjMyODE4NzkifQ%3D%3D" } } \ No newline at end of file diff --git a/snyk/test_data/rest_targets_page3.json b/snyk/test_data/rest_targets_page3.json index f23acbb..ff98eb6 100644 --- a/snyk/test_data/rest_targets_page3.json +++ b/snyk/test_data/rest_targets_page3.json @@ -115,6 +115,6 @@ } ], "links": { - "prev": "/orgs/39ddc762-b1b9-41ce-ab42-defbe4575bd6/targets?limit=10&version=2022-02-16~experimental&excludeEmpty=true&ending_before=v1.eyJpZCI6IjMyODE4ODAifQ%3D%3D" + "prev": "/orgs/39ddc762-b1b9-41ce-ab42-defbe4575bd6/targets?limit=10&version=2024-06-21&excludeEmpty=true&ending_before=v1.eyJpZCI6IjMyODE4ODAifQ%3D%3D" } } \ No newline at end of file From 5c21f1d9dc8bf0f114115c9a6354231496e88d2e Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 15:08:44 +0300 Subject: [PATCH 11/23] feat: added patch update project test --- snyk/test_client.py | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/snyk/test_client.py b/snyk/test_client.py index 7865679..1985c64 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -194,8 +194,8 @@ def test_project(self, requests_mock, client, organizations, projects): matcher = re.compile("projects.*$") requests_mock.get(matcher, json=projects) assert ( - "testing-new-name" - == client.projects.get("f9fec29a-d288-40d9-a019-cedf825e6efb").name + "testing-new-name" + == client.projects.get("f9fec29a-d288-40d9-a019-cedf825e6efb").name ) def test_non_existent_project(self, requests_mock, client, organizations, projects): @@ -259,12 +259,12 @@ def test_v3get(self, requests_mock, v3_client, v3_targets_page1): assert len(targets["data"]) == 10 def test_get_v3_pages( - self, - requests_mock, - v3_client, - v3_targets_page1, - v3_targets_page2, - v3_targets_page3, + self, + requests_mock, + v3_client, + v3_targets_page1, + v3_targets_page2, + v3_targets_page3, ): requests_mock.get( f"{V3_URL}/orgs/{V3_ORG}/targets?limit=10&version={V3_VERSION}", @@ -296,12 +296,12 @@ def test_rest_get(self, requests_mock, rest_client, rest_targets_page1): assert len(targets["data"]) == 10 def test_get_rest_pages( - self, - requests_mock, - rest_client, - rest_targets_page1, - rest_targets_page2, - rest_targets_page3, + self, + requests_mock, + rest_client, + rest_targets_page1, + rest_targets_page2, + rest_targets_page3, ): requests_mock.get( f"{REST_URL}/orgs/{REST_ORG}/targets?limit=10&version={REST_VERSION}", @@ -328,6 +328,15 @@ def test_rest_limit_deduplication(self, requests_mock, rest_client): params = {"limit": 10} rest_client.get(f"orgs/{REST_ORG}/projects?limit=100", params) - def test_patch_update_project_and_raises_error(self, requests_mock, rest_client): - matcher = "projects/f9fec29a-d288-40d9-a019-cedf825e6efb" - requests_mock.patch("") + def test_patch_update_project_should_return_new_project(self, requests_mock, rest_client, projects): + matcher = re.compile("projects/f9fec29a-d288-40d9-a019-cedf825e6efb") + project = projects["data"][0] + project["attributes"]["tags"] = [{"key": "test_key", "value": "test_value"}] + project["attributes"]["environment"] = ["backend"] + project["attributes"]["lifecycle"] = ["development"] + requests_mock.patch(matcher, json=project, status_code=200) + + response = rest_client.patch(f"orgs/{REST_ORG}/projects/{project['id']}", body=project) + + assert response.status_code == 200 + assert response.json() From 3b76531840312192c204d9645176acc15bd5168b Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 15:38:45 +0300 Subject: [PATCH 12/23] feat: tests for patch update project --- snyk/test_client.py | 68 ++++++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/snyk/test_client.py b/snyk/test_client.py index 1985c64..f154287 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -194,8 +194,8 @@ def test_project(self, requests_mock, client, organizations, projects): matcher = re.compile("projects.*$") requests_mock.get(matcher, json=projects) assert ( - "testing-new-name" - == client.projects.get("f9fec29a-d288-40d9-a019-cedf825e6efb").name + "testing-new-name" + == client.projects.get("f9fec29a-d288-40d9-a019-cedf825e6efb").name ) def test_non_existent_project(self, requests_mock, client, organizations, projects): @@ -259,12 +259,12 @@ def test_v3get(self, requests_mock, v3_client, v3_targets_page1): assert len(targets["data"]) == 10 def test_get_v3_pages( - self, - requests_mock, - v3_client, - v3_targets_page1, - v3_targets_page2, - v3_targets_page3, + self, + requests_mock, + v3_client, + v3_targets_page1, + v3_targets_page2, + v3_targets_page3, ): requests_mock.get( f"{V3_URL}/orgs/{V3_ORG}/targets?limit=10&version={V3_VERSION}", @@ -296,12 +296,12 @@ def test_rest_get(self, requests_mock, rest_client, rest_targets_page1): assert len(targets["data"]) == 10 def test_get_rest_pages( - self, - requests_mock, - rest_client, - rest_targets_page1, - rest_targets_page2, - rest_targets_page3, + self, + requests_mock, + rest_client, + rest_targets_page1, + rest_targets_page2, + rest_targets_page3, ): requests_mock.get( f"{REST_URL}/orgs/{REST_ORG}/targets?limit=10&version={REST_VERSION}", @@ -328,15 +328,43 @@ def test_rest_limit_deduplication(self, requests_mock, rest_client): params = {"limit": 10} rest_client.get(f"orgs/{REST_ORG}/projects?limit=100", params) - def test_patch_update_project_should_return_new_project(self, requests_mock, rest_client, projects): + def test_patch_update_project_should_return_new_project( + self, requests_mock, rest_client, projects + ): matcher = re.compile("projects/f9fec29a-d288-40d9-a019-cedf825e6efb") project = projects["data"][0] - project["attributes"]["tags"] = [{"key": "test_key", "value": "test_value"}] - project["attributes"]["environment"] = ["backend"] - project["attributes"]["lifecycle"] = ["development"] + body = { + "data": { + "attributes": { + "business_criticality": ["critical"], + "environment": ["backend", "internal"], + "lifecycle": ["development"], + "tags": [{"key": "key-test", "value": "value-test"}], + } + } + } + project["attributes"] = {**project["attributes"], **body["data"]["attributes"]} requests_mock.patch(matcher, json=project, status_code=200) - response = rest_client.patch(f"orgs/{REST_ORG}/projects/{project['id']}", body=project) + response = rest_client.patch( + f"orgs/{REST_ORG}/projects/{project['id']}", body=project + ) + response_data = response.json() assert response.status_code == 200 - assert response.json() + assert response_data == project + + def test_patch_update_project_when_invalid_should_throw_exception( + self, requests_mock, rest_client + ): + matcher = re.compile("projects/f9fec29a-d288-40d9-a019-cedf825e6efb") + body = {"attributes": {"environment": ["backend"]}} + + requests_mock.patch(matcher, json=body, status_code=400) + with pytest.raises(SnykError): + rest_client.patch( + f"orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + body=body, + ) + + assert requests_mock.call_count == 1 From f0a05e25dd57db8f1e8493dcbc06281ec8749d1d Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 16:09:10 +0300 Subject: [PATCH 13/23] chore: added get project by id and tests back --- snyk/managers.py | 35 ++++++++++++++++------------------ snyk/test_models.py | 46 ++++++++++++++++++++------------------------- 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/snyk/managers.py b/snyk/managers.py index c188c2f..f9c4d47 100644 --- a/snyk/managers.py +++ b/snyk/managers.py @@ -255,25 +255,22 @@ def filter(self, tags: List[Dict[str, str]] = [], **kwargs: Any): def get(self, id: str): if self.instance: - path = "orgs/%s/projects/%s" % (self.instance.id, id) - params = {"expand": "target", "meta.latest_issue_counts": "true"} - resp = self.client.get(path, version="2024-06-21", params=params) - response_json = resp.json() - if "data" in response_json: - project_data = self._rest_to_v1_response_format(response_json["data"]) - project_data["organization"] = self.instance.to_dict() - # We move tags to _tags as a cache, to avoid the need for additional requests - # when working with tags. We want tags to be the manager - try: - project_data["_tags"] = project_data["tags"] - del project_data["tags"] - except KeyError: - pass - if project_data.get("totalDependencies") is None: - project_data["totalDependencies"] = 0 - project_klass = self.klass.from_dict(project_data) - project_klass.organization = self.instance - return project_klass + path = "org/%s/project/%s" % (self.instance.id, id) + resp = self.client.get(path) + project_data = resp.json() + project_data["organization"] = self.instance.to_dict() + # We move tags to _tags as a cache, to avoid the need for additional requests + # when working with tags. We want tags to be the manager + try: + project_data["_tags"] = project_data["tags"] + del project_data["tags"] + except KeyError: + pass + if project_data.get("totalDependencies") is None: + project_data["totalDependencies"] = 0 + project_klass = self.klass.from_dict(project_data) + project_klass.organization = self.instance + return project_klass else: return super().get(id) diff --git a/snyk/test_models.py b/snyk/test_models.py index 912ecef..632788f 100644 --- a/snyk/test_models.py +++ b/snyk/test_models.py @@ -46,28 +46,22 @@ def members(self): @pytest.fixture def project(self): return { - "data": { - "id": "6d5813be-7e6d-4ab8-80c2-1e3e2a454545", - "attributes": { - "name": "atokeneduser/goof", - "created": "2018-10-29T09:50:54.014Z", - "origin": "cli", - "type": "npm", - "readOnly": "false", - "testFrequency": "daily", - "lastTestedDate": "2023-01-13T09:50:54.014Z", - "isMonitored": "true", - "tags": [{"key": "some-key", "value": "some-value"}], - }, - "meta": { - "latest_issue_counts": { - "critical": 1, - "low": 8, - "high": 13, - "medium": 15, - } - }, - } + "name": "atokeneduser/goof", + "id": "6d5813be-7e6d-4ab8-80c2-1e3e2a454545", + "created": "2018-10-29T09:50:54.014Z", + "origin": "cli", + "type": "npm", + "readOnly": "false", + "testFrequency": "daily", + "lastTestedDate": "2023-01-13T09:50:54.014Z", + "isMonitored": "true", + "issueCountsBySeverity": { + "critical": 1, + "low": 8, + "high": 13, + "medium": 15, + }, + "tags": [{"key": "some-key", "value": "some-value"}], } @pytest.fixture @@ -281,7 +275,7 @@ def test_invite_admin(self, organization, requests_mock): assert organization.invite("example@example.com", admin=True) def test_get_project(self, organization, project, requests_mock): - matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") + matcher = re.compile("project/6d5813be-7e6d-4ab8-80c2-1e3e2a454545$") requests_mock.get(matcher, json=project) assert ( "atokeneduser/goof" @@ -291,7 +285,7 @@ def test_get_project(self, organization, project, requests_mock): def test_get_project_organization_has_client( self, organization, project, requests_mock ): - matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") + matcher = re.compile("project/6d5813be-7e6d-4ab8-80c2-1e3e2a454545$") requests_mock.get(matcher, json=project) assert ( organization.projects.get( @@ -328,7 +322,7 @@ def test_filter_projects_not_by_tag(self, organization, requests_mock): assert organization.projects.filter() == [] def test_tags_cache(self, organization, project, requests_mock): - matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") + matcher = re.compile("project/6d5813be-7e6d-4ab8-80c2-1e3e2a454545$") requests_mock.get(matcher, json=project) assert organization.projects.get( "6d5813be-7e6d-4ab8-80c2-1e3e2a454545" @@ -337,7 +331,7 @@ def test_tags_cache(self, organization, project, requests_mock): def test_get_organization_project_has_tags( self, organization, project, requests_mock ): - matcher = re.compile("projects/6d5813be-7e6d-4ab8-80c2-1e3e2a454545") + matcher = re.compile("project/6d5813be-7e6d-4ab8-80c2-1e3e2a454545$") requests_mock.get(matcher, json=project) assert organization.projects.get( "6d5813be-7e6d-4ab8-80c2-1e3e2a454545" From d6db2444be015a51c7d3d9d7e0034d196fe34bec Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 16:47:49 +0300 Subject: [PATCH 14/23] chore: use rest boolean for clients and tests --- snyk/client.py | 43 +++++++++++++++++++++++++++++++++++++------ snyk/test_client.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index f562fe3..9c56278 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -86,14 +86,28 @@ def request( raise SnykHTTPError(resp) return resp - def post(self, path: str, body: Any, headers: dict = {}) -> requests.Response: - url = f"{self.api_url}/{path}" + def post( + self, + path: str, + body: Any, + headers: dict = {}, + params: Dict[str, Any] = {}, + use_rest: bool = False, + ) -> requests.Response: + url = f"{self.rest_api_url if use_rest else self.api_url}/{path}" logger.debug(f"POST: {url}") + if use_rest and "version" not in params: + params["version"] = self.version if self.version else "2024-06-21" + resp = retry_call( self.request, fargs=[requests.post, url], - fkwargs={"json": body, "headers": {**self.api_post_headers, **headers}}, + fkwargs={ + "json": body, + "headers": {**self.api_post_headers, **headers}, + "params": params, + }, tries=self.tries, delay=self.delay, backoff=self.backoff, @@ -140,14 +154,31 @@ def patch( return resp - def put(self, path: str, body: Any, headers: dict = {}) -> requests.Response: - url = "%s/%s" % (self.api_url, path) + def put( + self, + path: str, + body: Any, + headers: dict = {}, + params: Dict[str, Any] = {}, + use_rest: bool = False, + ) -> requests.Response: + url = "%s/%s" % ( + self.rest_api_url if use_rest else self.api_url, + path, + ) logger.debug("PUT: %s" % url) + if use_rest and "version" not in params: + params["version"] = self.version if self.version else "2024-06-21" + resp = retry_call( self.request, fargs=[requests.put, url], - fkwargs={"json": body, "headers": {**self.api_post_headers, **headers}}, + fkwargs={ + "json": body, + "headers": {**self.api_post_headers, **headers}, + "params": params, + }, tries=self.tries, delay=self.delay, backoff=self.backoff, diff --git a/snyk/test_client.py b/snyk/test_client.py index f154287..46dc0d7 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -368,3 +368,33 @@ def test_patch_update_project_when_invalid_should_throw_exception( ) assert requests_mock.call_count == 1 + + def test_post_request_rest_api(self, requests_mock, rest_client): + matcher = re.compile( + f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" + ) + requests_mock.post(matcher, json={}, status_code=200) + params = {"version": REST_VERSION} + rest_client.post( + f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + body={}, + params=params, + use_rest=True, + ) + + assert requests_mock.call_count == 1 + + def test_put_request_rest_api(self, requests_mock, rest_client): + matcher = re.compile( + f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" + ) + requests_mock.put(matcher, json={}, status_code=200) + params = {"version": REST_VERSION} + rest_client.put( + f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + body={}, + params=params, + use_rest=True, + ) + + assert requests_mock.call_count == 1 From a04902afcb1503b2378412c8c5e3fb060e069e74 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Mon, 12 Aug 2024 16:51:57 +0300 Subject: [PATCH 15/23] chore: send query params --- snyk/test_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/snyk/test_client.py b/snyk/test_client.py index 46dc0d7..b61cc8d 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -347,7 +347,9 @@ def test_patch_update_project_should_return_new_project( requests_mock.patch(matcher, json=project, status_code=200) response = rest_client.patch( - f"orgs/{REST_ORG}/projects/{project['id']}", body=project + f"orgs/{REST_ORG}/projects/{project['id']}", + body=project, + params={"expand": "target"}, ) response_data = response.json() From 2bc763f5fa41c6bf88889b5708e8ce906fd0d24c Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Tue, 13 Aug 2024 10:20:37 +0300 Subject: [PATCH 16/23] feat: tests to avoid side-effects for api headers --- examples/api-demo-1b-update-project.py | 0 snyk/client.py | 13 +++++--- snyk/test_client.py | 43 +++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 examples/api-demo-1b-update-project.py diff --git a/examples/api-demo-1b-update-project.py b/examples/api-demo-1b-update-project.py new file mode 100644 index 0000000..e69de29 diff --git a/snyk/client.py b/snyk/client.py index 9c56278..fa2001d 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -1,7 +1,8 @@ +import copy import logging import re import urllib.parse -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Pattern from urllib.parse import parse_qs, urlparse import requests @@ -41,8 +42,10 @@ def __init__( "Authorization": "token %s" % self.api_token, "User-Agent": user_agent, } - self.api_post_headers = self.api_headers + self.api_post_headers = copy.deepcopy(self.api_headers) self.api_post_headers["Content-Type"] = "application/json" + self.api_patch_headers = copy.deepcopy(self.api_headers) + self.api_patch_headers["Content-Type"] = "application/vnd.api+json" self.tries = tries self.backoff = backoff self.delay = delay @@ -139,7 +142,7 @@ def patch( fargs=[requests.patch, url], fkwargs={ "json": body, - "headers": {**self.api_post_headers, **headers}, + "headers": {**self.api_patch_headers, **headers}, "params": params, }, tries=self.tries, @@ -370,8 +373,8 @@ def issues(self): raise SnykNotImplementedError # pragma: no cover def __is_v1_project_path(self, path: str) -> bool: - v1_paths: List[str] = [ - rf"org/{self.__uuid_pattern}/project/{self.__uuid_pattern}/?" + v1_paths: List[Pattern[str]] = [ + re.compile(f"^org/{self.__uuid_pattern}/project/{self.__uuid_pattern}$") ] for v1_path in v1_paths: diff --git a/snyk/test_client.py b/snyk/test_client.py index b61cc8d..628cf12 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -331,8 +331,10 @@ def test_rest_limit_deduplication(self, requests_mock, rest_client): def test_patch_update_project_should_return_new_project( self, requests_mock, rest_client, projects ): - matcher = re.compile("projects/f9fec29a-d288-40d9-a019-cedf825e6efb") project = projects["data"][0] + matcher = re.compile( + f"orgs/{REST_ORG}/projects/{project['id']}\\?([^&=]+=[^&=]+&?)+$" + ) body = { "data": { "attributes": { @@ -356,10 +358,28 @@ def test_patch_update_project_should_return_new_project( assert response.status_code == 200 assert response_data == project + def test_token_added_to_patch_headers(self, client): + assert client.api_patch_headers["Authorization"] == "token token" + + def test_patch_headers_use_correct_mimetype(self, client): + assert client.api_patch_headers["Content-Type"] == "application/vnd.api+json" + + def test_patch_has_version_in_query_params(self, client, requests_mock): + matcher = re.compile("\\?version=2[0-9]{3}-[0-9]{2}-[0-9]{2}$") + requests_mock.patch(matcher, json={}, status_code=200) + client.patch( + f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + body={}, + ) + + assert requests_mock.call_count == 1 + def test_patch_update_project_when_invalid_should_throw_exception( self, requests_mock, rest_client ): - matcher = re.compile("projects/f9fec29a-d288-40d9-a019-cedf825e6efb") + matcher = re.compile( + "projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version=2[0-9]{3}-[0-9]{2}-[0-9]{2}$" + ) body = {"attributes": {"environment": ["backend"]}} requests_mock.patch(matcher, json=body, status_code=400) @@ -371,7 +391,7 @@ def test_patch_update_project_when_invalid_should_throw_exception( assert requests_mock.call_count == 1 - def test_post_request_rest_api(self, requests_mock, rest_client): + def test_post_request_rest_api_when_specified(self, requests_mock, rest_client): matcher = re.compile( f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" ) @@ -386,7 +406,7 @@ def test_post_request_rest_api(self, requests_mock, rest_client): assert requests_mock.call_count == 1 - def test_put_request_rest_api(self, requests_mock, rest_client): + def test_put_request_rest_api_when_specified(self, requests_mock, rest_client): matcher = re.compile( f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" ) @@ -400,3 +420,18 @@ def test_put_request_rest_api(self, requests_mock, rest_client): ) assert requests_mock.call_count == 1 + + def test_delete_redirects_to_rest_api_for_delete_project( + self, client, requests_mock, projects + ): + project = projects["data"][0] + matcher = re.compile( + "orgs/%s/projects/%s\\?version=2[0-9]{3}-[0-9]{2}-[0-9]{2}$" + % (REST_ORG, project["id"]) + ) + + requests_mock.delete(matcher, json={}, status_code=200) + + client.delete(f"org/{REST_ORG}/project/{project['id']}") + + assert requests_mock.call_count == 1 From 90f67bc4c2ec03a0a6e8486c5bf1aaaf8ebf780d Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Tue, 13 Aug 2024 10:55:53 +0300 Subject: [PATCH 17/23] feat: clients can use rest path for delete and tests --- snyk/client.py | 13 +++++++++---- snyk/test_client.py | 37 +++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index fa2001d..4e9074b 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -267,21 +267,26 @@ def get( return resp - def delete(self, path: str) -> requests.Response: + def delete(self, path: str, use_rest: bool = False) -> requests.Response: is_v1_project_path: bool = self.__is_v1_project_path(path) if is_v1_project_path: ids = re.findall(rf"{self.__uuid_pattern}", path) - path = f"orgs/{ids[0]}/projects/{ids[1]}?version={self.version if self.version else '2024-06-21'}" + path = f"orgs/{ids[0]}/projects/{ids[1]}" url = f"{self.rest_api_url}/{path}" + use_rest = True else: - url = f"{self.api_url}/{path}" + url = f"{self.rest_api_url if use_rest else self.api_url}/{path}" + + params = {} + if use_rest: + params["version"] = self.version if self.version else "2024-06-21" logger.debug(f"DELETE: {url}") resp = retry_call( self.request, fargs=[requests.delete, url], - fkwargs={"headers": self.api_headers}, + fkwargs={"headers": self.api_headers, "params": params}, tries=self.tries, delay=self.delay, backoff=self.backoff, diff --git a/snyk/test_client.py b/snyk/test_client.py index 628cf12..ce2a072 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -391,13 +391,13 @@ def test_patch_update_project_when_invalid_should_throw_exception( assert requests_mock.call_count == 1 - def test_post_request_rest_api_when_specified(self, requests_mock, rest_client): + def test_post_request_rest_api_when_specified(self, requests_mock, client): matcher = re.compile( f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" ) requests_mock.post(matcher, json={}, status_code=200) params = {"version": REST_VERSION} - rest_client.post( + client.post( f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", body={}, params=params, @@ -406,13 +406,13 @@ def test_post_request_rest_api_when_specified(self, requests_mock, rest_client): assert requests_mock.call_count == 1 - def test_put_request_rest_api_when_specified(self, requests_mock, rest_client): + def test_put_request_rest_api_when_specified(self, requests_mock, client): matcher = re.compile( f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" ) requests_mock.put(matcher, json={}, status_code=200) params = {"version": REST_VERSION} - rest_client.put( + client.put( f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", body={}, params=params, @@ -421,6 +421,35 @@ def test_put_request_rest_api_when_specified(self, requests_mock, rest_client): assert requests_mock.call_count == 1 + def test_put_request_v1_api_when_specified(self, requests_mock, client): + matcher = re.compile( + f"^https://api.snyk.io/v1/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb" + ) + requests_mock.put(matcher, json={}, status_code=200) + client.put( + f"org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb", + body={}, + use_rest=False, + ) + + assert requests_mock.call_count == 1 + + def test_delete_use_rest_when_specified(self, requests_mock, client): + matcher = re.compile( + "^%s/orgs/%s\\?version=2[0-9]{3}-[0-9]{2}-[0-9]{2}$" % (REST_URL, REST_ORG) + ) + requests_mock.delete(matcher, json={}, status_code=200) + + client.delete(f"orgs/{REST_ORG}", use_rest=True) + assert requests_mock.call_count == 1 + + def test_delete_use_v1_when_specified(self, requests_mock, client): + matcher = re.compile("^%s/orgs/%s" % ("https://api.snyk.io/v1", REST_ORG)) + requests_mock.delete(matcher, json={}, status_code=200) + + client.delete(f"orgs/{REST_ORG}") + assert requests_mock.call_count == 1 + def test_delete_redirects_to_rest_api_for_delete_project( self, client, requests_mock, projects ): From a823df48609c4161fd96c2428d77da19c3482b2e Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Tue, 13 Aug 2024 11:24:41 +0300 Subject: [PATCH 18/23] chore: removed example file --- examples/api-demo-1b-update-project.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 examples/api-demo-1b-update-project.py diff --git a/examples/api-demo-1b-update-project.py b/examples/api-demo-1b-update-project.py deleted file mode 100644 index e69de29..0000000 From b849478ddfb351fdad719fcb7c5ed76046bb0495 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Tue, 13 Aug 2024 15:45:11 +0300 Subject: [PATCH 19/23] chore: rename test from new project to updated project --- snyk/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snyk/test_client.py b/snyk/test_client.py index ce2a072..bf666a5 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -328,7 +328,7 @@ def test_rest_limit_deduplication(self, requests_mock, rest_client): params = {"limit": 10} rest_client.get(f"orgs/{REST_ORG}/projects?limit=100", params) - def test_patch_update_project_should_return_new_project( + def test_patch_update_project_should_return_updated_project( self, requests_mock, rest_client, projects ): project = projects["data"][0] From 657d4ec55c8fed6c929467f7be6196f5ff1c77e4 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Tue, 20 Aug 2024 09:44:43 +0300 Subject: [PATCH 20/23] chore: replace dict with Dict and extract latest version in a variable --- snyk/client.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index 4e9074b..90a3139 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -42,9 +42,9 @@ def __init__( "Authorization": "token %s" % self.api_token, "User-Agent": user_agent, } - self.api_post_headers = copy.deepcopy(self.api_headers) + self.api_post_headers = dict(self.api_headers) self.api_post_headers["Content-Type"] = "application/json" - self.api_patch_headers = copy.deepcopy(self.api_headers) + self.api_patch_headers = dict(self.api_headers) self.api_patch_headers["Content-Type"] = "application/vnd.api+json" self.tries = tries self.backoff = backoff @@ -54,6 +54,7 @@ def __init__( self.__uuid_pattern = ( r"[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" ) + self.__latest_version = "2024-06-21" # Ensure we don't have a trailing / if self.api_url[-1] == "/": @@ -93,7 +94,7 @@ def post( self, path: str, body: Any, - headers: dict = {}, + headers: Dict = {}, params: Dict[str, Any] = {}, use_rest: bool = False, ) -> requests.Response: @@ -101,7 +102,7 @@ def post( logger.debug(f"POST: {url}") if use_rest and "version" not in params: - params["version"] = self.version if self.version else "2024-06-21" + params["version"] = self.version or self.__latest_version resp = retry_call( self.request, @@ -135,7 +136,7 @@ def patch( logger.debug(f"PATCH: {url}") if "version" not in params: - params["version"] = self.version if self.version else "2024-06-21" + params["version"] = self.version or self.__latest_version resp = retry_call( self.request, @@ -161,7 +162,7 @@ def put( self, path: str, body: Any, - headers: dict = {}, + headers: Dict = {}, params: Dict[str, Any] = {}, use_rest: bool = False, ) -> requests.Response: @@ -172,7 +173,7 @@ def put( logger.debug("PUT: %s" % url) if use_rest and "version" not in params: - params["version"] = self.version if self.version else "2024-06-21" + params["version"] = self.version or self.__latest_version resp = retry_call( self.request, @@ -196,7 +197,7 @@ def put( def get( self, path: str, - params: dict = None, + params: Dict = None, version: str = None, exclude_version: bool = False, exclude_params: bool = False, @@ -279,7 +280,7 @@ def delete(self, path: str, use_rest: bool = False) -> requests.Response: params = {} if use_rest: - params["version"] = self.version if self.version else "2024-06-21" + params["version"] = self.version or self.__latest_version logger.debug(f"DELETE: {url}") @@ -298,7 +299,7 @@ def delete(self, path: str, use_rest: bool = False) -> requests.Response: return resp - def get_rest_pages(self, path: str, params: dict = {}) -> List: + def get_rest_pages(self, path: str, params: Dict = {}) -> List: """ Helper function to collect paginated responses from the rest API into a single list. From 45d2ec8fbd44a6b94bbad677905fd6cc3a6be7cf Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Tue, 20 Aug 2024 10:21:46 +0300 Subject: [PATCH 21/23] chore: replace type from any to dict --- snyk/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snyk/client.py b/snyk/client.py index 90a3139..0d40df4 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -93,7 +93,7 @@ def request( def post( self, path: str, - body: Any, + body: Dict[str, Any], headers: Dict = {}, params: Dict[str, Any] = {}, use_rest: bool = False, From 09987800f04a12ed63467c339a152f5eb312d400 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Tue, 20 Aug 2024 15:32:37 +0300 Subject: [PATCH 22/23] fix: added rest content type for post requests when calling rest route --- snyk/client.py | 9 ++++++--- snyk/test_client.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index 0d40df4..ac23843 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -101,15 +101,18 @@ def post( url = f"{self.rest_api_url if use_rest else self.api_url}/{path}" logger.debug(f"POST: {url}") - if use_rest and "version" not in params: - params["version"] = self.version or self.__latest_version + request_headers = {**self.api_post_headers, **headers} + if use_rest: + if "version" not in params: + params["version"] = self.version or self.__latest_version + request_headers["Content-Type"] = "application/vnd.api+json" resp = retry_call( self.request, fargs=[requests.post, url], fkwargs={ "json": body, - "headers": {**self.api_post_headers, **headers}, + "headers": request_headers, "params": params, }, tries=self.tries, diff --git a/snyk/test_client.py b/snyk/test_client.py index bf666a5..069ab8c 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -19,6 +19,8 @@ V3_URL = "https://api.snyk.io/v3" V3_VERSION = "2022-02-16~experimental" +V1_URL = "https://api.snyk.io/v1" + class TestSnykClient(object): @pytest.fixture @@ -406,6 +408,42 @@ def test_post_request_rest_api_when_specified(self, requests_mock, client): assert requests_mock.call_count == 1 + def test_post_request_has_rest_content_type_when_specified( + self, requests_mock, client + ): + matcher = re.compile( + f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" + ) + requests_mock.post(matcher, json={}, status_code=200) + params = {"version": REST_VERSION} + client.post( + f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + body={}, + params=params, + use_rest=True, + ) + + assert ( + requests_mock.last_request.headers["Content-Type"] + == "application/vnd.api+json" + ) + + def test_post_request_has_v1_content_type_when_specified( + self, requests_mock, client + ): + matcher = re.compile( + f"{V1_URL}/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb$" + ) + requests_mock.post(matcher, json={}, status_code=200) + + client.post( + f"{V1_URL}/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb", + body={}, + use_rest=False, + ) + + assert requests_mock.last_request.headers["Content-Type"] == "application/json" + def test_put_request_rest_api_when_specified(self, requests_mock, client): matcher = re.compile( f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" From d4fe020ad119d4629f05bcd4d282a5cfdd692819 Mon Sep 17 00:00:00 2001 From: David Alexandru Pop Date: Tue, 20 Aug 2024 16:00:14 +0300 Subject: [PATCH 23/23] fix: added rest content type for put method and tests --- snyk/client.py | 10 +++++--- snyk/test_client.py | 56 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/snyk/client.py b/snyk/client.py index ac23843..c4bddc4 100644 --- a/snyk/client.py +++ b/snyk/client.py @@ -175,15 +175,19 @@ def put( ) logger.debug("PUT: %s" % url) - if use_rest and "version" not in params: - params["version"] = self.version or self.__latest_version + request_headers = {**self.api_post_headers, **headers} + + if use_rest: + if "version" not in params: + params["version"] = self.version or self.__latest_version + request_headers["Content-Type"] = "application/vnd.api+json" resp = retry_call( self.request, fargs=[requests.put, url], fkwargs={ "json": body, - "headers": {**self.api_post_headers, **headers}, + "headers": request_headers, "params": params, }, tries=self.tries, diff --git a/snyk/test_client.py b/snyk/test_client.py index 069ab8c..3a760d1 100644 --- a/snyk/test_client.py +++ b/snyk/test_client.py @@ -335,7 +335,7 @@ def test_patch_update_project_should_return_updated_project( ): project = projects["data"][0] matcher = re.compile( - f"orgs/{REST_ORG}/projects/{project['id']}\\?([^&=]+=[^&=]+&?)+$" + f"^{REST_URL}/orgs/{REST_ORG}/projects/{project['id']}\\?([^&=]+=[^&=]+&?)+$" ) body = { "data": { @@ -395,12 +395,12 @@ def test_patch_update_project_when_invalid_should_throw_exception( def test_post_request_rest_api_when_specified(self, requests_mock, client): matcher = re.compile( - f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" + f"^{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" ) requests_mock.post(matcher, json={}, status_code=200) params = {"version": REST_VERSION} client.post( - f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + f"orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", body={}, params=params, use_rest=True, @@ -412,12 +412,12 @@ def test_post_request_has_rest_content_type_when_specified( self, requests_mock, client ): matcher = re.compile( - f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" + f"^{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" ) requests_mock.post(matcher, json={}, status_code=200) params = {"version": REST_VERSION} client.post( - f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + f"orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", body={}, params=params, use_rest=True, @@ -432,12 +432,12 @@ def test_post_request_has_v1_content_type_when_specified( self, requests_mock, client ): matcher = re.compile( - f"{V1_URL}/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb$" + f"^{V1_URL}/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb$" ) requests_mock.post(matcher, json={}, status_code=200) client.post( - f"{V1_URL}/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb", + f"org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb", body={}, use_rest=False, ) @@ -446,12 +446,12 @@ def test_post_request_has_v1_content_type_when_specified( def test_put_request_rest_api_when_specified(self, requests_mock, client): matcher = re.compile( - f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" + f"^{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" ) requests_mock.put(matcher, json={}, status_code=200) params = {"version": REST_VERSION} client.put( - f"{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + f"orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", body={}, params=params, use_rest=True, @@ -461,7 +461,7 @@ def test_put_request_rest_api_when_specified(self, requests_mock, client): def test_put_request_v1_api_when_specified(self, requests_mock, client): matcher = re.compile( - f"^https://api.snyk.io/v1/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb" + f"^{V1_URL}/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb" ) requests_mock.put(matcher, json={}, status_code=200) client.put( @@ -472,6 +472,42 @@ def test_put_request_v1_api_when_specified(self, requests_mock, client): assert requests_mock.call_count == 1 + def test_put_request_has_rest_content_type_when_specified( + self, requests_mock, client + ): + matcher = re.compile( + f"^{REST_URL}/orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb\\?version={REST_VERSION}$" + ) + requests_mock.put(matcher, json={}, status_code=200) + params = {"version": REST_VERSION} + client.put( + f"orgs/{REST_ORG}/projects/f9fec29a-d288-40d9-a019-cedf825e6efb", + body={}, + params=params, + use_rest=True, + ) + + assert ( + requests_mock.last_request.headers["Content-Type"] + == "application/vnd.api+json" + ) + + def test_put_request_has_v1_content_type_when_specified( + self, requests_mock, client + ): + matcher = re.compile( + f"^{V1_URL}/org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb$" + ) + requests_mock.put(matcher, json={}, status_code=200) + + client.put( + f"org/{REST_ORG}/project/f9fec29a-d288-40d9-a019-cedf825e6efb", + body={}, + use_rest=False, + ) + + assert requests_mock.last_request.headers["Content-Type"] == "application/json" + def test_delete_use_rest_when_specified(self, requests_mock, client): matcher = re.compile( "^%s/orgs/%s\\?version=2[0-9]{3}-[0-9]{2}-[0-9]{2}$" % (REST_URL, REST_ORG)