From c2de81c7916d1dc50dcb3d40ed2ecf72eef3d5cf Mon Sep 17 00:00:00 2001 From: Tudor Amariei Date: Mon, 19 Aug 2024 11:44:08 +0300 Subject: [PATCH 1/3] Add the first set of endpoints - add status & nomenclature endpoints - add decoders for each endpoint type - add tests to be able to check that the endpoints work --- .gitignore | 3 + README.md | 2 +- pyproject.toml | 12 +++- requirements/dev.txt | 34 +++++++++-- requirements/format.txt | 2 +- requirements/tests.in | 7 +++ requirements/tests.txt | 21 +++++++ src/ngohub/__init__.py | 2 +- src/ngohub/core.py | 96 ++++++++++++++++++++++++++++++ src/ngohub/exceptions.py | 40 +++++++++++++ src/ngohub/nomenclatures.py | 70 ++++++++++++++++++++++ src/ngohub/status.py | 22 +++++++ tests/conftest.py | 25 ++++++++ tests/schemas.py | 75 ++++++++++++++++++++++++ tests/test_nomenclatures.py | 114 ++++++++++++++++++++++++++++++++++++ tests/test_status.py | 18 ++++++ 16 files changed, 535 insertions(+), 8 deletions(-) create mode 100644 src/ngohub/exceptions.py create mode 100644 src/ngohub/nomenclatures.py create mode 100644 src/ngohub/status.py create mode 100644 tests/conftest.py create mode 100644 tests/schemas.py create mode 100644 tests/test_nomenclatures.py create mode 100644 tests/test_status.py diff --git a/.gitignore b/.gitignore index 4b81198..e1f45b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.env* +!.env.example* + ### Python template # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index 5c3f445..5569919 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Project name +# NGO Hub [![GitHub contributors][ico-contributors]][link-contributors] [![GitHub last commit][ico-last-commit]][link-last-commit] diff --git a/pyproject.toml b/pyproject.toml index bdffe26..c1a8eb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,6 @@ name = "NGOHub" version = "0.0.3" description = "Python client for ngohub.ro API" readme = "README.md" -license = { file = "LICENSE" } classifiers = [ "Development Status :: 1 - Planning", "Intended Audience :: Developers", @@ -59,3 +58,14 @@ known_tests = "tests" known_first_party = "ngohub" default_section = "THIRDPARTY" sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER,TESTS" + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q" +testpaths = [ + "tests", +] +env_files = [ + ".env", + ".env.test", +] diff --git a/requirements/dev.txt b/requirements/dev.txt index 82727ec..9c834cb 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,12 +19,20 @@ click==8.1.7 # pip-tools colorama==0.4.6 # via tox +coverage==7.6.1 + # via + # -r tests.txt + # pytest-cov distlib==0.3.8 # via virtualenv filelock==3.15.4 # via # tox # virtualenv +iniconfig==2.0.0 + # via + # -r tests.txt + # pytest mypy-extensions==1.0.0 # via # -r format.txt @@ -32,9 +40,11 @@ mypy-extensions==1.0.0 packaging==24.1 # via # -r format.txt + # -r tests.txt # black # build # pyproject-api + # pytest # tox pathspec==0.12.1 # via @@ -49,20 +59,36 @@ platformdirs==4.2.2 # tox # virtualenv pluggy==1.5.0 - # via tox + # via + # -r tests.txt + # pytest + # tox pyproject-api==1.7.1 # via tox pyproject-hooks==1.1.0 # via # build # pip-tools -ruff==0.5.5 +pytest==8.3.2 + # via + # -r tests.txt + # pytest-cov + # pytest-env +pytest-cov==5.0.0 + # via -r tests.txt +pytest-env==1.1.3 + # via -r tests.txt +python-dotenv==1.0.1 + # via -r tests.txt +ruff==0.5.7 # via -r format.txt -tox==4.16.0 +schema==0.7.7 + # via -r tests.txt +tox==4.18.0 # via -r dev.in virtualenv==20.26.3 # via tox -wheel==0.43.0 +wheel==0.44.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/format.txt b/requirements/format.txt index 6ded0b5..12bcde3 100644 --- a/requirements/format.txt +++ b/requirements/format.txt @@ -16,5 +16,5 @@ pathspec==0.12.1 # via black platformdirs==4.2.2 # via black -ruff==0.5.5 +ruff==0.5.7 # via -r format.in diff --git a/requirements/tests.in b/requirements/tests.in index e69de29..3782551 100644 --- a/requirements/tests.in +++ b/requirements/tests.in @@ -0,0 +1,7 @@ +pytest +pytest-env +pytest-cov + +python-dotenv + +schema diff --git a/requirements/tests.txt b/requirements/tests.txt index 69533fc..09bf0ee 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -4,3 +4,24 @@ # # pip-compile --strip-extras tests.in # +coverage==7.6.1 + # via pytest-cov +iniconfig==2.0.0 + # via pytest +packaging==24.1 + # via pytest +pluggy==1.5.0 + # via pytest +pytest==8.3.2 + # via + # -r tests.in + # pytest-cov + # pytest-env +pytest-cov==5.0.0 + # via -r tests.in +pytest-env==1.1.3 + # via -r tests.in +python-dotenv==1.0.1 + # via -r tests.in +schema==0.7.7 + # via -r tests.in diff --git a/src/ngohub/__init__.py b/src/ngohub/__init__.py index 7147d19..7db3349 100644 --- a/src/ngohub/__init__.py +++ b/src/ngohub/__init__.py @@ -1 +1 @@ -from core import NGOHub +from .core import NGOHub diff --git a/src/ngohub/core.py b/src/ngohub/core.py index cf5daad..cad94b4 100644 --- a/src/ngohub/core.py +++ b/src/ngohub/core.py @@ -1,2 +1,98 @@ +import http +import json +import logging +import os +import socket +import urllib.parse +from http.client import HTTPResponse, HTTPSConnection +from typing import Dict, Optional + +from ngohub.exceptions import NGOHubDecodeException, NGOHubHTTPException + +logger = logging.getLogger(__name__) + +NGO_HUB_API_URL: str = os.environ.get("NGO_HUB_API_URL") + + +def decode_raw_response(response: HTTPResponse) -> str: + try: + string_response: str = response.read().decode("utf-8") + except UnicodeDecodeError: + raise NGOHubDecodeException(f"Failed to decode response: {response.read()}") + + return string_response + + +def json_response(response: HTTPResponse) -> Dict: + string_response: str = decode_raw_response(response) + + try: + dict_response = json.loads(string_response) + except json.JSONDecodeError: + raise NGOHubDecodeException(f"Failed to decode JSON response: {response.read()}") + + return dict_response + + +def ngohub_api_request( + request_method: str, + path: str, + token: str, + params: Optional[Dict], +) -> HTTPResponse: + """ + Perform a request to the NGO Hub API and return a JSON response, or raise NGOHubHTTPException + """ + if not NGO_HUB_API_URL: + raise ValueError("Environment variable NGO_HUB_API_URL can't be empty") + + if not path.startswith("/"): + path = f"/{path}" + + conn: HTTPSConnection = http.client.HTTPSConnection(NGO_HUB_API_URL) + + headers: Dict = { + "Content-Type": "application/json", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + encoded_params = None + if params: + encoded_params = urllib.parse.urlencode(params) + + try: + conn.request(method=request_method, url=path, body=encoded_params, headers=headers) + except socket.gaierror as e: + raise NGOHubHTTPException(f"Failed to make request to '{path}': {e}") + + try: + response: HTTPResponse = conn.getresponse() + except ConnectionError as e: + raise NGOHubHTTPException(f"Failed to get response from '{path}': {e}") + + if response.status != http.HTTPStatus.OK: + logger.info(path) + raise NGOHubHTTPException(f"{response.status} while retrieving '{path}'. Reason: {response.reason}") + + return response + + +def ngohub_api_get(path: str, token: str = None) -> HTTPResponse: + return ngohub_api_request("GET", path, token, params=None) + + +def ngohub_api_post(path: str, params: Dict, token: str = None) -> HTTPResponse: + return ngohub_api_request("POST", path, token, params) + + +def ngohub_api_patch(path: str, params: Dict, token: str = None) -> HTTPResponse: + return ngohub_api_request("PATCH", path, token, params) + + +def ngohub_api_delete(path: str, token: str = None) -> HTTPResponse: + return ngohub_api_request("DELETE", path, token, params=None) + + class NGOHub: pass diff --git a/src/ngohub/exceptions.py b/src/ngohub/exceptions.py new file mode 100644 index 0000000..7bcd526 --- /dev/null +++ b/src/ngohub/exceptions.py @@ -0,0 +1,40 @@ +class OrganizationException(Exception): + """Some kind of problem with an organization""" + + pass + + +class ClosedRegistrationException(OrganizationException): + """New organizations cannot be registered anymore""" + + pass + + +class DisabledOrganizationException(OrganizationException): + """The requested organization has been disabled from the platform""" + + pass + + +class DuplicateOrganizationException(OrganizationException): + """An organization with the same NGO Hub ID already exists""" + + pass + + +class MissingOrganizationException(OrganizationException): + """The requested organization does not exist""" + + pass + + +class NGOHubHTTPException(OrganizationException): + """NGO Hub API error""" + + pass + + +class NGOHubDecodeException(NGOHubHTTPException): + """Failed to decode response""" + + pass diff --git a/src/ngohub/nomenclatures.py b/src/ngohub/nomenclatures.py new file mode 100644 index 0000000..ca3d1eb --- /dev/null +++ b/src/ngohub/nomenclatures.py @@ -0,0 +1,70 @@ +from http.client import HTTPResponse +from typing import Any, Dict, List + +from ngohub.core import json_response, ngohub_api_get + + +def get_nomenclature(nomenclature: str) -> Any: + response: HTTPResponse = ngohub_api_get(f"/nomenclatures/{nomenclature}") + + return json_response(response) + + +def get_cities_nomenclatures(search: str = None, county_id: int = None, city_id: int = None) -> List[Dict[str, Any]]: + mandatory_params: List[Any] = [search, county_id] + if all(param is None for param in mandatory_params): + raise ValueError("Please provide at least one of the following: county_id, search") + + search_query: List[str] = [] + if search: + search_query.append(f"search={search}") + if county_id: + search_query.append(f"countyId={county_id}") + if city_id: + search_query.append(f"cityId={city_id}") + + return get_nomenclature(f"cities?{'&'.join(search_query)}") + + +def get_counties_nomenclatures() -> List[Dict[str, Any]]: + return get_nomenclature("counties") + + +def get_domains_nomenclatures(): + return get_nomenclature("domains") + + +def get_regions_nomenclatures(): + return get_nomenclature("regions") + + +def get_federations_nomenclatures(): + return get_nomenclature("federations") + + +def get_coalitions_nomenclatures(): + return get_nomenclature("coalitions") + + +def get_faculties_nomenclatures(): + return get_nomenclature("faculties") + + +def get_skills_nomenclatures(): + return get_nomenclature("skills") + + +def get_practice_domains_nomenclatures(): + return get_nomenclature("practice-domains") + + +def get_service_domains_nomenclatures(): + return get_nomenclature("service-domains") + + +def get_beneficiaries_nomenclatures(): + return get_nomenclature("beneficiaries") + + +def get_issuers_nomenclatures(): + return get_nomenclature("issuers") diff --git a/src/ngohub/status.py b/src/ngohub/status.py new file mode 100644 index 0000000..47a04ed --- /dev/null +++ b/src/ngohub/status.py @@ -0,0 +1,22 @@ +from http.client import HTTPResponse +from typing import Dict + +from ngohub.core import decode_raw_response, json_response, ngohub_api_get + + +def get_health() -> str: + response: HTTPResponse = ngohub_api_get("/health/") + + return decode_raw_response(response) + + +def get_version() -> Dict: + response: HTTPResponse = ngohub_api_get("/version/") + + return json_response(response) + + +def get_file_url(path: str) -> str: + response: HTTPResponse = ngohub_api_get(f"/file?path={path}") + + return decode_raw_response(response) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..107fd22 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +from dotenv import find_dotenv, load_dotenv + + +def pytest_configure(): + load_dotenv(find_dotenv(filename=".env.test")) + + +class FakeHTTPSConnection: + def __init__(self, status): + self.status = status + + def request(self, *args, **kwargs): + pass + + def getresponse(self): + return FakeHTTPResponse(self.status) + + +class FakeHTTPResponse: + def __init__(self, status): + self.status = status + + @staticmethod + def read(): + return b"OK" diff --git a/tests/schemas.py b/tests/schemas.py new file mode 100644 index 0000000..db4bb68 --- /dev/null +++ b/tests/schemas.py @@ -0,0 +1,75 @@ +from schema import Regex, Schema + +NOMENCLATURE_CITIES_SCHEMA = Schema( + [ + { + "id": int, + "name": str, + "countyId": int, + "createdOn": Regex(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z"), + "county": { + "id": int, + "name": str, + "abbreviation": str, + "regionId": int, + "createdOn": Regex(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z"), + }, + } + ] +) + +NOMENCLATURE_COUNTIES_SCHEMA = Schema( + [ + { + "id": int, + "name": str, + "abbreviation": str, + "regionId": int, + "createdOn": Regex(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z"), + } + ] +) + +NOMENCLATURE_ISSUERS_SCHEMA = NOMENCLATURE_SKILLS_SCHEMA = NOMENCLATURE_FACULTIES_SCHEMA = ( + NOMENCLATURE_REGIONS_SCHEMA +) = NOMENCLATURE_DOMAINS_SCHEMA = Schema( + [ + { + "id": int, + "name": str, + "createdOn": Regex(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z"), + "updatedOn": Regex(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z"), + } + ] +) + +NOMENCLATURE_FEDERATIONS_SCHEMA = NOMENCLATURE_COALITIONS_SCHEMA = Schema( + [ + { + "id": int, + "name": str, + "abbreviation": str, + "createdOn": Regex(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z"), + "updatedOn": Regex(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z"), + } + ] +) + +NOMENCLATURE_PRACTICE_DOMAINS_SCHEMA = NOMENCLATURE_SERVICE_DOMAINS_SCHEMA = Schema( + [ + { + "id": int, + "name": str, + "group": str, + } + ] +) + +NOMENCLATURE_BENEFIARIES_SCHEMA = Schema( + [ + { + "id": int, + "name": str, + } + ] +) diff --git a/tests/test_nomenclatures.py b/tests/test_nomenclatures.py new file mode 100644 index 0000000..1123f82 --- /dev/null +++ b/tests/test_nomenclatures.py @@ -0,0 +1,114 @@ +import pytest + +from ngohub.nomenclatures import ( + get_beneficiaries_nomenclatures, + get_cities_nomenclatures, + get_coalitions_nomenclatures, + get_counties_nomenclatures, + get_domains_nomenclatures, + get_faculties_nomenclatures, + get_federations_nomenclatures, + get_issuers_nomenclatures, + get_practice_domains_nomenclatures, + get_regions_nomenclatures, + get_service_domains_nomenclatures, + get_skills_nomenclatures, +) +from tests.schemas import ( + NOMENCLATURE_BENEFIARIES_SCHEMA, + NOMENCLATURE_CITIES_SCHEMA, + NOMENCLATURE_COALITIONS_SCHEMA, + NOMENCLATURE_COUNTIES_SCHEMA, + NOMENCLATURE_DOMAINS_SCHEMA, + NOMENCLATURE_FACULTIES_SCHEMA, + NOMENCLATURE_FEDERATIONS_SCHEMA, + NOMENCLATURE_ISSUERS_SCHEMA, + NOMENCLATURE_PRACTICE_DOMAINS_SCHEMA, + NOMENCLATURE_REGIONS_SCHEMA, + NOMENCLATURE_SERVICE_DOMAINS_SCHEMA, + NOMENCLATURE_SKILLS_SCHEMA, +) + + +def test_cities_nomenclatures_errors_if_no_params(): + with pytest.raises(ValueError): + get_cities_nomenclatures() + + +def test_cities_nomenclatures_returns_empty_response(): + response = get_cities_nomenclatures(search="UNKNOWN") + + assert response == [] + + +def test_cities_nomenclatures_schema(): + response = get_cities_nomenclatures(county_id=1, city_id=1) + + assert len(response) == 1 + assert NOMENCLATURE_CITIES_SCHEMA.validate(response) + + +def test_counties_nomenclatures_schema(): + response = get_counties_nomenclatures() + + assert NOMENCLATURE_COUNTIES_SCHEMA.validate(response) + + +def test_domains_nomenclatures_schema(): + response = get_domains_nomenclatures() + + assert NOMENCLATURE_DOMAINS_SCHEMA.validate(response) + + +def test_regions_nomenclatures_schema(): + response = get_regions_nomenclatures() + + assert NOMENCLATURE_REGIONS_SCHEMA.validate(response) + + +def test_federations_nomenclatures_schema(): + response = get_federations_nomenclatures() + + assert NOMENCLATURE_FEDERATIONS_SCHEMA.validate(response) + + +def test_coalitions_nomenclatures_schema(): + response = get_coalitions_nomenclatures() + + assert NOMENCLATURE_COALITIONS_SCHEMA.validate(response) + + +def test_faculties_nomenclatures_schema(): + response = get_faculties_nomenclatures() + + assert NOMENCLATURE_FACULTIES_SCHEMA.validate(response) + + +def test_skills_nomenclatures_schema(): + response = get_skills_nomenclatures() + + assert NOMENCLATURE_SKILLS_SCHEMA.validate(response) + + +def test_practice_domains_nomenclatures_schema(): + response = get_practice_domains_nomenclatures() + + assert NOMENCLATURE_PRACTICE_DOMAINS_SCHEMA.validate(response) + + +def test_service_domains_nomenclatures_schema(): + response = get_service_domains_nomenclatures() + + assert NOMENCLATURE_SERVICE_DOMAINS_SCHEMA.validate(response) + + +def test_beneficiaries_nomenclatures_schema(): + response = get_beneficiaries_nomenclatures() + + assert NOMENCLATURE_BENEFIARIES_SCHEMA.validate(response) + + +def test_issuers_nomenclatures_schema(): + response = get_issuers_nomenclatures() + + assert NOMENCLATURE_ISSUERS_SCHEMA.validate(response) diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..57bca61 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,18 @@ +from ngohub.status import get_file_url, get_health, get_version + + +def test_health_returns_ok(): + assert get_health() == "OK" + + +def test_version_returns_version_revision(): + response = get_version() + assert "version" in response + assert "revision" in response + + +def test_file_returns_path(): + file_path = "test.txt" + response = get_file_url(file_path) + + assert f"amazonaws.com/{file_path}?AWSAccessKeyId=" in response From 98c274a5efcf86a8ce50f27c1265cb05275401fa Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Wed, 21 Aug 2024 10:36:28 +0300 Subject: [PATCH 2/3] Refactor the hub and HTTP client --- src/ngohub/core.py | 133 +++++++++++++++++------------------- src/ngohub/exceptions.py | 18 +++-- src/ngohub/network.py | 112 ++++++++++++++++++++++++++++++ src/ngohub/nomenclatures.py | 70 ------------------- src/ngohub/status.py | 22 ------ tests/test_nomenclatures.py | 57 ++++++++-------- tests/test_status.py | 11 +-- 7 files changed, 222 insertions(+), 201 deletions(-) create mode 100644 src/ngohub/network.py delete mode 100644 src/ngohub/nomenclatures.py delete mode 100644 src/ngohub/status.py diff --git a/src/ngohub/core.py b/src/ngohub/core.py index cad94b4..0b5cb02 100644 --- a/src/ngohub/core.py +++ b/src/ngohub/core.py @@ -1,98 +1,89 @@ -import http -import json -import logging -import os -import socket -import urllib.parse -from http.client import HTTPResponse, HTTPSConnection -from typing import Dict, Optional +from abc import ABC, abstractmethod +from typing import Any, Dict, List -from ngohub.exceptions import NGOHubDecodeException, NGOHubHTTPException +from ngohub.network import HTTPClient, HTTPClientResponse -logger = logging.getLogger(__name__) -NGO_HUB_API_URL: str = os.environ.get("NGO_HUB_API_URL") - - -def decode_raw_response(response: HTTPResponse) -> str: - try: - string_response: str = response.read().decode("utf-8") - except UnicodeDecodeError: - raise NGOHubDecodeException(f"Failed to decode response: {response.read()}") - - return string_response +class BaseHub(ABC): + """ + TODO: Define all the required methods for the hub interface + """ + @abstractmethod + def __init__(self, api_base_url: str) -> None: + pass -def json_response(response: HTTPResponse) -> Dict: - string_response: str = decode_raw_response(response) +class NGOHub(BaseHub): + def __init__(self, api_base_url: str) -> None: + self.api_base_url: str = api_base_url or "" + self.client: HTTPClient = HTTPClient(self.api_base_url) - try: - dict_response = json.loads(string_response) - except json.JSONDecodeError: - raise NGOHubDecodeException(f"Failed to decode JSON response: {response.read()}") + def get_health(self) -> str: + # TODO: Maybe refactor to `is_healthy() -> Bool`? + response: HTTPClientResponse = self.client.api_get("/health/") - return dict_response + return response.to_str() + def get_version(self) -> Dict: + # TODO: Maybe refactor to `get_version() -> str`? + response: HTTPClientResponse = self.client.api_get("/version/") -def ngohub_api_request( - request_method: str, - path: str, - token: str, - params: Optional[Dict], -) -> HTTPResponse: - """ - Perform a request to the NGO Hub API and return a JSON response, or raise NGOHubHTTPException - """ - if not NGO_HUB_API_URL: - raise ValueError("Environment variable NGO_HUB_API_URL can't be empty") + return response.to_dict() - if not path.startswith("/"): - path = f"/{path}" + def get_file_url(self, path: str) -> str: + response: HTTPClientResponse = self.client.api_get(f"/file?path={path}") - conn: HTTPSConnection = http.client.HTTPSConnection(NGO_HUB_API_URL) + return response.to_str() - headers: Dict = { - "Content-Type": "application/json", - } - if token: - headers["Authorization"] = f"Bearer {token}" + def _get_nomenclature(self, nomenclature: str) -> Any: + response: HTTPClientResponse = self.client.api_get(f"/nomenclatures/{nomenclature}") - encoded_params = None - if params: - encoded_params = urllib.parse.urlencode(params) + return response.to_dict() - try: - conn.request(method=request_method, url=path, body=encoded_params, headers=headers) - except socket.gaierror as e: - raise NGOHubHTTPException(f"Failed to make request to '{path}': {e}") + def get_cities_nomenclatures(self, search: str = None, county_id: int = None, city_id: int = None) -> List[Dict[str, Any]]: + mandatory_params: List[Any] = [search, county_id] + if all(param is None for param in mandatory_params): + raise ValueError("Please provide at least one of the following: county_id, search") - try: - response: HTTPResponse = conn.getresponse() - except ConnectionError as e: - raise NGOHubHTTPException(f"Failed to get response from '{path}': {e}") + search_query: List[str] = [] + if search: + search_query.append(f"search={search}") + if county_id: + search_query.append(f"countyId={county_id}") + if city_id: + search_query.append(f"cityId={city_id}") - if response.status != http.HTTPStatus.OK: - logger.info(path) - raise NGOHubHTTPException(f"{response.status} while retrieving '{path}'. Reason: {response.reason}") + return self._get_nomenclature(f"cities?{'&'.join(search_query)}") - return response + def get_counties_nomenclatures(self) -> List[Dict[str, Any]]: + return self._get_nomenclature("counties") + def get_domains_nomenclatures(self): + return self._get_nomenclature("domains") -def ngohub_api_get(path: str, token: str = None) -> HTTPResponse: - return ngohub_api_request("GET", path, token, params=None) + def get_regions_nomenclatures(self): + return self._get_nomenclature("regions") + def get_federations_nomenclatures(self): + return self._get_nomenclature("federations") -def ngohub_api_post(path: str, params: Dict, token: str = None) -> HTTPResponse: - return ngohub_api_request("POST", path, token, params) + def get_coalitions_nomenclatures(self): + return self._get_nomenclature("coalitions") + def get_faculties_nomenclatures(self): + return self._get_nomenclature("faculties") -def ngohub_api_patch(path: str, params: Dict, token: str = None) -> HTTPResponse: - return ngohub_api_request("PATCH", path, token, params) + def get_skills_nomenclatures(self): + return self._get_nomenclature("skills") + def get_practice_domains_nomenclatures(self): + return self._get_nomenclature("practice-domains") -def ngohub_api_delete(path: str, token: str = None) -> HTTPResponse: - return ngohub_api_request("DELETE", path, token, params=None) + def get_service_domains_nomenclatures(self): + return self._get_nomenclature("service-domains") + def get_beneficiaries_nomenclatures(self): + return self._get_nomenclature("beneficiaries") -class NGOHub: - pass + def get_issuers_nomenclatures(self): + return self._get_nomenclature("issuers") diff --git a/src/ngohub/exceptions.py b/src/ngohub/exceptions.py index 7bcd526..a7e1551 100644 --- a/src/ngohub/exceptions.py +++ b/src/ngohub/exceptions.py @@ -1,10 +1,16 @@ -class OrganizationException(Exception): - """Some kind of problem with an organization""" +class HubException(Exception): + """The base exception for all Hub issues""" pass -class ClosedRegistrationException(OrganizationException): +class OrganizationException(HubException): + """The base exception for all Hub Organization issues""" + + pass + + +class ClosedOrganizationRegistrationException(OrganizationException): """New organizations cannot be registered anymore""" pass @@ -28,13 +34,13 @@ class MissingOrganizationException(OrganizationException): pass -class NGOHubHTTPException(OrganizationException): - """NGO Hub API error""" +class HubHTTPException(HubException): + """The base exception for all Hub HTTP/network issues""" pass -class NGOHubDecodeException(NGOHubHTTPException): +class HubDecodeException(HubHTTPException): """Failed to decode response""" pass diff --git a/src/ngohub/network.py b/src/ngohub/network.py new file mode 100644 index 0000000..c55cb83 --- /dev/null +++ b/src/ngohub/network.py @@ -0,0 +1,112 @@ + +import http +import json +import logging +import socket +import urllib.parse +from http.client import HTTPResponse, HTTPSConnection +from typing import Dict, Optional + +from ngohub.exceptions import HubDecodeException, HubHTTPException + + +logger = logging.getLogger(__name__) + + +class HTTPClientResponse: + """ + HTTP responses with some helper methods + """ + + def __init__(self, raw_response: HTTPResponse): + self.raw_response: HTTPResponse = raw_response + + def to_str(self) -> str: + try: + string_response: str = self.raw_response.read().decode("utf-8") + except UnicodeDecodeError: + raise HubDecodeException(f"Failed to decode response: {self.raw_response.read()}") + + return string_response + + def to_dict(self) -> Dict: + string_response: str = self.to_str() + + try: + dict_response = json.loads(string_response) + except json.JSONDecodeError: + raise HubDecodeException(f"Failed to decode JSON response: {self.raw_response.read()}") + + return dict_response + + +class HTTPClient: + """ + HTTP client for interacting with an HTTP API + """ + + def __init__(self, api_base_url: str, *, auth_type="Bearer", auth_header="Authorization"): + self.api_base_url = api_base_url or "" + self.auth_type = auth_type + self.auth_header = auth_header + + def _api_request( + self, + request_method: str, + path: str, + token: str, + params: Optional[Dict], + ) -> HTTPClientResponse: + """ + Perform a request to the NGO Hub API and return a JSON response, or raise HubHTTPException + """ + if not self.api_base_url: + raise ValueError("The API base URL cannot be empty") + + if not path.startswith("/"): + path = f"/{path}" + + conn: HTTPSConnection = http.client.HTTPSConnection(self.api_base_url) + + headers: Dict = { + "Content-Type": "application/json", + } + if token: + headers[self.auth_header] = f"{self.auth_type} {token}" + + encoded_params = None + if params: + encoded_params = urllib.parse.urlencode(params) + + try: + conn.request(method=request_method, url=path, body=encoded_params, headers=headers) + except socket.gaierror as e: + raise HubHTTPException(f"Failed to make request to '{path}': {e}") + + try: + response: HTTPResponse = conn.getresponse() + except ConnectionError as e: + raise HubHTTPException(f"Failed to get response from '{path}': {e}") + + if response.status != http.HTTPStatus.OK: + logger.info(path) + raise HubHTTPException(f"{response.status} while retrieving '{path}'. Reason: {response.reason}") + + return HTTPClientResponse(response) + + + def api_get(self, path: str, token: str = None) -> HTTPClientResponse: + return self._api_request("GET", path, token, params=None) + + + def api_post(self, path: str, params: Dict, token: str = None) -> HTTPClientResponse: + return self._api_request("POST", path, token, params) + + + def api_patch(self, path: str, params: Dict, token: str = None) -> HTTPClientResponse: + return self._api_request("PATCH", path, token, params) + + + def api_delete(self, path: str, token: str = None) -> HTTPClientResponse: + return self._api_request("DELETE", path, token, params=None) + diff --git a/src/ngohub/nomenclatures.py b/src/ngohub/nomenclatures.py deleted file mode 100644 index ca3d1eb..0000000 --- a/src/ngohub/nomenclatures.py +++ /dev/null @@ -1,70 +0,0 @@ -from http.client import HTTPResponse -from typing import Any, Dict, List - -from ngohub.core import json_response, ngohub_api_get - - -def get_nomenclature(nomenclature: str) -> Any: - response: HTTPResponse = ngohub_api_get(f"/nomenclatures/{nomenclature}") - - return json_response(response) - - -def get_cities_nomenclatures(search: str = None, county_id: int = None, city_id: int = None) -> List[Dict[str, Any]]: - mandatory_params: List[Any] = [search, county_id] - if all(param is None for param in mandatory_params): - raise ValueError("Please provide at least one of the following: county_id, search") - - search_query: List[str] = [] - if search: - search_query.append(f"search={search}") - if county_id: - search_query.append(f"countyId={county_id}") - if city_id: - search_query.append(f"cityId={city_id}") - - return get_nomenclature(f"cities?{'&'.join(search_query)}") - - -def get_counties_nomenclatures() -> List[Dict[str, Any]]: - return get_nomenclature("counties") - - -def get_domains_nomenclatures(): - return get_nomenclature("domains") - - -def get_regions_nomenclatures(): - return get_nomenclature("regions") - - -def get_federations_nomenclatures(): - return get_nomenclature("federations") - - -def get_coalitions_nomenclatures(): - return get_nomenclature("coalitions") - - -def get_faculties_nomenclatures(): - return get_nomenclature("faculties") - - -def get_skills_nomenclatures(): - return get_nomenclature("skills") - - -def get_practice_domains_nomenclatures(): - return get_nomenclature("practice-domains") - - -def get_service_domains_nomenclatures(): - return get_nomenclature("service-domains") - - -def get_beneficiaries_nomenclatures(): - return get_nomenclature("beneficiaries") - - -def get_issuers_nomenclatures(): - return get_nomenclature("issuers") diff --git a/src/ngohub/status.py b/src/ngohub/status.py deleted file mode 100644 index 47a04ed..0000000 --- a/src/ngohub/status.py +++ /dev/null @@ -1,22 +0,0 @@ -from http.client import HTTPResponse -from typing import Dict - -from ngohub.core import decode_raw_response, json_response, ngohub_api_get - - -def get_health() -> str: - response: HTTPResponse = ngohub_api_get("/health/") - - return decode_raw_response(response) - - -def get_version() -> Dict: - response: HTTPResponse = ngohub_api_get("/version/") - - return json_response(response) - - -def get_file_url(path: str) -> str: - response: HTTPResponse = ngohub_api_get(f"/file?path={path}") - - return decode_raw_response(response) diff --git a/tests/test_nomenclatures.py b/tests/test_nomenclatures.py index 1123f82..ddaf293 100644 --- a/tests/test_nomenclatures.py +++ b/tests/test_nomenclatures.py @@ -1,19 +1,6 @@ import pytest -from ngohub.nomenclatures import ( - get_beneficiaries_nomenclatures, - get_cities_nomenclatures, - get_coalitions_nomenclatures, - get_counties_nomenclatures, - get_domains_nomenclatures, - get_faculties_nomenclatures, - get_federations_nomenclatures, - get_issuers_nomenclatures, - get_practice_domains_nomenclatures, - get_regions_nomenclatures, - get_service_domains_nomenclatures, - get_skills_nomenclatures, -) +from ngohub.core import NGOHub from tests.schemas import ( NOMENCLATURE_BENEFIARIES_SCHEMA, NOMENCLATURE_CITIES_SCHEMA, @@ -31,84 +18,98 @@ def test_cities_nomenclatures_errors_if_no_params(): + hub = NGOHub("/") with pytest.raises(ValueError): - get_cities_nomenclatures() + hub.get_cities_nomenclatures() def test_cities_nomenclatures_returns_empty_response(): - response = get_cities_nomenclatures(search="UNKNOWN") + hub = NGOHub("/") + response = hub.get_cities_nomenclatures(search="UNKNOWN") assert response == [] def test_cities_nomenclatures_schema(): - response = get_cities_nomenclatures(county_id=1, city_id=1) + hub = NGOHub("/") + response = hub.get_cities_nomenclatures(county_id=1, city_id=1) assert len(response) == 1 assert NOMENCLATURE_CITIES_SCHEMA.validate(response) def test_counties_nomenclatures_schema(): - response = get_counties_nomenclatures() + hub = NGOHub("/") + response = hub.get_counties_nomenclatures() assert NOMENCLATURE_COUNTIES_SCHEMA.validate(response) def test_domains_nomenclatures_schema(): - response = get_domains_nomenclatures() + hub = NGOHub("/") + response = hub.get_domains_nomenclatures() assert NOMENCLATURE_DOMAINS_SCHEMA.validate(response) def test_regions_nomenclatures_schema(): - response = get_regions_nomenclatures() + hub = NGOHub("/") + response = hub.get_regions_nomenclatures() assert NOMENCLATURE_REGIONS_SCHEMA.validate(response) def test_federations_nomenclatures_schema(): - response = get_federations_nomenclatures() + hub = NGOHub("/") + response = hub.get_federations_nomenclatures() assert NOMENCLATURE_FEDERATIONS_SCHEMA.validate(response) def test_coalitions_nomenclatures_schema(): - response = get_coalitions_nomenclatures() + hub = NGOHub("/") + response = hub.get_coalitions_nomenclatures() assert NOMENCLATURE_COALITIONS_SCHEMA.validate(response) def test_faculties_nomenclatures_schema(): - response = get_faculties_nomenclatures() + hub = NGOHub("/") + response = hub.get_faculties_nomenclatures() assert NOMENCLATURE_FACULTIES_SCHEMA.validate(response) def test_skills_nomenclatures_schema(): - response = get_skills_nomenclatures() + hub = NGOHub("/") + response = hub.get_skills_nomenclatures() assert NOMENCLATURE_SKILLS_SCHEMA.validate(response) def test_practice_domains_nomenclatures_schema(): - response = get_practice_domains_nomenclatures() + hub = NGOHub("/") + response = hub.get_practice_domains_nomenclatures() assert NOMENCLATURE_PRACTICE_DOMAINS_SCHEMA.validate(response) def test_service_domains_nomenclatures_schema(): - response = get_service_domains_nomenclatures() + hub = NGOHub("/") + response = hub.get_service_domains_nomenclatures() assert NOMENCLATURE_SERVICE_DOMAINS_SCHEMA.validate(response) def test_beneficiaries_nomenclatures_schema(): - response = get_beneficiaries_nomenclatures() + hub = NGOHub("/") + response = hub.get_beneficiaries_nomenclatures() assert NOMENCLATURE_BENEFIARIES_SCHEMA.validate(response) def test_issuers_nomenclatures_schema(): - response = get_issuers_nomenclatures() + hub = NGOHub("/") + response = hub.get_issuers_nomenclatures() assert NOMENCLATURE_ISSUERS_SCHEMA.validate(response) diff --git a/tests/test_status.py b/tests/test_status.py index 57bca61..784b9f8 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,18 +1,21 @@ -from ngohub.status import get_file_url, get_health, get_version +from ngohub.core import NGOHub def test_health_returns_ok(): - assert get_health() == "OK" + hub = NGOHub("/") + assert hub.get_health() == "OK" def test_version_returns_version_revision(): - response = get_version() + hub = NGOHub("/") + response = hub.get_version() assert "version" in response assert "revision" in response def test_file_returns_path(): + hub = NGOHub("/") file_path = "test.txt" - response = get_file_url(file_path) + response = hub.get_file_url(file_path) assert f"amazonaws.com/{file_path}?AWSAccessKeyId=" in response From 91ed48ddd1f4072777c7a1c278b5337cd4428d4b Mon Sep 17 00:00:00 2001 From: Tudor Amariei Date: Wed, 21 Aug 2024 11:17:45 +0300 Subject: [PATCH 3/3] Remove TODOs and linting errors --- pyproject.toml | 6 +++--- src/ngohub/__init__.py | 2 +- src/ngohub/core.py | 25 +++++++++++++++++-------- src/ngohub/network.py | 8 +------- tests/conftest.py | 9 +++++++++ tests/schemas.py | 7 +++++++ tests/test_nomenclatures.py | 28 ++++++++++++++-------------- tests/test_status.py | 15 +++++++++------ 8 files changed, 61 insertions(+), 39 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c1a8eb3..05378bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ Download = "https://github.com/code4romania/pyngohub/tags" requires = ["setuptools>=72.1"] build-backend = "setuptools.build_meta" +[lint] +ignore = [] + [tool.ruff] exclude = [ ".eggs", @@ -41,9 +44,6 @@ exclude = [ line-length = 120 target-version = "py311" -# TEMP: Disable until we have some code -ignore = ["F401"] - [tool.black] line-length = 120 target-version = ["py311"] diff --git a/src/ngohub/__init__.py b/src/ngohub/__init__.py index 7db3349..b70c06b 100644 --- a/src/ngohub/__init__.py +++ b/src/ngohub/__init__.py @@ -1 +1 @@ -from .core import NGOHub +from .core import NGOHub as NGOHub diff --git a/src/ngohub/core.py b/src/ngohub/core.py index 0b5cb02..f0c7931 100644 --- a/src/ngohub/core.py +++ b/src/ngohub/core.py @@ -6,8 +6,9 @@ class BaseHub(ABC): """ - TODO: Define all the required methods for the hub interface + Abstract class used to define all the required methods for a hub interface """ + @abstractmethod def __init__(self, api_base_url: str) -> None: pass @@ -18,17 +19,23 @@ def __init__(self, api_base_url: str) -> None: self.api_base_url: str = api_base_url or "" self.client: HTTPClient = HTTPClient(self.api_base_url) - def get_health(self) -> str: - # TODO: Maybe refactor to `is_healthy() -> Bool`? + def is_healthy(self) -> bool: response: HTTPClientResponse = self.client.api_get("/health/") - return response.to_str() + response_is_ok: bool = response.to_str() == "OK" + + return response_is_ok - def get_version(self) -> Dict: - # TODO: Maybe refactor to `get_version() -> str`? + def get_version(self) -> Dict[str, str]: response: HTTPClientResponse = self.client.api_get("/version/") - return response.to_dict() + response_dict: Dict = response.to_dict() + version_revision: Dict[str, str] = { + "version": response_dict["version"], + "revision": response_dict["revision"], + } + + return version_revision def get_file_url(self, path: str) -> str: response: HTTPClientResponse = self.client.api_get(f"/file?path={path}") @@ -40,7 +47,9 @@ def _get_nomenclature(self, nomenclature: str) -> Any: return response.to_dict() - def get_cities_nomenclatures(self, search: str = None, county_id: int = None, city_id: int = None) -> List[Dict[str, Any]]: + def get_cities_nomenclatures( + self, search: str = None, county_id: int = None, city_id: int = None + ) -> List[Dict[str, Any]]: mandatory_params: List[Any] = [search, county_id] if all(param is None for param in mandatory_params): raise ValueError("Please provide at least one of the following: county_id, search") diff --git a/src/ngohub/network.py b/src/ngohub/network.py index c55cb83..b6b583d 100644 --- a/src/ngohub/network.py +++ b/src/ngohub/network.py @@ -1,4 +1,3 @@ - import http import json import logging @@ -44,7 +43,7 @@ class HTTPClient: """ HTTP client for interacting with an HTTP API """ - + def __init__(self, api_base_url: str, *, auth_type="Bearer", auth_header="Authorization"): self.api_base_url = api_base_url or "" self.auth_type = auth_type @@ -94,19 +93,14 @@ def _api_request( return HTTPClientResponse(response) - def api_get(self, path: str, token: str = None) -> HTTPClientResponse: return self._api_request("GET", path, token, params=None) - def api_post(self, path: str, params: Dict, token: str = None) -> HTTPClientResponse: return self._api_request("POST", path, token, params) - def api_patch(self, path: str, params: Dict, token: str = None) -> HTTPClientResponse: return self._api_request("PATCH", path, token, params) - def api_delete(self, path: str, token: str = None) -> HTTPClientResponse: return self._api_request("DELETE", path, token, params=None) - diff --git a/tests/conftest.py b/tests/conftest.py index 107fd22..960868d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,18 @@ +import os + +import pytest from dotenv import find_dotenv, load_dotenv def pytest_configure(): load_dotenv(find_dotenv(filename=".env.test")) + pytest.ngohub_api_url = os.environ.get("NGO_HUB_API_URL") + + +def ngohub_url(): + return os.environ.get("NGO_HUB_API_URL") + class FakeHTTPSConnection: def __init__(self, status): diff --git a/tests/schemas.py b/tests/schemas.py index db4bb68..f0a0143 100644 --- a/tests/schemas.py +++ b/tests/schemas.py @@ -1,5 +1,12 @@ from schema import Regex, Schema +VERSION_REVISION_SCHEMA = Schema( + { + "version": Regex(r"\d+\.\d+\.\d+"), + "revision": Regex(r"[0-9a-f]{40}"), + } +) + NOMENCLATURE_CITIES_SCHEMA = Schema( [ { diff --git a/tests/test_nomenclatures.py b/tests/test_nomenclatures.py index ddaf293..0e45bba 100644 --- a/tests/test_nomenclatures.py +++ b/tests/test_nomenclatures.py @@ -18,20 +18,20 @@ def test_cities_nomenclatures_errors_if_no_params(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) with pytest.raises(ValueError): hub.get_cities_nomenclatures() def test_cities_nomenclatures_returns_empty_response(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_cities_nomenclatures(search="UNKNOWN") assert response == [] def test_cities_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_cities_nomenclatures(county_id=1, city_id=1) assert len(response) == 1 @@ -39,77 +39,77 @@ def test_cities_nomenclatures_schema(): def test_counties_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_counties_nomenclatures() assert NOMENCLATURE_COUNTIES_SCHEMA.validate(response) def test_domains_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_domains_nomenclatures() assert NOMENCLATURE_DOMAINS_SCHEMA.validate(response) def test_regions_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_regions_nomenclatures() assert NOMENCLATURE_REGIONS_SCHEMA.validate(response) def test_federations_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_federations_nomenclatures() assert NOMENCLATURE_FEDERATIONS_SCHEMA.validate(response) def test_coalitions_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_coalitions_nomenclatures() assert NOMENCLATURE_COALITIONS_SCHEMA.validate(response) def test_faculties_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_faculties_nomenclatures() assert NOMENCLATURE_FACULTIES_SCHEMA.validate(response) def test_skills_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_skills_nomenclatures() assert NOMENCLATURE_SKILLS_SCHEMA.validate(response) def test_practice_domains_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_practice_domains_nomenclatures() assert NOMENCLATURE_PRACTICE_DOMAINS_SCHEMA.validate(response) def test_service_domains_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_service_domains_nomenclatures() assert NOMENCLATURE_SERVICE_DOMAINS_SCHEMA.validate(response) def test_beneficiaries_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_beneficiaries_nomenclatures() assert NOMENCLATURE_BENEFIARIES_SCHEMA.validate(response) def test_issuers_nomenclatures_schema(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_issuers_nomenclatures() assert NOMENCLATURE_ISSUERS_SCHEMA.validate(response) diff --git a/tests/test_status.py b/tests/test_status.py index 784b9f8..e3f6977 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,20 +1,23 @@ +import pytest + from ngohub.core import NGOHub +from tests.schemas import VERSION_REVISION_SCHEMA def test_health_returns_ok(): - hub = NGOHub("/") - assert hub.get_health() == "OK" + hub = NGOHub(pytest.ngohub_api_url) + assert hub.is_healthy() def test_version_returns_version_revision(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) response = hub.get_version() - assert "version" in response - assert "revision" in response + + assert VERSION_REVISION_SCHEMA.validate(response) def test_file_returns_path(): - hub = NGOHub("/") + hub = NGOHub(pytest.ngohub_api_url) file_path = "test.txt" response = hub.get_file_url(file_path)