diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index a21c79be8..cee30b908 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -28,6 +28,7 @@ from .resolution import APIResoulution from .security import SecurityMiddleware from .services import APIServices from .snapshots import APISnapshots +from .store import APIStore from .supervisor import APISupervisor _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -80,6 +81,7 @@ class RestAPI(CoreSysAttributes): self._register_services() self._register_snapshots() self._register_supervisor() + self._register_store() await self.start() @@ -339,12 +341,10 @@ class RestAPI(CoreSysAttributes): web.get("/addons", api_addons.list), web.post("/addons/reload", api_addons.reload), web.get("/addons/{addon}/info", api_addons.info), - web.post("/addons/{addon}/install", api_addons.install), web.post("/addons/{addon}/uninstall", api_addons.uninstall), web.post("/addons/{addon}/start", api_addons.start), web.post("/addons/{addon}/stop", api_addons.stop), web.post("/addons/{addon}/restart", api_addons.restart), - web.post("/addons/{addon}/update", api_addons.update), web.post("/addons/{addon}/options", api_addons.options), web.post( "/addons/{addon}/options/validate", api_addons.options_validate @@ -470,6 +470,46 @@ class RestAPI(CoreSysAttributes): ] ) + def _register_store(self) -> None: + """Register store endpoints.""" + api_store = APIStore() + api_store.coresys = self.coresys + + self.webapp.add_routes( + [ + web.get("/store", api_store.store_info), + web.get("/store/addons", api_store.addons_list), + web.get("/store/addons/{addon}", api_store.addons_addon_info), + web.get("/store/addons/{addon}/{version}", api_store.addons_addon_info), + web.post( + "/store/addons/{addon}/install", api_store.addons_addon_install + ), + web.post( + "/store/addons/{addon}/install/{version}", + api_store.addons_addon_install, + ), + web.post("/store/addons/{addon}/update", api_store.addons_addon_update), + web.post( + "/store/addons/{addon}/update/{version}", + api_store.addons_addon_update, + ), + web.post("/store/reload", api_store.reload), + web.get("/store/repositories", api_store.repositories_list), + web.get( + "/store/repositories/{repository}", + api_store.repositories_repository_info, + ), + ] + ) + + # Reroute from legacy + self.webapp.add_routes( + [ + web.post("/addons/{addon}/install", api_store.addons_addon_install), + web.post("/addons/{addon}/update", api_store.addons_addon_update), + ] + ) + def _register_panel(self) -> None: """Register panel for Home Assistant.""" panel_dir = Path(__file__).parent.joinpath("panel") diff --git a/supervisor/api/store.py b/supervisor/api/store.py new file mode 100644 index 000000000..6631df520 --- /dev/null +++ b/supervisor/api/store.py @@ -0,0 +1,156 @@ +"""Init file for Supervisor Home Assistant RESTful API.""" +import asyncio +from typing import Any, Awaitable, Dict, List + +from aiohttp import web + +from ..api.utils import api_process +from ..const import ( + ATTR_ADDONS, + ATTR_ADVANCED, + ATTR_AVAILABLE, + ATTR_BUILD, + ATTR_DESCRIPTON, + ATTR_HOMEASSISTANT, + ATTR_ICON, + ATTR_INSTALLED, + ATTR_LOGO, + ATTR_MAINTAINER, + ATTR_NAME, + ATTR_REPOSITORIES, + ATTR_REPOSITORY, + ATTR_SLUG, + ATTR_SOURCE, + ATTR_STAGE, + ATTR_UPDATE_AVAILABLE, + ATTR_URL, + ATTR_VERSION, + ATTR_VERSION_LATEST, +) +from ..coresys import CoreSysAttributes +from ..exceptions import APIError +from ..store.addon import AddonStore +from ..store.repository import Repository + + +class APIStore(CoreSysAttributes): + """Handle RESTful API for store functions.""" + + def _extract_addon(self, request: web.Request) -> AddonStore: + """Return add-on, throw an exception it it doesn't exist.""" + addon_slug: str = request.match_info.get("addon") + addon_version: str = request.match_info.get("version", "latest") + + addon = self.sys_addons.store.get(addon_slug) + if not addon: + raise APIError( + f"Addon {addon_slug} with version {addon_version} does not exist in the store" + ) + + return addon + + def _extract_repository(self, request: web.Request) -> Repository: + """Return repository, throw an exception it it doesn't exist.""" + repository_slug: str = request.match_info.get("repository") + + repository = self.sys_store.get(repository_slug) + if not repository: + raise APIError(f"Repository {repository_slug} does not exist in the store") + + return repository + + def _generate_addon_information(self, addon: AddonStore) -> Dict[str, Any]: + """Generate addon information.""" + return { + ATTR_ADVANCED: addon.advanced, + ATTR_AVAILABLE: addon.available, + ATTR_BUILD: addon.need_build, + ATTR_DESCRIPTON: addon.description, + ATTR_HOMEASSISTANT: addon.homeassistant_version, + ATTR_ICON: addon.with_icon, + ATTR_INSTALLED: addon.is_installed, + ATTR_LOGO: addon.with_logo, + ATTR_NAME: addon.name, + ATTR_REPOSITORY: addon.repository, + ATTR_SLUG: addon.slug, + ATTR_STAGE: addon.stage, + ATTR_UPDATE_AVAILABLE: addon.need_update if addon.is_installed else False, + ATTR_URL: addon.url, + ATTR_VERSION_LATEST: addon.latest_version, + ATTR_VERSION: addon.version if addon.is_installed else None, + } + + def _generate_repository_information( + self, repository: Repository + ) -> Dict[str, Any]: + """Generate repository information.""" + return { + ATTR_SLUG: repository.slug, + ATTR_NAME: repository.name, + ATTR_SOURCE: repository.source, + ATTR_URL: repository.url, + ATTR_MAINTAINER: repository.maintainer, + } + + @api_process + async def reload(self, request: web.Request) -> None: + """Reload all add-on data from store.""" + await asyncio.shield(self.sys_store.reload()) + + @api_process + async def store_info(self, request: web.Request) -> Dict[str, Any]: + """Return store information.""" + return { + ATTR_ADDONS: [ + self._generate_addon_information(self.sys_addons.store[addon]) + for addon in self.sys_addons.store + ], + ATTR_REPOSITORIES: [ + self._generate_repository_information(repository) + for repository in self.sys_store.all + ], + } + + @api_process + async def addons_list(self, request: web.Request) -> List[Dict[str, Any]]: + """Return all store add-ons.""" + return [ + self._generate_addon_information(self.sys_addons.store[addon]) + for addon in self.sys_addons.store + ] + + @api_process + def addons_addon_install(self, request: web.Request) -> Awaitable[None]: + """Install add-on.""" + addon = self._extract_addon(request) + return asyncio.shield(addon.install()) + + @api_process + def addons_addon_update(self, request: web.Request) -> Awaitable[None]: + """Update add-on.""" + addon = self._extract_addon(request) + if not addon.is_installed: + raise APIError(f"Addon {addon.slug} is not installed") + return asyncio.shield(addon.update()) + + @api_process + async def addons_addon_info(self, request: web.Request) -> Dict[str, Any]: + """Return add-on information.""" + addon: AddonStore = self._extract_addon(request) + return self._generate_addon_information(addon) + + @api_process + async def repositories_list(self, request: web.Request) -> List[Dict[str, Any]]: + """Return all repositories.""" + return [ + self._generate_repository_information(repository) + for repository in self.sys_store.all + ] + + @api_process + async def repositories_repository_info( + self, request: web.Request + ) -> Dict[str, Any]: + """Return repository information.""" + repository: Repository = self._extract_repository(request) + return self._generate_repository_information(repository) diff --git a/supervisor/api/utils.py b/supervisor/api/utils.py index 829daf49e..744c5c7ba 100644 --- a/supervisor/api/utils.py +++ b/supervisor/api/utils.py @@ -62,7 +62,7 @@ def api_process(method): except (APIError, APIForbidden, HassioError) as err: return api_return_error(error=err) - if isinstance(answer, dict): + if isinstance(answer, (dict, list)): return api_return_ok(data=answer) if isinstance(answer, web.Response): return answer diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 835c9f855..fd0f6c2a3 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -20,12 +20,12 @@ if TYPE_CHECKING: from .core import Core from .dbus import DBusManager from .discovery import Discovery + from .hardware.module import HardwareManager from .hassos import HassOS from .homeassistant import HomeAssistant from .host import HostManager from .ingress import Ingress from .jobs import JobManager - from .hardware.module import HardwareManager from .misc.scheduler import Scheduler from .misc.tasks import Tasks from .plugins import PluginManager diff --git a/tests/api/test_store.py b/tests/api/test_store.py new file mode 100644 index 000000000..bbcd72bb3 --- /dev/null +++ b/tests/api/test_store.py @@ -0,0 +1,67 @@ +"""Test Store API.""" +from aiohttp.test_utils import TestClient +import pytest + +from supervisor.store.addon import AddonStore +from supervisor.store.repository import Repository + + +@pytest.mark.asyncio +async def test_api_store( + api_client: TestClient, store_addon: AddonStore, repository: Repository +): + """Test /store REST API.""" + resp = await api_client.get("/store") + result = await resp.json() + + assert result["data"]["addons"][0]["slug"] == store_addon.slug + assert result["data"]["repositories"][0]["slug"] == repository.slug + + +@pytest.mark.asyncio +async def test_api_store_addons(api_client: TestClient, store_addon: AddonStore): + """Test /store/addons REST API.""" + print("test") + resp = await api_client.get("/store/addons") + result = await resp.json() + print(result) + + assert result["data"][0]["slug"] == store_addon.slug + + +@pytest.mark.asyncio +async def test_api_store_addons_addon(api_client: TestClient, store_addon: AddonStore): + """Test /store/addons/{addon} REST API.""" + resp = await api_client.get(f"/store/addons/{store_addon.slug}") + result = await resp.json() + assert result["data"]["slug"] == store_addon.slug + + +@pytest.mark.asyncio +async def test_api_store_addons_addon_version( + api_client: TestClient, store_addon: AddonStore +): + """Test /store/addons/{addon}/{version} REST API.""" + resp = await api_client.get(f"/store/addons/{store_addon.slug}/1.0.0") + result = await resp.json() + assert result["data"]["slug"] == store_addon.slug + + +@pytest.mark.asyncio +async def test_api_store_repositories(api_client: TestClient, repository: Repository): + """Test /store/repositories REST API.""" + resp = await api_client.get("/store/repositories") + result = await resp.json() + + assert result["data"][0]["slug"] == repository.slug + + +@pytest.mark.asyncio +async def test_api_store_repositories_repository( + api_client: TestClient, repository: Repository +): + """Test /store/repositories/{repository} REST API.""" + resp = await api_client.get(f"/store/repositories/{repository.slug}") + result = await resp.json() + + assert result["data"]["slug"] == repository.slug diff --git a/tests/conftest.py b/tests/conftest.py index aca46385f..2296b4134 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,8 @@ from supervisor.bootstrap import initialize_coresys from supervisor.coresys import CoreSys from supervisor.dbus.network import NetworkManager from supervisor.docker import DockerAPI +from supervisor.store.addon import AddonStore +from supervisor.store.repository import Repository from supervisor.utils.gdbus import DBus from tests.common import exists_fixture, load_fixture, load_json_fixture @@ -206,3 +208,25 @@ def run_dir(tmp_path): tmp_state = Path(tmp_path, "supervisor") mock_run.write_text = tmp_state.write_text yield tmp_state + + +@pytest.fixture +def store_addon(coresys: CoreSys, tmp_path): + """Store add-on fixture.""" + addon_obj = AddonStore(coresys, "test_store_addon") + + coresys.addons.store[addon_obj.slug] = addon_obj + coresys.store.data.addons[addon_obj.slug] = load_json_fixture("add-on.json") + yield addon_obj + + +@pytest.fixture +def repository(coresys: CoreSys): + """Repository fixture.""" + repository_obj = Repository( + coresys, "https://github.com/awesome-developer/awesome-repo" + ) + + coresys.store.repositories[repository_obj.slug] = repository_obj + + yield repository_obj diff --git a/tests/fixtures/add-on.json b/tests/fixtures/add-on.json new file mode 100644 index 000000000..d98812089 --- /dev/null +++ b/tests/fixtures/add-on.json @@ -0,0 +1,10 @@ +{ + "advanced": false, + "arch": ["amd64"], + "description": "Test add-on", + "location": "tmp_path", + "name": "Test Add-on", + "repository": "https://github.com/awesome-developer/awesome-repo", + "stage": "stable", + "version": "1.0.0" +} \ No newline at end of file