diff --git a/example/api/src/main.py b/example/api/src/main.py index 4732035b..73356f71 100644 --- a/example/api/src/main.py +++ b/example/api/src/main.py @@ -3,7 +3,7 @@ from starlette.responses import JSONResponse from cpl.api.api_module import ApiModule from cpl.api.application.web_app import WebApp from cpl.application.application_builder import ApplicationBuilder -from cpl.auth import AuthModule +from cpl.graphql.application.graphql_app import GraphQLApp from cpl.auth.permission.permissions import Permissions from cpl.auth.schema import AuthUser, Role from cpl.core.configuration import Configuration @@ -11,12 +11,13 @@ from cpl.core.console import Console from cpl.core.environment import Environment from cpl.core.utils.cache import Cache from cpl.database.mysql.mysql_module import MySQLModule +from cpl.graphql.graphql_module import GraphQLModule from scoped_service import ScopedService from service import PingService def main(): - builder = ApplicationBuilder[WebApp](WebApp) + builder = ApplicationBuilder[GraphQLApp](GraphQLApp) Configuration.add_json_file(f"appsettings.json") Configuration.add_json_file(f"appsettings.{Environment.get_environment()}.json") @@ -27,6 +28,7 @@ def main(): builder.services.add_transient(PingService) builder.services.add_module(MySQLModule) builder.services.add_module(ApiModule) + builder.services.add_module(GraphQLModule) builder.services.add_scoped(ScopedService) @@ -47,6 +49,9 @@ def main(): permissions=[Permissions.administrator], ) app.with_routes_directory("routes") + app.with_graphql() + app.with_playground() + app.with_graphiql() provider = builder.service_provider user_cache = provider.get_service(Cache[AuthUser]) diff --git a/src/cpl-api/cpl/api/abc/web_app_abc.py b/src/cpl-api/cpl/api/abc/web_app_abc.py new file mode 100644 index 00000000..7092231b --- /dev/null +++ b/src/cpl-api/cpl/api/abc/web_app_abc.py @@ -0,0 +1,45 @@ +from abc import ABC +from enum import Enum +from typing import Self + +from starlette.applications import Starlette + +from cpl.api.model.api_route import ApiRoute +from cpl.api.model.validation_match import ValidationMatch +from cpl.api.typing import HTTPMethods, PartialMiddleware, TEndpoint, PolicyInput +from cpl.application.abc.application_abc import ApplicationABC +from cpl.dependency.service_provider import ServiceProvider +from cpl.dependency.typing import Modules + + +class WebAppABC(ApplicationABC, ABC): + + def __init__(self, services: ServiceProvider, modules: Modules, required_modules: list[str | object] = None): + super().__init__(services, modules, required_modules) + + def with_routes_directory(self, directory: str) -> Self: ... + def with_app(self, app: Starlette) -> Self: ... + def with_routes( + self, + routes: list[ApiRoute], + method: HTTPMethods, + authentication: bool = False, + roles: list[str | Enum] = None, + permissions: list[str | Enum] = None, + policies: list[str] = None, + match: ValidationMatch = None, + ) -> Self: ... + def with_route( + self, + path: str, + fn: TEndpoint, + method: HTTPMethods, + authentication: bool = False, + roles: list[str | Enum] = None, + permissions: list[str | Enum] = None, + policies: list[str] = None, + match: ValidationMatch = None, + ) -> Self: ... + def with_middleware(self, middleware: PartialMiddleware) -> Self: ... + def with_authentication(self) -> Self: ... + def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self: ... diff --git a/src/cpl-api/cpl/api/application/web_app.py b/src/cpl-api/cpl/api/application/web_app.py index 476e54d2..9b8867fb 100644 --- a/src/cpl-api/cpl/api/application/web_app.py +++ b/src/cpl-api/cpl/api/application/web_app.py @@ -1,6 +1,6 @@ import os from enum import Enum -from typing import Mapping, Any, Callable, Self, Union +from typing import Mapping, Any, Self import uvicorn from starlette.applications import Starlette @@ -10,6 +10,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from starlette.types import ExceptionHandler +from cpl.api.abc.web_app_abc import WebAppABC from cpl.api.api_module import ApiModule from cpl.api.error import APIError from cpl.api.logger import APILogger @@ -24,8 +25,7 @@ from cpl.api.registry.policy import PolicyRegistry from cpl.api.registry.route import RouteRegistry from cpl.api.router import Router from cpl.api.settings import ApiSettings -from cpl.api.typing import HTTPMethods, PartialMiddleware, PolicyResolver -from cpl.application.abc.application_abc import ApplicationABC +from cpl.api.typing import HTTPMethods, PartialMiddleware, TEndpoint, PolicyInput from cpl.auth.auth_module import AuthModule from cpl.auth.permission.permission_module import PermissionsModule from cpl.core.configuration.configuration import Configuration @@ -33,12 +33,10 @@ from cpl.dependency.inject import inject from cpl.dependency.service_provider import ServiceProvider from cpl.dependency.typing import Modules -PolicyInput = Union[dict[str, PolicyResolver], Policy] - -class WebApp(ApplicationABC): - def __init__(self, services: ServiceProvider, modules: Modules): - super().__init__(services, modules, [AuthModule, PermissionsModule, ApiModule]) +class WebApp(WebAppABC): + def __init__(self, services: ServiceProvider, modules: Modules, required_modules: list[str | object] = None): + super().__init__(services, modules, [AuthModule, PermissionsModule, ApiModule] + (required_modules or [])) self._app: Starlette | None = None self._logger = services.get_service(APILogger) @@ -78,16 +76,17 @@ class WebApp(ApplicationABC): self._logger.debug(f"Allowed origins: {origins}") return origins.split(",") - def with_app(self, app: Starlette) -> Self: - assert app is not None, "app must not be None" - assert isinstance(app, Starlette), "app must be an instance of Starlette" - self._app = app - return self - def _check_for_app(self): if self._app is not None: raise ValueError("App is already set, cannot add routes or middleware") + def _validate_policies(self): + for rule in Router.get_authorization_rules(): + for policy_name in rule["policies"]: + policy = self._policies.get(policy_name) + if not policy: + self._logger.fatal(f"Authorization policy '{policy_name}' not found") + def with_routes_directory(self, directory: str) -> Self: self._check_for_app() assert directory is not None, "directory must not be None" @@ -102,6 +101,12 @@ class WebApp(ApplicationABC): return self + def with_app(self, app: Starlette) -> Self: + assert app is not None, "app must not be None" + assert isinstance(app, Starlette), "app must be an instance of Starlette" + self._app = app + return self + def with_routes( self, routes: list[ApiRoute], @@ -131,7 +136,7 @@ class WebApp(ApplicationABC): def with_route( self, path: str, - fn: Callable[[Request], Any], + fn: TEndpoint, method: HTTPMethods, authentication: bool = False, roles: list[str | Enum] = None, @@ -179,6 +184,7 @@ class WebApp(ApplicationABC): return self def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self: + self._check_for_app() if policies: _policies = [] @@ -206,13 +212,6 @@ class WebApp(ApplicationABC): self.with_middleware(AuthorizationMiddleware) return self - def _validate_policies(self): - for rule in Router.get_authorization_rules(): - for policy_name in rule["policies"]: - policy = self._policies.get(policy_name) - if not policy: - self._logger.fatal(f"Authorization policy '{policy_name}' not found") - async def main(self): self._logger.debug(f"Preparing API") self._validate_policies() diff --git a/src/cpl-api/cpl/api/typing.py b/src/cpl-api/cpl/api/typing.py index c8319900..a62d4927 100644 --- a/src/cpl-api/cpl/api/typing.py +++ b/src/cpl-api/cpl/api/typing.py @@ -2,6 +2,7 @@ from typing import Union, Literal, Callable, Type, Awaitable from urllib.request import Request from starlette.middleware import Middleware +from starlette.responses import Response from starlette.types import ASGIApp from starlette.websockets import WebSocket @@ -9,6 +10,7 @@ from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware from cpl.auth.schema import AuthUser TRequest = Union[Request, WebSocket] +TEndpoint = Callable[[TRequest, ...], Awaitable[Response]] | Callable[[TRequest, ...], Response] HTTPMethods = Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] PartialMiddleware = Union[ ASGIMiddleware, @@ -17,3 +19,4 @@ PartialMiddleware = Union[ Callable[[ASGIApp], ASGIApp], ] PolicyResolver = Callable[[AuthUser], bool | Awaitable[bool]] +PolicyInput = Union[dict[str, PolicyResolver], "Policy"] diff --git a/src/cpl-application/cpl/application/abc/application_abc.py b/src/cpl-application/cpl/application/abc/application_abc.py index 59c43b88..a90db406 100644 --- a/src/cpl-application/cpl/application/abc/application_abc.py +++ b/src/cpl-application/cpl/application/abc/application_abc.py @@ -56,7 +56,7 @@ class ApplicationABC(ABC): module_dependency_error( type(self).__name__, - module.__name__, + module.__name__ if not isinstance(module, str) else module, ImportError( f"Required module '{module}' for application '{self.__class__.__name__}' is not loaded. Load using 'add_module({module})' method." ), diff --git a/src/cpl-auth/cpl/auth/schema/_administration/auth_user.py b/src/cpl-auth/cpl/auth/schema/_administration/auth_user.py index cae14f97..5409e468 100644 --- a/src/cpl-auth/cpl/auth/schema/_administration/auth_user.py +++ b/src/cpl-auth/cpl/auth/schema/_administration/auth_user.py @@ -10,7 +10,7 @@ from cpl.auth.permission.permissions import Permissions from cpl.core.typing import SerialId from cpl.database.abc import DbModelABC from cpl.database.logger import DBLogger -from cpl.dependency import ServiceProvider +from cpl.dependency import get_provider class AuthUser(DbModelABC): @@ -87,3 +87,4 @@ class AuthUser(DbModelABC): self._keycloak_id = str(uuid.UUID(int=0)) await auth_user_dao.update(self) + diff --git a/src/cpl-graphql/cpl/graphql/__init__.py b/src/cpl-graphql/cpl/graphql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cpl-graphql/cpl/graphql/_endpoints/__init__.py b/src/cpl-graphql/cpl/graphql/_endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cpl-graphql/cpl/graphql/_endpoints/graphiql.py b/src/cpl-graphql/cpl/graphql/_endpoints/graphiql.py new file mode 100644 index 00000000..2aedb538 --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/_endpoints/graphiql.py @@ -0,0 +1,37 @@ +from starlette.responses import HTMLResponse + +async def graphiql_endpoint(request): + return HTMLResponse(""" + + +
+ +