From c99b01cc54cf52b3a4c7197599fbb2ab7226c9b7 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Wed, 9 Aug 2023 18:27:46 -0300 Subject: [PATCH] Resolve admin UI tag to a version via jsdelivr api. (#1306) --- api/admin/config.py | 59 +++++++++++++++++++++++++++- pyproject.toml | 1 + tests/api/admin/test_config.py | 70 ++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/api/admin/config.py b/api/admin/config.py index cf708d708e..bf64393caf 100644 --- a/api/admin/config.py +++ b/api/admin/config.py @@ -1,8 +1,13 @@ +import logging import os from enum import Enum from typing import Optional from urllib.parse import urljoin +from requests import RequestException + +from core.util.http import HTTP, RequestNetworkException + class OperationalMode(str, Enum): production = "production" @@ -34,6 +39,10 @@ class Configuration: }, } + METADATA_URL_TEMPLATE = ( + "https://data.jsdelivr.com/v1/packages/npm/{name}/resolved?specifier={version}" + ) + DEVELOPMENT_MODE_PACKAGE_TEMPLATE = "node_modules/{name}" STATIC_ASSETS_REL_PATH = "dist" @@ -43,6 +52,9 @@ class Configuration: ENV_ADMIN_UI_PACKAGE_NAME = "TPP_CIRCULATION_ADMIN_PACKAGE_NAME" ENV_ADMIN_UI_PACKAGE_VERSION = "TPP_CIRCULATION_ADMIN_PACKAGE_VERSION" + # Cache the package version after first lookup. + _version: Optional[str] = None + @classmethod def operational_mode(cls) -> OperationalMode: return ( @@ -51,6 +63,10 @@ def operational_mode(cls) -> OperationalMode: else OperationalMode.production ) + @classmethod + def logger(cls) -> logging.Logger: + return logging.getLogger(f"{cls.__module__}.{cls.__name__}") + @classmethod def package_name(cls) -> str: """Get the effective package name. @@ -60,13 +76,52 @@ def package_name(cls) -> str: """ return os.environ.get(cls.ENV_ADMIN_UI_PACKAGE_NAME) or cls.PACKAGE_NAME + @classmethod + def resolve_package_version(cls, package_name: str, package_version: str) -> str: + """Resolve a package version to a specific version, if necessary. For + example, if the version is a tag or partial semver. This is done by + querying the jsdelivr API.""" + url = cls.METADATA_URL_TEMPLATE.format( + name=package_name, version=package_version + ) + try: + response = HTTP.get_with_timeout(url) + if response.status_code == 200 and "version" in response.json(): + return str(response.json()["version"]) + except (RequestNetworkException, RequestException): + cls.logger().exception("Failed to resolve package version.") + # If the request fails, just return the version as-is. + ... + + return package_version + + @classmethod + def env_package_version(cls) -> Optional[str]: + """Get the package version specified in configuration or environment. + + :return Package verison. + """ + if cls.ENV_ADMIN_UI_PACKAGE_VERSION not in os.environ: + return None + + version = os.environ[cls.ENV_ADMIN_UI_PACKAGE_VERSION] + if version in ["latest", "next", "dev"]: + version = cls.resolve_package_version(cls.package_name(), version) + + return version + @classmethod def package_version(cls) -> str: - """Get the effective package version. + """Get the effective package version, resolved to a specific version, + if necessary. For example, if the version is a tag or partial semver. + This is done by querying the jsdelivr API. :return Package verison. """ - return os.environ.get(cls.ENV_ADMIN_UI_PACKAGE_VERSION) or cls.PACKAGE_VERSION + if cls._version is None: + cls._version = cls.env_package_version() or cls.PACKAGE_VERSION + + return cls._version @classmethod def lookup_asset_url( diff --git a/pyproject.toml b/pyproject.toml index 6e4c991d25..656c629a6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ disallow_untyped_decorators = true disallow_untyped_defs = true module = [ "api.admin.announcement_list_validator", + "api.admin.config", "api.admin.controller.library_settings", "api.admin.controller.patron_auth_services", "api.admin.form_data", diff --git a/tests/api/admin/test_config.py b/tests/api/admin/test_config.py index 0d28fe1cc8..ec3b933950 100644 --- a/tests/api/admin/test_config.py +++ b/tests/api/admin/test_config.py @@ -1,7 +1,10 @@ +import logging import os from typing import Optional +from unittest.mock import MagicMock, patch import pytest +from requests import RequestException from api.admin.config import Configuration as AdminConfig from api.admin.config import OperationalMode @@ -15,6 +18,71 @@ def _set_env(monkeypatch, key: str, value: Optional[str]): elif key in os.environ: monkeypatch.delenv(key) + def test_package_version_cached(self, monkeypatch): + with patch( + "api.admin.config.Configuration.env_package_version" + ) as env_package_version: + env_package_version.return_value = None + + # The first call to package_version() should call env_package_version() + assert AdminConfig.package_version() == AdminConfig.PACKAGE_VERSION + assert env_package_version.call_count == 1 + env_package_version.reset_mock() + + # The second call to package_version() should not call env_package_version() + # because the result is cached. + assert AdminConfig.package_version() == AdminConfig.PACKAGE_VERSION + assert env_package_version.call_count == 0 + + @pytest.mark.parametrize( + "package_version, resolves, expected_result", + [ + ["1.0.0", False, "1.0.0"], + ["latest", True, "x.x.x"], + ["next", True, "x.x.x"], + ["dev", True, "x.x.x"], + [None, False, None], + ], + ) + def test_env_package_version( + self, + monkeypatch, + package_version: Optional[str], + resolves: bool, + expected_result: Optional[str], + ): + with patch( + "api.admin.config.Configuration.resolve_package_version" + ) as resolve_package_version: + resolve_package_version.return_value = "x.x.x" + self._set_env( + monkeypatch, "TPP_CIRCULATION_ADMIN_PACKAGE_VERSION", package_version + ) + assert AdminConfig.env_package_version() == expected_result + assert resolve_package_version.call_count == (1 if resolves else 0) + + def test_resolve_package_version(self, caplog): + with patch("api.admin.config.HTTP") as http_patch: + http_patch.get_with_timeout.return_value = MagicMock( + status_code=200, json=MagicMock(return_value={"version": "1.0.0"}) + ) + assert ( + AdminConfig.resolve_package_version("some-package", "latest") == "1.0.0" + ) + http_patch.get_with_timeout.assert_called_once_with( + "https://data.jsdelivr.com/v1/packages/npm/some-package/resolved?specifier=latest" + ) + + # If there is an exception while trying to resolve the package version, return the default. + caplog.set_level(logging.ERROR) + http_patch.get_with_timeout.side_effect = RequestException() + assert ( + AdminConfig.resolve_package_version("some-package", "latest") + == "latest" + ) + assert len(caplog.records) == 1 + assert "Failed to resolve package version" in caplog.text + @pytest.mark.parametrize( "package_name, package_version, mode, expected_result_startswith", [ @@ -55,6 +123,8 @@ def test_package_url( ) result = AdminConfig.package_url(_operational_mode=mode) assert result.startswith(expected_result_startswith) + # Reset the cached version + AdminConfig._version = None @pytest.mark.parametrize( "package_name, package_version, expected_result",