From fd4eba3fc0a4038f0fc126cd3eda445209e32d25 Mon Sep 17 00:00:00 2001 From: edraft Date: Fri, 26 Sep 2025 21:56:21 +0200 Subject: [PATCH] Added gql base #181 --- example/api/src/main.py | 9 ++- src/cpl-api/cpl/api/abc/web_app_abc.py | 45 ++++++++++++ src/cpl-api/cpl/api/application/web_app.py | 43 ++++++----- src/cpl-api/cpl/api/typing.py | 3 + .../cpl/application/abc/application_abc.py | 2 +- .../auth/schema/_administration/auth_user.py | 3 +- src/cpl-graphql/cpl/graphql/__init__.py | 0 .../cpl/graphql/_endpoints/__init__.py | 0 .../cpl/graphql/_endpoints/graphiql.py | 37 ++++++++++ .../cpl/graphql/_endpoints/graphql.py | 19 +++++ .../cpl/graphql/_endpoints/playground.py | 27 +++++++ .../cpl/graphql/application/__init__.py | 1 + .../cpl/graphql/application/graphql_app.py | 72 +++++++++++++++++++ src/cpl-graphql/cpl/graphql/graphql_module.py | 13 ++++ src/cpl-graphql/cpl/graphql/service.py | 16 +++++ src/cpl-graphql/pyproject.toml | 30 ++++++++ src/cpl-graphql/requirements.dev.txt | 1 + src/cpl-graphql/requirements.txt | 2 + 18 files changed, 297 insertions(+), 26 deletions(-) create mode 100644 src/cpl-api/cpl/api/abc/web_app_abc.py create mode 100644 src/cpl-graphql/cpl/graphql/__init__.py create mode 100644 src/cpl-graphql/cpl/graphql/_endpoints/__init__.py create mode 100644 src/cpl-graphql/cpl/graphql/_endpoints/graphiql.py create mode 100644 src/cpl-graphql/cpl/graphql/_endpoints/graphql.py create mode 100644 src/cpl-graphql/cpl/graphql/_endpoints/playground.py create mode 100644 src/cpl-graphql/cpl/graphql/application/__init__.py create mode 100644 src/cpl-graphql/cpl/graphql/application/graphql_app.py create mode 100644 src/cpl-graphql/cpl/graphql/graphql_module.py create mode 100644 src/cpl-graphql/cpl/graphql/service.py create mode 100644 src/cpl-graphql/pyproject.toml create mode 100644 src/cpl-graphql/requirements.dev.txt create mode 100644 src/cpl-graphql/requirements.txt 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(""" + + + + + GraphiQL + + + +
+ + + + + + + + + + + + """) diff --git a/src/cpl-graphql/cpl/graphql/_endpoints/graphql.py b/src/cpl-graphql/cpl/graphql/_endpoints/graphql.py new file mode 100644 index 00000000..f982238c --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/_endpoints/graphql.py @@ -0,0 +1,19 @@ +from starlette.requests import Request +from starlette.responses import Response, JSONResponse + +from cpl.graphql.service import GraphQLService + + +async def graphql_endpoint(request: Request, service: GraphQLService) -> Response: + body = await request.json() + query = body.get("query") + variables = body.get("variables") + + result = service.execute(query, variables, {"request": request}) + response_data = {} + if result.errors: + response_data["errors"] = [str(e) for e in result.errors] + if result.data: + response_data["data"] = result.data + + return JSONResponse(response_data) diff --git a/src/cpl-graphql/cpl/graphql/_endpoints/playground.py b/src/cpl-graphql/cpl/graphql/_endpoints/playground.py new file mode 100644 index 00000000..68e59fdf --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/_endpoints/playground.py @@ -0,0 +1,27 @@ +from starlette.requests import Request +from starlette.responses import Response, HTMLResponse + + +async def playground_endpoint(request: Request) -> Response: + return HTMLResponse(""" + + + + + GraphQL Playground + + + + + +
+ + + + """) diff --git a/src/cpl-graphql/cpl/graphql/application/__init__.py b/src/cpl-graphql/cpl/graphql/application/__init__.py new file mode 100644 index 00000000..96b2346c --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/application/__init__.py @@ -0,0 +1 @@ +from .graphql_app import WebApp diff --git a/src/cpl-graphql/cpl/graphql/application/graphql_app.py b/src/cpl-graphql/cpl/graphql/application/graphql_app.py new file mode 100644 index 00000000..fa2f6d86 --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/application/graphql_app.py @@ -0,0 +1,72 @@ +from enum import Enum + +from cpl.api.application import WebApp +from cpl.api.model.validation_match import ValidationMatch +from cpl.dependency.service_provider import ServiceProvider +from cpl.dependency.typing import Modules +from .._endpoints.graphiql import graphiql_endpoint +from .._endpoints.graphql import graphql_endpoint +from .._endpoints.playground import playground_endpoint +from ..graphql_module import GraphQLModule + + +class GraphQLApp(WebApp): + def __init__(self, services: ServiceProvider, modules: Modules): + super().__init__(services, modules, [GraphQLModule]) + + def with_graphql( + self, + authentication: bool = False, + roles: list[str | Enum] = None, + permissions: list[str | Enum] = None, + policies: list[str] = None, + match: ValidationMatch = None, + ): + self.with_route( + path="/api/graphql", + fn=graphql_endpoint, + method="POST", + authentication=authentication, + roles=roles, + permissions=permissions, + policies=policies, + match=match, + ) + + def with_graphiql( + self, + authentication: bool = False, + roles: list[str | Enum] = None, + permissions: list[str | Enum] = None, + policies: list[str] = None, + match: ValidationMatch = None, + ): + self.with_route( + path="/api/graphiql", + fn=graphiql_endpoint, + method="GET", + authentication=authentication, + roles=roles, + permissions=permissions, + policies=policies, + match=match, + ) + + def with_playground( + self, + authentication: bool = False, + roles: list[str | Enum] = None, + permissions: list[str | Enum] = None, + policies: list[str] = None, + match: ValidationMatch = None, + ): + self.with_route( + path="/api/playground", + fn=playground_endpoint, + method="GET", + authentication=authentication, + roles=roles, + permissions=permissions, + policies=policies, + match=match, + ) diff --git a/src/cpl-graphql/cpl/graphql/graphql_module.py b/src/cpl-graphql/cpl/graphql/graphql_module.py new file mode 100644 index 00000000..283e7aaf --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/graphql_module.py @@ -0,0 +1,13 @@ +from cpl.api import ApiModule +from cpl.dependency.module.module import Module +from cpl.dependency.service_provider import ServiceProvider +from cpl.graphql.service import GraphQLService + + +class GraphQLModule(Module): + dependencies = [ApiModule] + scoped = [GraphQLService] + + @staticmethod + def configure(services: ServiceProvider) -> None: + pass diff --git a/src/cpl-graphql/cpl/graphql/service.py b/src/cpl-graphql/cpl/graphql/service.py new file mode 100644 index 00000000..018c75e8 --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/service.py @@ -0,0 +1,16 @@ +from graphene import Schema +from graphql import graphql_sync + + +class GraphQLService: + def __init__(self): + pass + + def execute(self, query: str, variables: dict | None = None, context: dict | None = None): + result = graphql_sync( + self._schema.graphql_schema, + query, + variable_values=variables, + context_value=context or {}, + ) + return result diff --git a/src/cpl-graphql/pyproject.toml b/src/cpl-graphql/pyproject.toml new file mode 100644 index 00000000..cecb85d2 --- /dev/null +++ b/src/cpl-graphql/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=70.1.0", "wheel>=0.43.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "cpl-database" +version = "2024.7.0" +description = "CPL database" +readme ="CPL database package" +requires-python = ">=3.12" +license = { text = "MIT" } +authors = [ + { name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" } +] +keywords = ["cpl", "database", "backend", "shared", "library"] + +dynamic = ["dependencies", "optional-dependencies"] + +[project.urls] +Homepage = "https://www.sh-edraft.de" + +[tool.setuptools.packages.find] +where = ["."] +include = ["cpl*"] + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } +optional-dependencies.dev = { file = ["requirements.dev.txt"] } + + diff --git a/src/cpl-graphql/requirements.dev.txt b/src/cpl-graphql/requirements.dev.txt new file mode 100644 index 00000000..e7664b42 --- /dev/null +++ b/src/cpl-graphql/requirements.dev.txt @@ -0,0 +1 @@ +black==25.1.0 \ No newline at end of file diff --git a/src/cpl-graphql/requirements.txt b/src/cpl-graphql/requirements.txt new file mode 100644 index 00000000..abe92c36 --- /dev/null +++ b/src/cpl-graphql/requirements.txt @@ -0,0 +1,2 @@ +cpl-api +graphene==3.4.3 \ No newline at end of file