Skip to content

Commit

Permalink
Resolve admin UI tag to a version via jsdelivr api. (#1306)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathangreen authored Aug 9, 2023
1 parent 78a3116 commit c99b01c
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 2 deletions.
59 changes: 57 additions & 2 deletions api/admin/config.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"

Expand All @@ -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 (
Expand All @@ -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.
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions tests/api/admin/test_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
[
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit c99b01c

Please sign in to comment.