feat: load http views dynamically (#113)

This commit is contained in:
2023-09-30 01:24:04 +03:00
committed by GitHub
parent bc9682373d
commit 9fe760efdf
45 changed files with 352 additions and 74 deletions

View File

@ -92,6 +92,7 @@ class Migrations(LazyLogging):
list[Migration]: list of found migrations
"""
migrations: list[Migration] = []
package_dir = Path(__file__).resolve().parent
modules = [module_name for (_, module_name, _) in iter_modules([str(package_dir)])]

View File

@ -17,18 +17,90 @@
# 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 Application
from aiohttp.web import Application, View
from collections.abc import Generator
from importlib.machinery import SourceFileLoader
from pathlib import Path
from pkgutil import ModuleInfo, iter_modules
from types import ModuleType
from typing import Any, Type, TypeGuard
from ahriman.web.views.api.docs import DocsView
from ahriman.web.views.api.swagger import SwaggerView
from ahriman.web.views.index import IndexView
from ahriman.web.views import v1, v2
from ahriman.web.views.base import BaseView
__all__ = ["setup_routes"]
def _dynamic_routes(module_root: Path) -> dict[str, Type[View]]:
"""
extract dynamic routes based on views
Args:
module_root(Path): root module path with views
Returns:
dict[str, Type[View]]: map of the route to its view
"""
def is_base_view(clz: Any) -> TypeGuard[Type[BaseView]]:
return isinstance(clz, type) and issubclass(clz, BaseView)
routes: dict[str, Type[View]] = {}
for module_info in _modules(module_root):
module = _module(module_info)
for attribute_name in dir(module):
view = getattr(module, attribute_name)
if not is_base_view(view):
continue
routes.update([(route, view) for route in view.ROUTES])
return routes
def _module(module_info: ModuleInfo) -> ModuleType:
"""
load module from its info
Args:
module_info(ModuleInfo): module info descriptor
Returns:
ModuleType: loaded module
Raises:
ValueError: if loader is not an instance of ``SourceFileLoader``
"""
module_spec = module_info.module_finder.find_spec(module_info.name, None)
if module_spec is None:
raise ValueError(f"Module specification of {module_info.name} is empty")
loader = module_spec.loader
if not isinstance(loader, SourceFileLoader):
raise ValueError(f"Module {module_info.name} loader is not an instance of SourceFileLoader")
module = ModuleType(loader.name)
loader.exec_module(module)
return module
def _modules(module_root: Path) -> Generator[ModuleInfo, None, None]:
"""
extract available modules from package
Args:
module_root(Path): module root path
Yields:
ModuleInfo: module information each available module
"""
for module_info in iter_modules([str(module_root)]):
if module_info.ispkg:
yield from _modules(module_root / module_info.name)
else:
yield module_info
def setup_routes(application: Application, static_path: Path) -> None:
"""
setup all defined routes
@ -37,30 +109,8 @@ def setup_routes(application: Application, static_path: Path) -> None:
application(Application): web application instance
static_path(Path): path to static files directory
"""
application.router.add_view("/", IndexView)
application.router.add_view("/index.html", IndexView)
application.router.add_view("/api-docs", DocsView)
application.router.add_view("/api-docs/swagger.json", SwaggerView)
application.router.add_static("/static", static_path, follow_symlinks=True)
application.router.add_view("/api/v1/service/add", v1.AddView)
application.router.add_view("/api/v1/service/pgp", v1.PGPView)
application.router.add_view("/api/v1/service/rebuild", v1.RebuildView)
application.router.add_view("/api/v1/service/process/{process_id}", v1.ProcessView)
application.router.add_view("/api/v1/service/remove", v1.RemoveView)
application.router.add_view("/api/v1/service/request", v1.RequestView)
application.router.add_view("/api/v1/service/search", v1.SearchView)
application.router.add_view("/api/v1/service/update", v1.UpdateView)
application.router.add_view("/api/v1/service/upload", v1.UploadView)
application.router.add_view("/api/v1/packages", v1.PackagesView)
application.router.add_view("/api/v1/packages/{package}", v1.PackageView)
application.router.add_view("/api/v1/packages/{package}/logs", v1.LogsView)
application.router.add_view("/api/v2/packages/{package}/logs", v2.LogsView)
application.router.add_view("/api/v1/status", v1.StatusView)
application.router.add_view("/api/v1/login", v1.LoginView)
application.router.add_view("/api/v1/logout", v1.LogoutView)
views = Path(__file__).parent / "views"
for route, view in _dynamic_routes(views).items():
application.router.add_view(route, view)

View File

@ -34,6 +34,7 @@ class DocsView(BaseView):
"""
GET_PERMISSION = UserAccess.Unauthorized
ROUTES = ["/api-docs"]
@aiohttp_jinja2.template("api.jinja2")
async def get(self) -> dict[str, Any]:

View File

@ -34,6 +34,7 @@ class SwaggerView(BaseView):
"""
GET_PERMISSION = UserAccess.Unauthorized
ROUTES = ["/api-docs/swagger.json"]
async def get(self) -> Response:
"""

View File

@ -38,9 +38,11 @@ class BaseView(View, CorsViewMixin):
Attributes:
OPTIONS_PERMISSION(UserAccess): (class attribute) options permissions of self
ROUTES(list[str]): (class attribute) list of supported routes
"""
OPTIONS_PERMISSION = UserAccess.Unauthorized
ROUTES: list[str] = []
@property
def configuration(self) -> Configuration:

View File

@ -44,6 +44,7 @@ class IndexView(BaseView):
"""
GET_PERMISSION = UserAccess.Unauthorized
ROUTES = ["/", "/index.html"]
@aiohttp_jinja2.template("build-status.jinja2")
async def get(self) -> dict[str, Any]:

View File

@ -17,20 +17,3 @@
# 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 ahriman.web.views.v1.service.add import AddView
from ahriman.web.views.v1.service.pgp import PGPView
from ahriman.web.views.v1.service.process import ProcessView
from ahriman.web.views.v1.service.rebuild import RebuildView
from ahriman.web.views.v1.service.remove import RemoveView
from ahriman.web.views.v1.service.request import RequestView
from ahriman.web.views.v1.service.search import SearchView
from ahriman.web.views.v1.service.update import UpdateView
from ahriman.web.views.v1.service.upload import UploadView
from ahriman.web.views.v1.status.logs import LogsView
from ahriman.web.views.v1.status.package import PackageView
from ahriman.web.views.v1.status.packages import PackagesView
from ahriman.web.views.v1.status.status import StatusView
from ahriman.web.views.v1.user.login import LoginView
from ahriman.web.views.v1.user.logout import LogoutView

View File

@ -35,6 +35,7 @@ class AddView(BaseView):
"""
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/service/add"]
@aiohttp_apispec.docs(
tags=["Actions"],

View File

@ -35,8 +35,9 @@ class PGPView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Full
GET_PERMISSION = UserAccess.Reporter
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/service/pgp"]
@aiohttp_apispec.docs(
tags=["Actions"],

View File

@ -35,6 +35,7 @@ class ProcessView(BaseView):
"""
GET_PERMISSION = UserAccess.Reporter
ROUTES = ["/api/v1/service/process/{process_id}"]
@aiohttp_apispec.docs(
tags=["Actions"],

View File

@ -35,6 +35,7 @@ class RebuildView(BaseView):
"""
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/service/rebuild"]
@aiohttp_apispec.docs(
tags=["Actions"],

View File

@ -35,6 +35,7 @@ class RemoveView(BaseView):
"""
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/service/remove"]
@aiohttp_apispec.docs(
tags=["Actions"],

View File

@ -35,6 +35,7 @@ class RequestView(BaseView):
"""
POST_PERMISSION = UserAccess.Reporter
ROUTES = ["/api/v1/service/request"]
@aiohttp_apispec.docs(
tags=["Actions"],

View File

@ -38,6 +38,7 @@ class SearchView(BaseView):
"""
GET_PERMISSION = UserAccess.Reporter
ROUTES = ["/api/v1/service/search"]
@aiohttp_apispec.docs(
tags=["Actions"],

View File

@ -35,6 +35,7 @@ class UpdateView(BaseView):
"""
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/service/update"]
@aiohttp_apispec.docs(
tags=["Actions"],

View File

@ -39,6 +39,7 @@ class UploadView(BaseView):
"""
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/service/upload"]
@staticmethod
async def save_file(part: BodyPartReader, target: Path, *, max_body_size: int | None = None) -> tuple[str, Path]:

View File

@ -41,6 +41,7 @@ class LogsView(BaseView):
DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full
GET_PERMISSION = UserAccess.Reporter
ROUTES = ["/api/v1/packages/{package}/logs"]
@aiohttp_apispec.docs(
tags=["Packages"],

View File

@ -41,6 +41,7 @@ class PackageView(BaseView):
DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full
GET_PERMISSION = UserAccess.Read
ROUTES = ["/api/v1/packages/{package}"]
@aiohttp_apispec.docs(
tags=["Packages"],

View File

@ -41,6 +41,7 @@ class PackagesView(BaseView):
GET_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/packages"]
@aiohttp_apispec.docs(
tags=["Packages"],

View File

@ -41,6 +41,7 @@ class StatusView(BaseView):
GET_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/status"]
@aiohttp_apispec.docs(
tags=["Status"],

View File

@ -37,6 +37,7 @@ class LoginView(BaseView):
"""
GET_PERMISSION = POST_PERMISSION = UserAccess.Unauthorized
ROUTES = ["/api/v1/login"]
@aiohttp_apispec.docs(
tags=["Login"],

View File

@ -36,6 +36,7 @@ class LogoutView(BaseView):
"""
POST_PERMISSION = UserAccess.Unauthorized
ROUTES = ["/api/v1/logout"]
@aiohttp_apispec.docs(
tags=["Login"],

View File

@ -17,4 +17,3 @@
# 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 ahriman.web.views.v2.status.logs import LogsView

View File

@ -37,6 +37,7 @@ class LogsView(BaseView):
"""
GET_PERMISSION = UserAccess.Reporter
ROUTES = ["/api/v2/packages/{package}/logs"]
@aiohttp_apispec.docs(
tags=["Packages"],