diff --git a/mapping_provider/__init__.py b/mapping_provider/__init__.py index e48147a4a4ff068ef4ef81152d18ac77138bcefc..8660a8800eb6e9442eaa353215589a46772c44fc 100644 --- a/mapping_provider/__init__.py +++ b/mapping_provider/__init__.py @@ -5,12 +5,16 @@ Default entry point for the FastAPI application. from fastapi import FastAPI from mapping_provider.api import common, map - +from mapping_provider import config +from mapping_provider import environment def create_app() -> FastAPI: """ Creates the FastAPI application instance, with routers attached. """ + environment.setup_logging() + environment.setup_sentry(config.load().sentry) + app = FastAPI( title="Mapping provider", description="Mapping provider endpoints for GÉANT maps", diff --git a/mapping_provider/api/map.py b/mapping_provider/api/map.py index 7616ab4ff0fb72c35fa5de5843dd1b2af316f9e3..a2b8461b13323a022fd89729faf36cc10a5daf7e 100644 --- a/mapping_provider/api/map.py +++ b/mapping_provider/api/map.py @@ -5,6 +5,8 @@ import requests from fastapi import APIRouter from pydantic import BaseModel +from mapping_provider import config + router = APIRouter() @@ -161,11 +163,11 @@ def get_sites() -> SiteList: """ handler for /sites """ - # TODO: catch/handle the usual exceptions + app_params = config.load() rv = requests.get( - f'{INPROV_API_URL_TODO}/map/sites', + f'{app_params.inventory}/map/sites', headers={'Accept': 'application/json'}) rv.raise_for_status() site_list_json = rv.json() @@ -183,8 +185,9 @@ def get_routers() -> RouterList: # TODO: catch/handle the usual exceptions + app_params = config.load() rv = requests.get( - f'{INPROV_API_URL_TODO}/map/routers', + f'{app_params.inventory}/map/routers', headers={'Accept': 'application/json'}) rv.raise_for_status() router_list_json = rv.json() @@ -202,8 +205,9 @@ def get_trunks() -> ServiceList: # TODO: catch/handle the usual exceptions + app_params = config.load() rv = requests.get( - f'{INPROV_API_URL_TODO}/map/services/IP TRUNK', + f'{app_params.inventory}/map/services/IP TRUNK', headers={'Accept': 'application/json'}) rv.raise_for_status() service_list_json = rv.json() diff --git a/mapping_provider/config.py b/mapping_provider/config.py new file mode 100644 index 0000000000000000000000000000000000000000..c61b44aee8a46c5eaa2f913285fbafe7beb59158 --- /dev/null +++ b/mapping_provider/config.py @@ -0,0 +1,65 @@ +import os +from pydantic import BaseModel, Field, HttpUrl + +class SentryConfig(BaseModel): + dsn: str + environment: str + level: str = Field( + default='error', + pattern="^(debug|info|warning|error)$") + + # @field_validator('level') + # @classmethod + # def validate_level(cls, v): + # if v not in ['debug', 'info', 'warning', 'error']: + # raise ValueError('level must be one of: debug, info, warning, error') + # return v + +class Configuration(BaseModel): + sentry: SentryConfig + inventory: HttpUrl + + +# CONFIG_SCHEMA = { +# '$schema': 'https://json-schema.org/draft/2020-12/schema', + +# 'definitions': { +# 'sentry-info': { +# 'type': 'object', +# 'properties': { +# 'dsn': {'type': 'string'}, +# 'environment': {'type': 'string'}, +# 'level': { +# 'type': 'string', +# 'enum': ['debug', 'info', 'warning', 'error'], +# 'default': DEFAULT_SENTRY_LOG_LEVEL +# } +# }, +# 'required': ['dsn'], +# 'additionalProperties': False, +# }, +# }, + +# 'type': 'object', +# 'properties': { +# 'sentry': {'$ref': '#/definitions/sentry-info'}, +# 'inventory': { +# 'type': 'string', +# 'format': 'uri', +# } +# }, +# 'required': ['sentry', 'inventory'], +# 'additionalProperties': False +# } + + +def load(): + """ + Loads, validates and returns configuration parameters from + the file named in the environment variable 'SETTINGS_FILENAME'. + + :return: + """ + assert 'SETTINGS_FILENAME' in os.environ + with open(os.environ['SETTINGS_FILENAME']) as f: + return Configuration.model_validate_json(f.read()) diff --git a/mapping_provider/environment.py b/mapping_provider/environment.py new file mode 100644 index 0000000000000000000000000000000000000000..527ab4b6215a3067c3ec67c88b7ef3cda4070af7 --- /dev/null +++ b/mapping_provider/environment.py @@ -0,0 +1,93 @@ +""" +Environment setup +=================== + +.. autofunction:: mapping_provider.environment.setup_logging + +.. autofunction:: mapping_provider.environment.setup_sentry + +""" +import json +import logging.config +import logging +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mapping_provider.config import SentryConfig + +import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration + + +LOGGING_DEFAULT_CONFIG = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'simple': { + 'format': '%(asctime)s - %(name)s ' + '(%(lineno)d) - %(levelname)s - %(message)s' + } + }, + + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'level': 'DEBUG', + 'formatter': 'simple', + 'stream': 'ext://sys.stdout' + } + + }, + + 'loggers': { + 'mapping_provider': { + 'level': 'DEBUG', + 'handlers': ['console'], + 'propagate': False + } + }, + + 'root': { + 'level': 'WARNING', + 'handlers': ['console'] + } +} + + +def setup_logging(): + """ + Sets up logging using the configured filename. + + if LOGGING_CONFIG is defined in the environment, use this for + the filename, otherwise use LOGGING_DEFAULT_CONFIG + """ + logging_config = LOGGING_DEFAULT_CONFIG + if 'LOGGING_CONFIG' in os.environ: + filename = os.environ['LOGGING_CONFIG'] + with open(filename) as f: + logging_config = json.load(f) + + logging.config.dictConfig(logging_config) + + +def setup_sentry(sentry_config_params: 'SentryConfig'): + """ + Sets up the sentry instrumentation based on the Configuration.sentry params. + """ + match str(sentry_config_params.level): + case 'debug': level = logging.DEBUG + case 'info': level = logging.INFO + case 'warning': level = logging.WARNING + case 'error': level = logging.ERROR + + sentry_sdk.init( + dsn=sentry_config_params.dsn, + environment=sentry_config_params.environment, + integrations=[ + LoggingIntegration( + level=logging.INFO, # Capture info and above as breadcrumbs + event_level=level # Send records as events + ), + ], + ) diff --git a/requirements.txt b/requirements.txt index bb2015ee9487eb433ea427c9b1981ce044ad2c78..0c99b1de0cb6985c5f38d93aa260d46242f3c856 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,18 +2,12 @@ fastapi uvicorn[standard] requests jsonschema +sentry_sdk httpx # required for fastapi TestClient pytest responses -sphinx -sphinx-rtd-theme -sphinxcontrib-plantuml -sphinxcontrib-drawio -sphinxcontrib-openapi - ruff mypy -tox pre-commit diff --git a/setup.py b/setup.py index 5e5209c7d2b398fa6ee6b7e28f954dc397cc8133..b0fd259282caceba9920789bbe3e94fc7259e70c 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,10 @@ setup( python_requires=">=3.10", install_requires=[ "fastapi", - "uvicorn[standard]" + "uvicorn[standard]", + "requests", + "jsonschema", + "sentry_sdk", ], long_description=open("README.md", encoding="utf-8").read(), long_description_content_type="text/markdown", diff --git a/test/conftest.py b/test/conftest.py index 30d1064fc524060647411bb2ac20bb73c6991ddb..4e7c4f211775a3d0ff490a372c3df867dec0fc8d 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,3 +1,8 @@ +import json +import os +import tempfile +from unittest.mock import patch + import pytest from fastapi.testclient import TestClient @@ -5,5 +10,26 @@ from mapping_provider import create_app @pytest.fixture -def client(): - return TestClient(create_app()) +def dummy_config(): + return { + 'sentry': { + 'dsn': 'https://token@hostname.geant.org:1111/a/b', + 'environment': 'unit tests' + }, + 'inventory': 'https://dummy-hostname.dummy.domain' + } + + +@pytest.fixture +def dummy_config_filename(dummy_config): + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(json.dumps(dummy_config).encode('utf-8')) + f.flush() + yield f.name + + +@pytest.fixture +def client(dummy_config_filename): + os.environ['SETTINGS_FILENAME'] = dummy_config_filename + with patch('sentry_sdk.init') as _mock_sentry_init: + return TestClient(create_app()) diff --git a/tox.ini b/tox.ini index 2a820fee2d3f0b473e8ae995c0be5289a8fff683..6cab18c91010822224274a53fba412a9b6931858 100644 --- a/tox.ini +++ b/tox.ini @@ -9,9 +9,9 @@ commands = ruff check mapping_provider test [testenv:typecheck] description = Type-check code with mypy deps = - mypy - types-jsonschema - types-requests + mypy + types-jsonschema + types-requests commands = mypy mapping_provider [testenv:docs]