Added gql base #181
Some checks failed
Test before pr merge / test-lint (pull_request) Failing after 7s

This commit is contained in:
2025-09-26 21:56:21 +02:00
parent e0f6e1c241
commit fd4eba3fc0
18 changed files with 297 additions and 26 deletions

View File

@@ -3,7 +3,7 @@ from starlette.responses import JSONResponse
from cpl.api.api_module import ApiModule from cpl.api.api_module import ApiModule
from cpl.api.application.web_app import WebApp from cpl.api.application.web_app import WebApp
from cpl.application.application_builder import ApplicationBuilder 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.permission.permissions import Permissions
from cpl.auth.schema import AuthUser, Role from cpl.auth.schema import AuthUser, Role
from cpl.core.configuration import Configuration 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.environment import Environment
from cpl.core.utils.cache import Cache from cpl.core.utils.cache import Cache
from cpl.database.mysql.mysql_module import MySQLModule from cpl.database.mysql.mysql_module import MySQLModule
from cpl.graphql.graphql_module import GraphQLModule
from scoped_service import ScopedService from scoped_service import ScopedService
from service import PingService from service import PingService
def main(): def main():
builder = ApplicationBuilder[WebApp](WebApp) builder = ApplicationBuilder[GraphQLApp](GraphQLApp)
Configuration.add_json_file(f"appsettings.json") Configuration.add_json_file(f"appsettings.json")
Configuration.add_json_file(f"appsettings.{Environment.get_environment()}.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_transient(PingService)
builder.services.add_module(MySQLModule) builder.services.add_module(MySQLModule)
builder.services.add_module(ApiModule) builder.services.add_module(ApiModule)
builder.services.add_module(GraphQLModule)
builder.services.add_scoped(ScopedService) builder.services.add_scoped(ScopedService)
@@ -47,6 +49,9 @@ def main():
permissions=[Permissions.administrator], permissions=[Permissions.administrator],
) )
app.with_routes_directory("routes") app.with_routes_directory("routes")
app.with_graphql()
app.with_playground()
app.with_graphiql()
provider = builder.service_provider provider = builder.service_provider
user_cache = provider.get_service(Cache[AuthUser]) user_cache = provider.get_service(Cache[AuthUser])

View File

@@ -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: ...

View File

@@ -1,6 +1,6 @@
import os import os
from enum import Enum from enum import Enum
from typing import Mapping, Any, Callable, Self, Union from typing import Mapping, Any, Self
import uvicorn import uvicorn
from starlette.applications import Starlette from starlette.applications import Starlette
@@ -10,6 +10,7 @@ from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.types import ExceptionHandler from starlette.types import ExceptionHandler
from cpl.api.abc.web_app_abc import WebAppABC
from cpl.api.api_module import ApiModule from cpl.api.api_module import ApiModule
from cpl.api.error import APIError from cpl.api.error import APIError
from cpl.api.logger import APILogger 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.registry.route import RouteRegistry
from cpl.api.router import Router from cpl.api.router import Router
from cpl.api.settings import ApiSettings from cpl.api.settings import ApiSettings
from cpl.api.typing import HTTPMethods, PartialMiddleware, PolicyResolver from cpl.api.typing import HTTPMethods, PartialMiddleware, TEndpoint, PolicyInput
from cpl.application.abc.application_abc import ApplicationABC
from cpl.auth.auth_module import AuthModule from cpl.auth.auth_module import AuthModule
from cpl.auth.permission.permission_module import PermissionsModule from cpl.auth.permission.permission_module import PermissionsModule
from cpl.core.configuration.configuration import Configuration 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.service_provider import ServiceProvider
from cpl.dependency.typing import Modules from cpl.dependency.typing import Modules
PolicyInput = Union[dict[str, PolicyResolver], Policy]
class WebApp(WebAppABC):
class WebApp(ApplicationABC): def __init__(self, services: ServiceProvider, modules: Modules, required_modules: list[str | object] = None):
def __init__(self, services: ServiceProvider, modules: Modules): super().__init__(services, modules, [AuthModule, PermissionsModule, ApiModule] + (required_modules or []))
super().__init__(services, modules, [AuthModule, PermissionsModule, ApiModule])
self._app: Starlette | None = None self._app: Starlette | None = None
self._logger = services.get_service(APILogger) self._logger = services.get_service(APILogger)
@@ -78,16 +76,17 @@ class WebApp(ApplicationABC):
self._logger.debug(f"Allowed origins: {origins}") self._logger.debug(f"Allowed origins: {origins}")
return origins.split(",") 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): def _check_for_app(self):
if self._app is not None: if self._app is not None:
raise ValueError("App is already set, cannot add routes or middleware") 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: def with_routes_directory(self, directory: str) -> Self:
self._check_for_app() self._check_for_app()
assert directory is not None, "directory must not be None" assert directory is not None, "directory must not be None"
@@ -102,6 +101,12 @@ class WebApp(ApplicationABC):
return self 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( def with_routes(
self, self,
routes: list[ApiRoute], routes: list[ApiRoute],
@@ -131,7 +136,7 @@ class WebApp(ApplicationABC):
def with_route( def with_route(
self, self,
path: str, path: str,
fn: Callable[[Request], Any], fn: TEndpoint,
method: HTTPMethods, method: HTTPMethods,
authentication: bool = False, authentication: bool = False,
roles: list[str | Enum] = None, roles: list[str | Enum] = None,
@@ -179,6 +184,7 @@ class WebApp(ApplicationABC):
return self return self
def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self: def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self:
self._check_for_app()
if policies: if policies:
_policies = [] _policies = []
@@ -206,13 +212,6 @@ class WebApp(ApplicationABC):
self.with_middleware(AuthorizationMiddleware) self.with_middleware(AuthorizationMiddleware)
return self 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): async def main(self):
self._logger.debug(f"Preparing API") self._logger.debug(f"Preparing API")
self._validate_policies() self._validate_policies()

View File

@@ -2,6 +2,7 @@ from typing import Union, Literal, Callable, Type, Awaitable
from urllib.request import Request from urllib.request import Request
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.responses import Response
from starlette.types import ASGIApp from starlette.types import ASGIApp
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
@@ -9,6 +10,7 @@ from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
from cpl.auth.schema import AuthUser from cpl.auth.schema import AuthUser
TRequest = Union[Request, WebSocket] TRequest = Union[Request, WebSocket]
TEndpoint = Callable[[TRequest, ...], Awaitable[Response]] | Callable[[TRequest, ...], Response]
HTTPMethods = Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] HTTPMethods = Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
PartialMiddleware = Union[ PartialMiddleware = Union[
ASGIMiddleware, ASGIMiddleware,
@@ -17,3 +19,4 @@ PartialMiddleware = Union[
Callable[[ASGIApp], ASGIApp], Callable[[ASGIApp], ASGIApp],
] ]
PolicyResolver = Callable[[AuthUser], bool | Awaitable[bool]] PolicyResolver = Callable[[AuthUser], bool | Awaitable[bool]]
PolicyInput = Union[dict[str, PolicyResolver], "Policy"]

View File

@@ -56,7 +56,7 @@ class ApplicationABC(ABC):
module_dependency_error( module_dependency_error(
type(self).__name__, type(self).__name__,
module.__name__, module.__name__ if not isinstance(module, str) else module,
ImportError( ImportError(
f"Required module '{module}' for application '{self.__class__.__name__}' is not loaded. Load using 'add_module({module})' method." f"Required module '{module}' for application '{self.__class__.__name__}' is not loaded. Load using 'add_module({module})' method."
), ),

View File

@@ -10,7 +10,7 @@ from cpl.auth.permission.permissions import Permissions
from cpl.core.typing import SerialId from cpl.core.typing import SerialId
from cpl.database.abc import DbModelABC from cpl.database.abc import DbModelABC
from cpl.database.logger import DBLogger from cpl.database.logger import DBLogger
from cpl.dependency import ServiceProvider from cpl.dependency import get_provider
class AuthUser(DbModelABC): class AuthUser(DbModelABC):
@@ -87,3 +87,4 @@ class AuthUser(DbModelABC):
self._keycloak_id = str(uuid.UUID(int=0)) self._keycloak_id = str(uuid.UUID(int=0))
await auth_user_dao.update(self) await auth_user_dao.update(self)

View File

View File

@@ -0,0 +1,37 @@
from starlette.responses import HTMLResponse
async def graphiql_endpoint(request):
return HTMLResponse("""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>GraphiQL</title>
<link href="https://unpkg.com/graphiql@2.4.0/graphiql.min.css" rel="stylesheet" />
</head>
<body style="margin:0;overflow:hidden;">
<div id="graphiql" style="height:100vh;"></div>
<!-- React + ReactDOM -->
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<!-- GraphiQL -->
<script src="https://unpkg.com/graphiql@2.4.0/graphiql.min.js"></script>
<script>
const graphQLFetcher = graphQLParams =>
fetch('/api/graphql', {
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(graphQLParams),
}).then(response => response.json()).catch(() => response.text());
ReactDOM.render(
React.createElement(GraphiQL, { fetcher: graphQLFetcher }),
document.getElementById('graphiql'),
);
</script>
</body>
</html>
""")

View File

@@ -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)

View File

@@ -0,0 +1,27 @@
from starlette.requests import Request
from starlette.responses import Response, HTMLResponse
async def playground_endpoint(request: Request) -> Response:
return HTMLResponse("""
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8/>
<title>GraphQL Playground</title>
<link rel="stylesheet" href="https://unpkg.com/graphql-playground-react/build/static/css/index.css" />
<link rel="shortcut icon" href="https://raw.githubusercontent.com/graphql/graphql-playground/master/packages/graphql-playground-react/public/favicon.png" />
<script src="https://unpkg.com/graphql-playground-react/build/static/js/middleware.js"></script>
</head>
<body>
<div id="root"/>
<script>
window.addEventListener('load', function () {
GraphQLPlayground.init(document.getElementById('root'), {
endpoint: '/api/graphql'
})
})
</script>
</body>
</html>
""")

View File

@@ -0,0 +1 @@
from .graphql_app import WebApp

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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"] }

View File

@@ -0,0 +1 @@
black==25.1.0

View File

@@ -0,0 +1,2 @@
cpl-api
graphene==3.4.3