Added api & route handling
Some checks failed
Build on push / prepare (push) Successful in 9s
Build on push / core (push) Successful in 19s
Build on push / query (push) Successful in 19s
Build on push / dependency (push) Successful in 17s
Build on push / application (push) Successful in 15s
Build on push / database (push) Successful in 18s
Build on push / mail (push) Successful in 19s
Build on push / translation (push) Successful in 23s
Build on push / auth (push) Successful in 16s
Build on push / api (push) Failing after 14s

This commit is contained in:
2025-09-19 21:03:33 +02:00
parent 1a67318091
commit ddc62dfb9a
34 changed files with 568 additions and 42 deletions

View File

@@ -12,6 +12,13 @@ jobs:
version_suffix: 'dev'
secrets: inherit
api:
uses: ./.gitea/workflows/package.yaml
needs: [ prepare, application, auth, core, dependency ]
with:
working_directory: src/cpl-application
secrets: inherit
application:
uses: ./.gitea/workflows/package.yaml
needs: [ prepare, core, dependency ]

View File

View File

@@ -0,0 +1,7 @@
from cpl.core.log.logger import Logger
class APILogger(Logger):
def __init__(self, source: str):
Logger.__init__(self, source, "api")

View File

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

View File

@@ -0,0 +1,25 @@
from http.client import HTTPException
class APIError(HTTPException):
status_code = 500
class Unauthorized(APIError):
status_code = 401
class Forbidden(APIError):
status_code = 403
class NotFound(APIError):
status_code = 404
class AlreadyExists(APIError):
status_code = 409
class EndpointNotImplemented(APIError):
status_code = 501

View File

@@ -0,0 +1,65 @@
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from cpl.api.api_logger import APILogger
_logger = APILogger(__name__)
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
await self._log_request(request)
response = await call_next(request)
await self._log_after_request(request, response)
return response
@staticmethod
def _filter_relevant_headers(headers: dict) -> dict:
relevant_keys = {
"content-type",
"host",
"connection",
"user-agent",
"origin",
"referer",
"accept",
}
return {key: value for key, value in headers.items() if key in relevant_keys}
@classmethod
async def _log_request(cls, request: Request):
_logger.debug(
f"Request {request.state.request_id}: {request.method}@{request.url.path} from {request.client.host}"
)
from cpl.core.ctx.user_context import get_user
user = get_user()
request_info = {
"headers": cls._filter_relevant_headers(dict(request.headers)),
"args": dict(request.query_params),
"form-data": (
await request.form()
if request.headers.get("content-type") == "application/x-www-form-urlencoded"
else None
),
"payload": (await request.json() if request.headers.get("content-length") == "0" else None),
"user": f"{user.id}-{user.keycloak_id}" if user else None,
"files": (
{key: file.filename for key, file in (await request.form()).items()} if await request.form() else None
),
}
_logger.trace(f"Request {request.state.request_id}: {request_info}")
@staticmethod
async def _log_after_request(request: Request, response: Response):
duration = (time.time() - request.state.start_time) * 1000
_logger.info(
f"Request finished {request.state.request_id}: {response.status_code}-{request.method}@{request.url.path} from {request.client.host} in {duration:.2f}ms"
)

View File

@@ -0,0 +1,48 @@
import time
from contextvars import ContextVar
from typing import Optional, Union
from uuid import uuid4
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.websockets import WebSocket
from cpl.api.api_logger import APILogger
from cpl.api.typing import TRequest
_request_context: ContextVar[Union[TRequest, None]] = ContextVar("request", default=None)
_logger = APILogger(__name__)
class RequestMiddleware(BaseHTTPMiddleware):
_request_token = {}
_user_token = {}
@classmethod
async def set_request_data(cls, request: TRequest):
request.state.request_id = uuid4()
request.state.start_time = time.time()
_logger.trace(f"Set new current request: {request.state.request_id}")
cls._request_token[request.state.request_id] = _request_context.set(request)
@classmethod
async def clean_request_data(cls):
request = get_request()
if request is None:
return
if request.state.request_id in cls._request_token:
_request_context.reset(cls._request_token[request.state.request_id])
async def dispatch(self, request: TRequest, call_next):
await self.set_request_data(request)
try:
response = await call_next(request)
return response
finally:
await self.clean_request_data()
def get_request() -> Optional[Union[TRequest, WebSocket]]:
return _request_context.get()

View File

@@ -0,0 +1,60 @@
from starlette.routing import Route
class Router:
_registered_routes: list[Route] = []
@classmethod
def get_routes(cls) -> list[Route]:
return cls._registered_routes
@classmethod
def route(cls, path=None, **kwargs):
def inner(fn):
cls._registered_routes.append(Route(path, fn, **kwargs))
setattr(fn, "_route_path", path)
return fn
return inner
@classmethod
def get(cls, path=None, **kwargs):
return cls.route(path, methods=["GET"], **kwargs)
@classmethod
def post(cls, path=None, **kwargs):
return cls.route(path, methods=["POST"], **kwargs)
@classmethod
def head(cls, path=None, **kwargs):
return cls.route(path, methods=["HEAD"], **kwargs)
@classmethod
def put(cls, path=None, **kwargs):
return cls.route(path, methods=["PUT"], **kwargs)
@classmethod
def delete(cls, path=None, **kwargs):
return cls.route(path, methods=["DELETE"], **kwargs)
@classmethod
def override(cls):
"""
Decorator to override an existing route with the same path.
Usage:
@Route.override()
@Route.get("/example")
async def example_endpoint(request: TRequest):
...
"""
def inner(fn):
route_path = getattr(fn, "_route_path", None)
routes = list(filter(lambda x: x.path == route_path, cls._registered_routes))
for route in routes[:-1]:
cls._registered_routes.remove(route)
return fn
return inner

View File

@@ -0,0 +1,13 @@
from typing import Union, Literal, Callable
from urllib.request import Request
from starlette.middleware import Middleware
from starlette.types import ASGIApp
from starlette.websockets import WebSocket
TRequest = Union[Request, WebSocket]
HTTPMethods = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
PartialMiddleware = Union[
Middleware,
Callable[[ASGIApp], ASGIApp],
]

View File

@@ -0,0 +1,153 @@
import os
from typing import Mapping, Any, Callable
import uvicorn
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.types import ExceptionHandler
from cpl.api.api_logger import APILogger
from cpl.api.api_settings import ApiSettings
from cpl.api.error import APIError
from cpl.api.middleware.logging import LoggingMiddleware
from cpl.api.middleware.request import RequestMiddleware
from cpl.api.router import Router
from cpl.api.typing import HTTPMethods, PartialMiddleware
from cpl.application.abc.application_abc import ApplicationABC
from cpl.core.configuration import Configuration
from cpl.dependency.service_provider_abc import ServiceProviderABC
_logger = APILogger("API")
class WebApp(ApplicationABC):
def __init__(self, services: ServiceProviderABC):
super().__init__(services)
self._app: Starlette | None = None
self._api_settings = Configuration.get(ApiSettings)
self._routes: list[Route] = []
self._middleware: list[Middleware] = [
Middleware(RequestMiddleware),
Middleware(LoggingMiddleware),
]
self._exception_handlers: Mapping[Any, ExceptionHandler] = {Exception: self.handle_exception}
@staticmethod
async def handle_exception(request: Request, exc: Exception):
if hasattr(request.state, "request_id"):
_logger.error(f"Request {request.state.request_id}", exc)
else:
_logger.error("Request unknown", exc)
if isinstance(exc, APIError):
return JSONResponse({"error": str(exc)}, status_code=exc.status_code)
return JSONResponse({"error": str(exc)}, status_code=500)
def _get_allowed_origins(self):
origins = self._api_settings.allowed_origins
if origins is None or origins == "":
_logger.warning("No allowed origins specified, allowing all origins")
return ["*"]
_logger.debug(f"Allowed origins: {origins}")
return origins.split(",")
def with_app(self, app: Starlette):
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 with_routes_directory(self, directory: str) -> "WebApp":
self._check_for_app()
assert directory is not None, "directory must not be None"
base = directory.replace("/", ".").replace("\\", ".")
for filename in os.listdir(directory):
if not filename.endswith(".py") or filename == "__init__.py":
continue
__import__(f"{base}.{filename[:-3]}")
return self
def with_routes(self, routes: list[Route]) -> "WebApp":
self._check_for_app()
assert self._routes is not None, "routes must not be None"
assert all(isinstance(route, Route) for route in routes), "all routes must be of type starlette.routing.Route"
self._routes.extend(routes)
return self
def with_route(self, path: str, fn: Callable[[Request], Any], method: HTTPMethods, **kwargs) -> "WebApp":
self._check_for_app()
assert path is not None, "path must not be None"
assert fn is not None, "fn must not be None"
assert method in ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], "method must be a valid HTTP method"
self._routes.append(Route(path, fn, methods=[method], **kwargs))
return self
def with_middleware(self, middleware: PartialMiddleware) -> "WebApp":
self._check_for_app()
if isinstance(middleware, Middleware):
self._middleware.append(middleware)
elif callable(middleware):
self._middleware.append(Middleware(middleware))
else:
raise ValueError("middleware must be of type starlette.middleware.Middleware or a callable")
return self
def main(self):
_logger.debug(f"Preparing API")
if self._app is None:
routes = [
Route(
path=route.path,
endpoint=self._services.inject(route.endpoint),
methods=route.methods,
name=route.name,
)
for route in self._routes + Router.get_routes()
]
app = Starlette(
routes=routes,
middleware=[
*self._middleware,
Middleware(
CORSMiddleware,
allow_origins=self._get_allowed_origins(),
allow_methods=["*"],
allow_headers=["*"],
),
],
exception_handlers=self._exception_handlers,
)
else:
app = self._app
_logger.info(f"Start API on {self._api_settings.host}:{self._api_settings.port}")
uvicorn.run(
app,
host=self._api_settings.host,
port=self._api_settings.port,
log_config=None,
)
_logger.info("Shutdown API")

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-application"
version = "2024.7.0"
description = "CPL application"
readme ="CPL application package"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [
{ name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" }
]
keywords = ["cpl", "application", "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,6 @@
cpl-auth
cpl-application
cpl-core
cpl-dependency
starlette==0.48.0
python-multipart==0.0.20

View File

@@ -4,7 +4,7 @@ from typing import Callable, Self
from cpl.application.host import Host
from cpl.core.console.console import Console
from cpl.core.log import LogSettings
from cpl.core.log.log_level_enum import LogLevel
from cpl.core.log.log_level import LogLevel
from cpl.core.log.logger_abc import LoggerABC
from cpl.dependency.service_provider_abc import ServiceProviderABC

View File

@@ -1,5 +1,5 @@
import asyncio
from typing import Type, Optional
from typing import Type, Optional, TypeVar, Generic
from cpl.application.abc.application_abc import ApplicationABC
from cpl.application.abc.application_extension_abc import ApplicationExtensionABC
@@ -8,9 +8,10 @@ from cpl.application.abc.startup_extension_abc import StartupExtensionABC
from cpl.application.host import Host
from cpl.dependency.service_collection import ServiceCollection
TApp = TypeVar("TApp", bound=ApplicationABC)
class ApplicationBuilder:
r"""A builder for constructing an application with configurable services and extensions."""
class ApplicationBuilder(Generic[TApp]):
def __init__(self, app: Type[ApplicationABC]):
assert app is not None, "app must not be None"
@@ -49,7 +50,7 @@ class ApplicationBuilder:
return self
def build(self) -> ApplicationABC:
def build(self) -> TApp:
for extension in self._startup_extensions:
Host.run(extension.configure_configuration)
Host.run(extension.configure_services, self._services)

View File

@@ -2,6 +2,7 @@ import inspect
import json
import os
import sys
from inspect import isclass
from typing import Any
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
@@ -126,7 +127,11 @@ class Configuration:
@classmethod
def get(cls, key: Any, default: D = None) -> T | D:
if inspect.isclass(key):
key = key.__name__
key_name = key.__name__ if inspect.isclass(key) else key
return cls._config.get(key, default)
result = cls._config.get(key_name, default)
if issubclass(key, ConfigurationModelABC) and result == default:
result = key()
cls.set(key, result)
return result

View File

@@ -49,7 +49,7 @@ class ConfigurationModelABC(ABC):
String.first_to_lower(field),
String.to_camel_case(field),
String.to_snake_case(field),
String.first_to_upper(String.to_camel_case(field)),
String.to_pascal_case(field),
]
value = None
@@ -64,7 +64,8 @@ class ConfigurationModelABC(ABC):
env_field = field.upper()
if self._env_prefix:
env_field = f"{self._env_prefix}_{env_field}"
value = Environment.get(env_field, cast_type)
value = cast(Environment.get(env_field, str), cast_type)
if value is None and required:
raise ValueError(f"{field} is required")

View File

@@ -1,4 +1,4 @@
from .logger import Logger
from .logger_abc import LoggerABC
from .log_level_enum import LogLevel
from .logging_settings import LogSettings
from .log_level import LogLevel
from .log_settings import LogSettings

View File

@@ -1,7 +1,7 @@
from typing import Optional
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
from cpl.core.log.log_level_enum import LogLevel
from cpl.core.log.log_level import LogLevel
class LogSettings(ConfigurationModelABC):
@@ -10,9 +10,9 @@ class LogSettings(ConfigurationModelABC):
self,
src: Optional[dict] = None,
):
ConfigurationModelABC.__init__(self, src)
ConfigurationModelABC.__init__(self, src, "LOG")
self.option("path", str, default="logs")
self.option("filename", str, default="app.log")
self.option("console_level", LogLevel, default=LogLevel.info)
self.option("console", LogLevel, default=LogLevel.info)
self.option("level", LogLevel, default=LogLevel.info)

View File

@@ -3,13 +3,12 @@ import traceback
from datetime import datetime
from cpl.core.console import Console
from cpl.core.log.log_level_enum import LogLevel
from cpl.core.log.log_level import LogLevel
from cpl.core.log.logger_abc import LoggerABC
from cpl.core.typing import Messages, Source
class Logger(LoggerABC):
_level = LogLevel.info
_levels = [x for x in LogLevel]
# ANSI color codes for different log levels
@@ -36,6 +35,13 @@ class Logger(LoggerABC):
self._file_prefix = file_prefix
self._create_log_dir()
@property
def _settings(self):
from cpl.core.configuration.configuration import Configuration
from cpl.core.log.log_settings import LogSettings
return Configuration.get(LogSettings)
@property
def log_file(self):
return f"logs/{self._file_prefix}_{datetime.now().strftime('%Y-%m-%d')}.log"
@@ -65,23 +71,32 @@ class Logger(LoggerABC):
f"{log_file.split('.log')[0]}_{datetime.now().strftime('%H-%M-%S')}.log",
)
def _write_log_to_file(self, content: str):
def _should_log(self, input_level: LogLevel, settings_level: LogLevel) -> bool:
return self._levels.index(input_level) >= self._levels.index(settings_level)
def _write_log_to_file(self, level: LogLevel, content: str):
if not self._should_log(level, self._settings.level):
return
file = self.log_file
self._ensure_file_size(file)
with open(file, "a") as log_file:
log_file.write(content + "\n")
log_file.close()
def _write_to_console(self, level: LogLevel, content: str):
if not self._should_log(level, self._settings.console):
return
Console.write_line(f"{self._COLORS.get(level, '\033[0m')}{content}\033[0m")
def _log(self, level: LogLevel, *messages: Messages):
try:
if self._levels.index(level) < self._levels.index(self._level):
return
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
formatted_message = self._format_message(level.value, timestamp, *messages)
self._write_log_to_file(formatted_message)
Console.write_line(f"{self._COLORS.get(level, '\033[0m')}{formatted_message}\033[0m")
self._write_log_to_file(level, formatted_message)
self._write_to_console(level, formatted_message)
except Exception as e:
print(f"Error while logging: {e} -> {traceback.format_exc()}")

View File

@@ -1,6 +1,6 @@
from abc import abstractmethod, ABC
from cpl.core.log.log_level_enum import LogLevel
from cpl.core.log.log_level import LogLevel
from cpl.core.typing import Messages

View File

@@ -3,6 +3,7 @@ from typing import Type, Any
from cpl.core.typing import T
def _cast_enum(value: str, enum_type: Type[Enum]) -> Enum:
try:
return enum_type(value)
@@ -36,6 +37,7 @@ def _cast_enum(value: str, enum_type: Type[Enum]) -> Enum:
raise ValueError(f"Cannot cast value '{value}' to enum '{enum_type.__name__}'")
def cast(value: Any, cast_type: Type[T], list_delimiter: str = ",") -> T:
"""
Cast a value to a specified type.
@@ -44,12 +46,12 @@ def cast(value: Any, cast_type: Type[T], list_delimiter: str = ",") -> T:
:param str list_delimiter: The delimiter to split the value into a list. Defaults to ",".
:return:
"""
if value is None:
return None
if cast_type == bool:
return value.lower() in ["true", "1", "yes", "on"]
if issubclass(cast_type, Enum):
return _cast_enum(value, cast_type)
if (cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__) == list:
if not (value.startswith("[") and value.endswith("]")) and list_delimiter not in value:
raise ValueError("List values must be enclosed in square brackets or use a delimiter.")
@@ -61,4 +63,7 @@ def cast(value: Any, cast_type: Type[T], list_delimiter: str = ",") -> T:
subtype = cast_type.__args__[0] if hasattr(cast_type, "__args__") else None
return [subtype(item) if subtype is not None else item for item in value]
if isinstance(cast_type, type) and issubclass(cast_type, Enum):
return _cast_enum(value, cast_type)
return cast_type(value)

View File

@@ -38,6 +38,9 @@ def get_value(
return value
try:
cast(cast_type, value, list_delimiter)
cast(value, cast_type, list_delimiter)
except (ValueError, TypeError):
from cpl.core.log import Logger
Logger(__name__).debug(f"Failed to cast value '{value}' to type '{cast_type.__name__}'")
return default

View File

@@ -18,10 +18,35 @@ class String:
String converted to CamelCase
"""
s = String.to_snake_case(s)
words = s.split('_')
return words[0] + ''.join(word.title() for word in words[1:])
# return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower()
parts = re.split(r"[^a-zA-Z0-9]+", s.strip())
parts = [p for p in parts if p]
if not parts:
return ""
return parts[0].lower() + "".join(word.capitalize() for word in parts[1:])
@staticmethod
def to_pascal_case(s: str) -> str:
r"""Converts string to pascal case
Parameter:
chars: :class:`str`
String to convert
Returns:
String converted to PascalCase
"""
parts = re.split(r"[^a-zA-Z0-9]+", s.strip())
parts = [p for p in parts if p]
if not parts:
return ""
return "".join(word.capitalize() for word in parts)
@staticmethod
def to_snake_case(chars: str) -> str:

View File

@@ -4,10 +4,9 @@ from enum import Enum
from types import NoneType
from typing import Generic, Optional, Union, Type, List, Any
from cpl.core.ctx import get_user
from cpl.core.typing import T, Id
from cpl.core.utils.string import String
from cpl.core.utils.get_value import get_value
from cpl.core.utils.string import String
from cpl.database.abc.db_context_abc import DBContextABC
from cpl.database.const import DATETIME_FORMAT
from cpl.database.db_logger import DBLogger
@@ -869,6 +868,8 @@ class DataAccessObjectABC(ABC, Generic[T_DBM]):
async def _get_editor_id(obj: T_DBM):
editor_id = obj.editor_id
if editor_id is None:
from cpl.core.ctx.user_context import get_user
user = get_user()
if user is not None:
editor_id = user.id

View File

@@ -77,7 +77,7 @@ class ServiceProvider(ServiceProviderABC):
return implementations
def _build_by_signature(self, sig: Signature, origin_service_type: type) -> list[R]:
def _build_by_signature(self, sig: Signature, origin_service_type: type=None) -> list[R]:
params = []
for param in sig.parameters.items():
parameter = param[1]

View File

@@ -1,6 +1,6 @@
import functools
from abc import abstractmethod, ABC
from inspect import Signature, signature
from inspect import Signature, signature, iscoroutinefunction
from typing import Optional, Type
from cpl.core.typing import T, R
@@ -36,7 +36,7 @@ class ServiceProviderABC(ABC):
return cls._provider.get_services(instance_type, *args, **kwargs)
@abstractmethod
def _build_by_signature(self, sig: Signature, origin_service_type: type) -> list[R]: ...
def _build_by_signature(self, sig: Signature, origin_service_type: type=None) -> list[R]: ...
@abstractmethod
def _build_service(self, service_type: type, *args, **kwargs) -> object:
@@ -115,11 +115,13 @@ class ServiceProviderABC(ABC):
return functools.partial(cls.inject)
@functools.wraps(f)
def inner(*args, **kwargs):
async def inner(*args, **kwargs):
if cls._provider is None:
raise Exception(f"{cls.__name__} not build!")
injection = [x for x in cls._provider._build_by_signature(signature(f)) if x is not None]
if iscoroutinefunction(f):
return await f(*args, *injection, **kwargs)
return f(*args, *injection, **kwargs)
return inner

View File

@@ -6,8 +6,8 @@ from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
class EMailClientSettings(ConfigurationModelABC):
def __init__(
self,
src: Optional[dict] = None,
self,
src: Optional[dict] = None,
):
ConfigurationModelABC.__init__(self, src, "EMAIL")

View File

@@ -0,0 +1,23 @@
from starlette.responses import JSONResponse
from cpl.api.web_app import WebApp
from cpl.application import ApplicationBuilder
from custom.api.src.service import PingService
def main():
builder = ApplicationBuilder[WebApp](WebApp)
builder.services.add_logging()
builder.services.add_transient(PingService)
app = builder.build()
app.with_route(path="/route1", fn=lambda r: JSONResponse("route1"), method="GET")
app.with_routes_directory("routes")
app.with_logging()
app.run()
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,13 @@
from urllib.request import Request
from starlette.responses import JSONResponse
from cpl.api.router import Router
from cpl.core.log import Logger
from custom.api.src.service import PingService
@Router.get(f"/ping")
async def ping(r: Request, ping: PingService, logger: Logger):
logger.info(f"Ping: {ping}")
return JSONResponse(ping.ping(r))

View File

@@ -0,0 +1,4 @@
class PingService:
def ping(self, r):
return "pong"