provide service api endpoints

This commit is contained in:
2021-09-08 20:00:41 +03:00
parent 16587306dd
commit 60246dd833
29 changed files with 483 additions and 51 deletions

View File

@ -19,13 +19,17 @@
# #
from aiohttp.web import Application from aiohttp.web import Application
from ahriman.web.views.ahriman import AhrimanView
from ahriman.web.views.index import IndexView from ahriman.web.views.index import IndexView
from ahriman.web.views.login import LoginView from ahriman.web.views.service.add import AddView
from ahriman.web.views.logout import LogoutView from ahriman.web.views.service.remove import RemoveView
from ahriman.web.views.package import PackageView from ahriman.web.views.service.search import SearchView
from ahriman.web.views.packages import PackagesView from ahriman.web.views.service.update import UpdateView
from ahriman.web.views.status import StatusView from ahriman.web.views.status.ahriman import AhrimanView
from ahriman.web.views.status.package import PackageView
from ahriman.web.views.status.packages import PackagesView
from ahriman.web.views.status.status import StatusView
from ahriman.web.views.user.login import LoginView
from ahriman.web.views.user.logout import LogoutView
def setup_routes(application: Application) -> None: def setup_routes(application: Application) -> None:
@ -37,8 +41,13 @@ def setup_routes(application: Application) -> None:
GET / get build status page GET / get build status page
GET /index.html same as above GET /index.html same as above
POST /user-api/v1/login login to service POST /service-api/v1/add add new packages to repository
POST /user-api/v1/logout logout from service
POST /service-api/v1/remove remove existing package from repository
GET /service-api/v1/search search for substring in AUR
POST /service-api/v1/update update existing package in repository
GET /status-api/v1/ahriman get current service status GET /status-api/v1/ahriman get current service status
POST /status-api/v1/ahriman update service status POST /status-api/v1/ahriman update service status
@ -52,13 +61,21 @@ def setup_routes(application: Application) -> None:
GET /status-api/v1/status get web service status itself GET /status-api/v1/status get web service status itself
POST /user-api/v1/login login to service
POST /user-api/v1/logout logout from service
:param application: web application instance :param application: web application instance
""" """
application.router.add_get("/", IndexView, allow_head=True) application.router.add_get("/", IndexView, allow_head=True)
application.router.add_get("/index.html", IndexView, allow_head=True) application.router.add_get("/index.html", IndexView, allow_head=True)
application.router.add_post("/user-api/v1/login", LoginView) application.router.add_post("/service-api/v1/add", AddView)
application.router.add_post("/user-api/v1/logout", LogoutView)
application.router.add_post("/service-api/v1/remove", RemoveView)
application.router.add_get("/service-api/v1/search", SearchView, allow_head=False)
application.router.add_post("/service-api/v1/update", UpdateView)
application.router.add_get("/status-api/v1/ahriman", AhrimanView, allow_head=True) application.router.add_get("/status-api/v1/ahriman", AhrimanView, allow_head=True)
application.router.add_post("/status-api/v1/ahriman", AhrimanView) application.router.add_post("/status-api/v1/ahriman", AhrimanView)
@ -71,3 +88,6 @@ def setup_routes(application: Application) -> None:
application.router.add_post("/status-api/v1/packages/{package}", PackageView) application.router.add_post("/status-api/v1/packages/{package}", PackageView)
application.router.add_get("/status-api/v1/status", StatusView, allow_head=True) application.router.add_get("/status-api/v1/status", StatusView, allow_head=True)
application.router.add_post("/user-api/v1/login", LoginView)
application.router.add_post("/user-api/v1/logout", LogoutView)

View File

@ -18,7 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import View from aiohttp.web import View
from typing import Any, Dict from typing import Any, Dict, List, Optional
from ahriman.core.auth.auth import Auth from ahriman.core.auth.auth import Auth
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
@ -54,20 +54,22 @@ class BaseView(View):
validator: Auth = self.request.app["validator"] validator: Auth = self.request.app["validator"]
return validator return validator
async def extract_data(self) -> Dict[str, Any]: async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]:
""" """
extract json data from either json or form data extract json data from either json or form data
:param list_keys: optional list of keys which must be forced to list from form data
:return: raw json object or form data converted to json :return: raw json object or form data converted to json
""" """
try: try:
json: Dict[str, Any] = await self.request.json() json: Dict[str, Any] = await self.request.json()
return json return json
except ValueError: except ValueError:
return await self.data_as_json() return await self.data_as_json(list_keys or [])
async def data_as_json(self) -> Dict[str, Any]: async def data_as_json(self, list_keys: List[str]) -> Dict[str, Any]:
""" """
extract form data and convert it to json object extract form data and convert it to json object
:param list_keys: list of keys which must be forced to list from form data
:return: form data converted to json. In case if a key is found multiple times it will be returned as list :return: form data converted to json. In case if a key is found multiple times it will be returned as list
""" """
raw = await self.request.post() raw = await self.request.post()
@ -77,6 +79,8 @@ class BaseView(View):
json[key].append(value) json[key].append(value)
elif key in json: elif key in json:
json[key] = [json[key], value] json[key] = [json[key], value]
elif key in list_keys:
json[key] = [value]
else: else:
json[key] = value json[key] = value
return json return json

View File

@ -0,0 +1,19 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

View File

@ -0,0 +1,52 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPFound, Response, json_response
from ahriman.web.views.base import BaseView
class AddView(BaseView):
"""
add package web view
"""
async def post(self) -> Response:
"""
add new package
JSON body must be supplied, the following model is used:
{
"packages": "ahriman", # either list of packages or package name as in AUR
"build_now": true # optional flag which runs build
}
:return: redirect to main page on success
"""
data = await self.extract_data(["packages"])
try:
now = data.get("build_now") or False
packages = data["packages"]
except Exception as e:
return json_response(text=str(e), status=400)
self.spawner.packages_add(packages, now)
return HTTPFound("/")

View File

@ -0,0 +1,50 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPFound, Response, json_response
from ahriman.web.views.base import BaseView
class RemoveView(BaseView):
"""
remove package web view
"""
async def post(self) -> Response:
"""
remove existing packages
JSON body must be supplied, the following model is used:
{
"packages": "ahriman", # either list of packages or package name
}
:return: redirect to main page on success
"""
data = await self.extract_data(["packages"])
try:
packages = data["packages"]
except Exception as e:
return json_response(text=str(e), status=400)
self.spawner.packages_remove(packages)
return HTTPFound("/")

View File

@ -0,0 +1,48 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import aur # type: ignore
from aiohttp.web import Response, json_response
from typing import Iterator
from ahriman.web.views.base import BaseView
class SearchView(BaseView):
"""
AUR search web view
"""
async def get(self) -> Response:
"""
search packages in AUR
search string (non empty) must be supplied as `for` parameter
:return: 200 with found package bases sorted by name
"""
search: Iterator[str] = filter(lambda s: len(s) > 3, self.request.query.getall("for", default=[]))
search_string = " ".join(search)
if not search_string:
return json_response(text="Search string must not be empty", status=400)
packages = aur.search(search_string)
return json_response(sorted(package.package_base for package in packages))

View File

@ -0,0 +1,50 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPFound, Response, json_response
from ahriman.web.views.base import BaseView
class UpdateView(BaseView):
"""
update package web view
"""
async def post(self) -> Response:
"""
update existing packages
JSON body must be supplied, the following model is used:
{
"packages": "ahriman", # either list of packages or package name
}
:return: redirect to main page on success
"""
data = await self.extract_data(["packages"])
try:
packages = data["packages"]
except Exception as e:
return json_response(text=str(e), status=400)
self.spawner.packages_update(packages)
return HTTPFound("/")

View File

@ -0,0 +1,19 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPNoContent, Response, json_response
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -51,7 +51,7 @@ class AhrimanView(BaseView):
try: try:
status = BuildStatusEnum(data["status"]) status = BuildStatusEnum(data["status"])
except Exception as e: except Exception as e:
raise HTTPBadRequest(text=str(e)) return json_response(text=str(e), status=400)
self.service.update_self(status) self.service.update_self(status)

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.core.exceptions import UnknownPackage from ahriman.core.exceptions import UnknownPackage
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
@ -80,11 +80,11 @@ class PackageView(BaseView):
package = Package.from_json(data["package"]) if "package" in data else None package = Package.from_json(data["package"]) if "package" in data else None
status = BuildStatusEnum(data["status"]) status = BuildStatusEnum(data["status"])
except Exception as e: except Exception as e:
raise HTTPBadRequest(text=str(e)) return json_response(text=str(e), status=400)
try: try:
self.service.update(base, status, package) self.service.update(base, status, package)
except UnknownPackage: except UnknownPackage:
raise HTTPBadRequest(text=f"Package {base} is unknown, but no package body set") return json_response(text=f"Package {base} is unknown, but no package body set", status=400)
return HTTPNoContent() return HTTPNoContent()

View File

@ -0,0 +1,19 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

View File

@ -1,5 +1,4 @@
import argparse import argparse
import aur
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@ -8,7 +7,6 @@ from ahriman.application.ahriman import _parser
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.lock import Lock from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.package import Package
@pytest.fixture @pytest.fixture
@ -32,31 +30,6 @@ def args() -> argparse.Namespace:
return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True) return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True)
@pytest.fixture
def aur_package_ahriman(package_ahriman: Package) -> aur.Package:
"""
fixture for AUR package
:param package_ahriman: package fixture
:return: AUR package test instance
"""
return aur.Package(
num_votes=None,
description=package_ahriman.packages[package_ahriman.base].description,
url_path=package_ahriman.web_url,
last_modified=None,
name=package_ahriman.base,
out_of_date=None,
id=None,
first_submitted=None,
maintainer=None,
version=package_ahriman.version,
license=package_ahriman.packages[package_ahriman.base].licenses,
url=None,
package_base=package_ahriman.base,
package_base_id=None,
category_id=None)
@pytest.fixture @pytest.fixture
def lock(args: argparse.Namespace, configuration: Configuration) -> Lock: def lock(args: argparse.Namespace, configuration: Configuration) -> Lock:
""" """

View File

@ -1,3 +1,4 @@
import aur
import pytest import pytest
from pathlib import Path from pathlib import Path
@ -46,6 +47,31 @@ def anyvar(cls: Type[T], strict: bool = False) -> T:
# generic fixtures # generic fixtures
@pytest.fixture
def aur_package_ahriman(package_ahriman: Package) -> aur.Package:
"""
fixture for AUR package
:param package_ahriman: package fixture
:return: AUR package test instance
"""
return aur.Package(
num_votes=None,
description=package_ahriman.packages[package_ahriman.base].description,
url_path=package_ahriman.web_url,
last_modified=None,
name=package_ahriman.base,
out_of_date=None,
id=None,
first_submitted=None,
maintainer=None,
version=package_ahriman.version,
license=package_ahriman.packages[package_ahriman.base].licenses,
url=None,
package_base=package_ahriman.base,
package_base_id=None,
category_id=None)
@pytest.fixture @pytest.fixture
def auth(configuration: Configuration) -> Auth: def auth(configuration: Configuration) -> Auth:
""" """

View File

@ -0,0 +1,35 @@
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"]})
assert response.status == 200
add_mock.assert_called_with(["ahriman"], False)
async def test_post_now(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post and run build
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"], "build_now": True})
assert response.status == 200
add_mock.assert_called_with(["ahriman"], True)
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise exception on missing packages payload
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/add")
assert response.status == 400
add_mock.assert_not_called()

View File

@ -0,0 +1,24 @@
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove")
response = await client.post("/service-api/v1/remove", json={"packages": ["ahriman"]})
assert response.status == 200
add_mock.assert_called_with(["ahriman"])
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise exception on missing packages payload
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove")
response = await client.post("/service-api/v1/remove")
assert response.status == 400
add_mock.assert_not_called()

View File

@ -0,0 +1,59 @@
import aur
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
async def test_get(client: TestClient, aur_package_ahriman: aur.Package, mocker: MockerFixture) -> None:
"""
must call get request correctly
"""
mocker.patch("aur.search", return_value=[aur_package_ahriman])
response = await client.get("/service-api/v1/search", params={"for": "ahriman"})
assert response.status == 200
assert await response.json() == ["ahriman"]
async def test_get_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise 400 on empty search string
"""
search_mock = mocker.patch("aur.search")
response = await client.get("/service-api/v1/search")
assert response.status == 400
search_mock.assert_not_called()
async def test_get_join(client: TestClient, mocker: MockerFixture) -> None:
"""
must join search args with space
"""
search_mock = mocker.patch("aur.search")
response = await client.get("/service-api/v1/search", params=[("for", "ahriman"), ("for", "maybe")])
assert response.status == 200
search_mock.assert_called_with("ahriman maybe")
async def test_get_join_filter(client: TestClient, mocker: MockerFixture) -> None:
"""
must filter search parameters with less than 3 symbols
"""
search_mock = mocker.patch("aur.search")
response = await client.get("/service-api/v1/search", params=[("for", "ah"), ("for", "maybe")])
assert response.status == 200
search_mock.assert_called_with("maybe")
async def test_get_join_filter_empty(client: TestClient, mocker: MockerFixture) -> None:
"""
must filter search parameters with less than 3 symbols (empty result)
"""
search_mock = mocker.patch("aur.search")
response = await client.get("/service-api/v1/search", params=[("for", "ah"), ("for", "ma")])
assert response.status == 400
search_mock.assert_not_called()

View File

@ -0,0 +1,24 @@
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_update")
response = await client.post("/service-api/v1/update", json={"packages": ["ahriman"]})
assert response.status == 200
add_mock.assert_called_with(["ahriman"])
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise exception on missing packages payload
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_update")
response = await client.post("/service-api/v1/update")
assert response.status == 400
add_mock.assert_not_called()

View File

@ -61,9 +61,6 @@ async def test_data_as_json(base: BaseView) -> None:
""" """
json = {"key1": "value1", "key2": ["value2", "value3"], "key3": ["value4", "value5", "value6"]} json = {"key1": "value1", "key2": ["value2", "value3"], "key3": ["value4", "value5", "value6"]}
async def get_json():
raise ValueError()
async def get_data(): async def get_data():
result = MultiDict() result = MultiDict()
for key, values in json.items(): for key, values in json.items():
@ -74,5 +71,18 @@ async def test_data_as_json(base: BaseView) -> None:
result.add(key, values) result.add(key, values)
return result return result
base._request = pytest.helpers.request(base.request.app, "", "", json=get_json, data=get_data) base._request = pytest.helpers.request(base.request.app, "", "", data=get_data)
assert await base.data_as_json() == json assert await base.data_as_json([]) == json
async def test_data_as_json_with_list_keys(base: BaseView) -> None:
"""
must parse multi value form payload with forced list
"""
json = {"key1": "value1"}
async def get_data():
return json
base._request = pytest.helpers.request(base.request.app, "", "", data=get_data)
assert await base.data_as_json(["key1"]) == {"key1": ["value1"]}