Added gql base #181

This commit is contained in:
2025-09-26 21:56:21 +02:00
parent 685c20e3bf
commit e1ab9cf0db
33 changed files with 500 additions and 30 deletions

View File

@@ -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,15 @@ 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 cpl.graphql.schema.root_query import RootQuery
from queries.hello import HelloQuery
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,12 +30,15 @@ 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)
builder.services.add_cache(AuthUser)
builder.services.add_cache(Role)
builder.services.add_transient(HelloQuery)
app = builder.build()
app.with_logging()
@@ -48,6 +54,13 @@ def main():
)
app.with_routes_directory("routes")
schema = app.with_graphql()
schema.query.string_field("ping", resolver=lambda *_: "pong")
schema.query.with_query("hello", HelloQuery)
app.with_playground()
app.with_graphiql()
provider = builder.service_provider
user_cache = provider.get_service(Cache[AuthUser])
role_cache = provider.get_service(Cache[Role])

View File

View File

@@ -0,0 +1,13 @@
import graphene
from cpl.graphql.schema.query import Query
class HelloQuery(Query):
def __init__(self):
Query.__init__(self)
self.string_field(
"message",
args={"name": graphene.String(default_value="world")},
resolver=lambda *_, name: f"Hello {name}",
)

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):
ApplicationABC.__init__(self, 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
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):
WebAppABC.__init__(self, 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()

View File

@@ -8,7 +8,7 @@ class APIError(HTTPException):
status_code = 500
def __init__(self, message: str = ""):
super().__init__(self.status_code, message)
HTTPException.__init__(self, self.status_code, message)
self._message = message
@property

View File

@@ -6,7 +6,7 @@ from cpl.core.configuration import ConfigurationModelABC
class ApiSettings(ConfigurationModelABC):
def __init__(self, src: Optional[dict] = None):
super().__init__(src)
ConfigurationModelABC.__init__(self, src)
self.option("host", str, "0.0.0.0")
self.option("port", int, 5000)

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ class ModuleABC(ABC):
__OPTIONAL_VARS = ["dependencies", "configuration", "singleton", "scoped", "transient", "hosted"]
def __init_subclass__(cls):
super().__init_subclass__()
ABC.__init_subclass__()
if f"{cls.__module__}.{cls.__name__}" == "cpl.dependency.module.module.Module":
return

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,13 @@
from starlette.requests import Request
from starlette.responses import Response, JSONResponse
from cpl.graphql.service.service import GraphQLService
async def graphql_endpoint(request: Request, service: GraphQLService) -> Response:
body = await request.json()
query = body.get("query")
variables = body.get("variables")
response_data = await service.execute(query, variables, request)
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,54 @@
from typing import Callable, Any, Type
from graphene import ObjectType
class QueryBase(ObjectType):
def __init__(self):
from cpl.graphql.schema.field import Field
ObjectType.__init__(self)
self._fields: dict[str, Field] = {}
def get_fields(self) -> dict[str, "Field"]:
return self._fields
def field(
self,
name: str,
t: type,
args: dict[str, Any] | None = None,
resolver: Callable | None = None,
):
gql_type_map: dict[object, str] = {
str: "String",
int: "Int",
float: "Float",
bool: "Boolean",
}
if t not in gql_type_map:
raise ValueError(f"Unsupported field type: {t}")
from cpl.graphql.schema.field import Field
self._fields[name] = Field(name, "String", resolver, args)
def with_query(self, name: str, subquery: Type["QueryBase"]):
from cpl.graphql.schema.field import Field
f = Field(name=name, gql_type="Object", resolver=lambda root, info, **kwargs: {}, subquery=subquery)
self._fields[name] = f
def string_field(self, name: str, args: dict[str, Any] | None = None, resolver: Callable | None = None):
self.field(name, str, args, resolver)
def int_field(self, name: str, args: dict[str, Any] | None = None, resolver: Callable | None = None):
self.field(name, int, args, resolver)
def float_field(self, name: str, args: dict[str, Any] | None = None, resolver: Callable | None = None):
self.field(name, float, args, resolver)
def bool_field(self, name: str, args: dict[str, Any] | None = None, resolver: Callable | None = None):
self.field(name, bool, args, resolver)

View File

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

View File

@@ -0,0 +1,80 @@
from enum import Enum
from typing import Self
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
from ..service.schema import Schema
class GraphQLApp(WebApp):
def __init__(self, services: ServiceProvider, modules: Modules):
WebApp.__init__(self, 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,
) -> Schema:
self.with_route(
path="/api/graphql",
fn=graphql_endpoint,
method="POST",
authentication=authentication,
roles=roles,
permissions=permissions,
policies=policies,
match=match,
)
schema = self._services.get_service(Schema)
if schema is None:
self._logger.fatal("Could not resolve RootQuery. Make sure GraphQLModule is registered.")
return schema
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:
self.with_route(
path="/api/graphiql",
fn=graphiql_endpoint,
method="GET",
authentication=authentication,
roles=roles,
permissions=permissions,
policies=policies,
match=match,
)
return self
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:
self.with_route(
path="/api/playground",
fn=playground_endpoint,
method="GET",
authentication=authentication,
roles=roles,
permissions=permissions,
policies=policies,
match=match,
)
return self

View File

@@ -0,0 +1,17 @@
from cpl.api import ApiModule
from cpl.dependency.module.module import Module
from cpl.dependency.service_provider import ServiceProvider
from cpl.graphql.schema.root_query import RootQuery
from cpl.graphql.service.schema import Schema
from cpl.graphql.service.service import GraphQLService
class GraphQLModule(Module):
dependencies = [ApiModule]
singleton = [Schema, RootQuery]
scoped = [GraphQLService]
@staticmethod
def configure(services: ServiceProvider) -> None:
schema = services.get_service(Schema)
schema.build()

View File

@@ -0,0 +1,30 @@
from cpl.graphql.abc.query_base import QueryBase
class Field:
def __init__(self, name: str, gql_type: str, resolver: callable, args: dict | None = None, subquery: QueryBase | None = None):
self._name = name
self._gql_type = gql_type
self._resolver = resolver
self._args = args or {}
self._subquery: QueryBase | None = subquery
@property
def name(self) -> str:
return self._name
@property
def type(self) -> str:
return self._gql_type
@property
def resolver(self) -> callable:
return self._resolver
@property
def args(self) -> dict:
return self._args
@property
def subquery(self) -> QueryBase | None:
return self._subquery

View File

@@ -0,0 +1,6 @@
from cpl.graphql.abc.query_base import QueryBase
class Query(QueryBase):
def __init__(self):
QueryBase.__init__(self)

View File

@@ -0,0 +1,6 @@
from cpl.graphql.schema.query import Query
class RootQuery(Query):
def __init__(self):
Query.__init__(self)

View File

@@ -0,0 +1,56 @@
import graphene
from cpl.api import APILogger
from cpl.dependency.service_provider import ServiceProvider
from cpl.graphql.schema.query import Query
from cpl.graphql.schema.root_query import RootQuery
class Schema:
def __init__(self, logger: APILogger, query: RootQuery, provider: ServiceProvider):
self._logger = logger
self._provider = provider
self._query = query
self._schema = None
@property
def schema(self) -> graphene.Schema | None:
return self._schema
@property
def query(self) -> RootQuery:
return self._query
def build(self) -> graphene.Schema:
self._schema = graphene.Schema(
query=self.to_graphene(self._query),
mutation=None,
subscription=None,
)
return self._schema
def to_graphene(self, query: Query, name: str | None = None):
assert query is not None, "Query cannot be None"
attrs = {}
for field in query.get_fields().values():
if field.type == "String":
attrs[field.name] = graphene.Field(
graphene.String,
**field.args,
resolver=field.resolver
)
elif field.type == "Object" and field.subquery is not None:
subquery = self._provider.get_service(field.subquery)
sub = self.to_graphene(subquery, name=field.name.capitalize())
attrs[field.name] = graphene.Field(
sub,
**field.args,
resolver=field.resolver
)
class_name = name or query.__class__.__name__
return type(class_name, (graphene.ObjectType,), attrs)

View File

@@ -0,0 +1,31 @@
from typing import Any, Dict, Optional
from cpl.api.typing import TRequest
from cpl.graphql.service.schema import Schema
class GraphQLService:
def __init__(self, schema: Schema):
self._schema = schema.schema
if self._schema is None:
raise ValueError("Schema has not been built. Call schema.build() before using the service.")
async def execute(
self,
query: str,
variables: Optional[Dict[str, Any]],
request: TRequest,
) -> Dict[str, Any]:
result = await self._schema.execute_async(
query,
variable_values=variables,
context_value={"request": request},
)
response_data: Dict[str, Any] = {}
if result.errors:
response_data["errors"] = [str(e) for e in result.errors]
if result.data:
response_data["data"] = result.data
return response_data

View File

@@ -0,0 +1,5 @@
from typing import Type
from cpl.graphql.schema.query import Query
TQuery = Type[Query]

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

View File

@@ -6,7 +6,7 @@ from cpl.query.typing import K
class OrderedEnumerable(Enumerable[T]):
def __init__(self, source, key_selectors: List[tuple[Callable[[T], K], bool]]):
super().__init__(source)
Enumerable.__init__(self, source)
self._key_selectors = key_selectors
def __iter__(self) -> Iterator[T]: