Renamed project dirs
All checks were successful
Test before pr merge / test-lint (pull_request) Successful in 6s
All checks were successful
Test before pr merge / test-lint (pull_request) Successful in 6s
This commit is contained in:
6
src/api/cpl/api/__init__.py
Normal file
6
src/api/cpl/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .error import APIError, AlreadyExists, EndpointNotImplemented, Forbidden, NotFound, Unauthorized
|
||||
from .logger import APILogger
|
||||
from .settings import ApiSettings
|
||||
from .api_module import ApiModule
|
||||
|
||||
__version__ = "1.0.0"
|
||||
1
src/api/cpl/api/abc/__init__.py
Normal file
1
src/api/cpl/api/abc/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .asgi_middleware_abc import ASGIMiddleware
|
||||
15
src/api/cpl/api/abc/asgi_middleware_abc.py
Normal file
15
src/api/cpl/api/abc/asgi_middleware_abc.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from starlette.types import Scope, Receive, Send
|
||||
|
||||
|
||||
class ASGIMiddleware(ABC):
|
||||
@abstractmethod
|
||||
def __init__(self, app):
|
||||
self._app = app
|
||||
|
||||
def _call_next(self, scope: Scope, receive: Receive, send: Send):
|
||||
return self._app(scope, receive, send)
|
||||
|
||||
@abstractmethod
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send): ...
|
||||
45
src/api/cpl/api/abc/web_app_abc.py
Normal file
45
src/api/cpl/api/abc/web_app_abc.py
Normal 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: ...
|
||||
22
src/api/cpl/api/api_module.py
Normal file
22
src/api/cpl/api/api_module.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from cpl.api import ApiSettings
|
||||
from cpl.api.registry.policy import PolicyRegistry
|
||||
from cpl.api.registry.route import RouteRegistry
|
||||
from cpl.auth.auth_module import AuthModule
|
||||
from cpl.auth.permission.permission_module import PermissionsModule
|
||||
from cpl.database.database_module import DatabaseModule
|
||||
from cpl.dependency import ServiceCollection
|
||||
from cpl.dependency.module.module import Module
|
||||
|
||||
|
||||
class ApiModule(Module):
|
||||
config = [ApiSettings]
|
||||
singleton = [
|
||||
PolicyRegistry,
|
||||
RouteRegistry,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def register(collection: ServiceCollection):
|
||||
collection.add_module(DatabaseModule)
|
||||
collection.add_module(AuthModule)
|
||||
collection.add_module(PermissionsModule)
|
||||
1
src/api/cpl/api/application/__init__.py
Normal file
1
src/api/cpl/api/application/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .web_app import WebApp
|
||||
275
src/api/cpl/api/application/web_app.py
Normal file
275
src/api/cpl/api/application/web_app.py
Normal file
@@ -0,0 +1,275 @@
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Mapping, Any, Self
|
||||
|
||||
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.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
|
||||
from cpl.api.middleware.authentication import AuthenticationMiddleware
|
||||
from cpl.api.middleware.authorization import AuthorizationMiddleware
|
||||
from cpl.api.middleware.logging import LoggingMiddleware
|
||||
from cpl.api.middleware.request import RequestMiddleware
|
||||
from cpl.api.model.api_route import ApiRoute
|
||||
from cpl.api.model.policy import Policy
|
||||
from cpl.api.model.validation_match import ValidationMatch
|
||||
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, TEndpoint, PolicyInput
|
||||
from cpl.auth.auth_module import AuthModule
|
||||
from cpl.auth.permission.permission_module import PermissionsModule
|
||||
from cpl.core.configuration.configuration import Configuration
|
||||
from cpl.dependency.inject import inject
|
||||
from cpl.dependency.service_provider import ServiceProvider
|
||||
from cpl.dependency.typing import Modules
|
||||
|
||||
|
||||
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)
|
||||
|
||||
self._api_settings = Configuration.get(ApiSettings)
|
||||
self._policies = services.get_service(PolicyRegistry)
|
||||
self._routes = services.get_service(RouteRegistry)
|
||||
|
||||
self._middleware: list[Middleware] = []
|
||||
self._exception_handlers: Mapping[Any, ExceptionHandler] = {
|
||||
Exception: self._handle_exception,
|
||||
APIError: self._handle_exception,
|
||||
}
|
||||
|
||||
self.with_middleware(RequestMiddleware)
|
||||
self.with_middleware(LoggingMiddleware)
|
||||
|
||||
async def _handle_exception(self, request: Request, exc: Exception):
|
||||
if isinstance(exc, APIError):
|
||||
self._logger.error(exc)
|
||||
return JSONResponse({"error": str(exc)}, status_code=exc.status_code)
|
||||
|
||||
if hasattr(request.state, "request_id"):
|
||||
self._logger.error(f"Request {request.state.request_id}", exc)
|
||||
else:
|
||||
self._logger.error("Request unknown", exc)
|
||||
|
||||
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 == "":
|
||||
self._logger.warning("No allowed origins specified, allowing all origins")
|
||||
return ["*"]
|
||||
|
||||
self._logger.debug(f"Allowed origins: {origins}")
|
||||
return origins.split(",")
|
||||
|
||||
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"
|
||||
|
||||
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_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],
|
||||
method: HTTPMethods,
|
||||
authentication: bool = False,
|
||||
roles: list[str | Enum] = None,
|
||||
permissions: list[str | Enum] = None,
|
||||
policies: list[str] = None,
|
||||
match: ValidationMatch = None,
|
||||
) -> Self:
|
||||
self._check_for_app()
|
||||
assert self._routes is not None, "routes must not be None"
|
||||
assert all(isinstance(route, ApiRoute) for route in routes), "all routes must be of type ApiRoute"
|
||||
for route in routes:
|
||||
self.with_route(
|
||||
route.path,
|
||||
route.fn,
|
||||
method,
|
||||
authentication,
|
||||
roles,
|
||||
permissions,
|
||||
policies,
|
||||
match,
|
||||
)
|
||||
return 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:
|
||||
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",
|
||||
"HEAD",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
], "method must be a valid HTTP method"
|
||||
|
||||
Router.route(path, method, registry=self._routes)(fn)
|
||||
|
||||
if authentication:
|
||||
Router.authenticate()(fn)
|
||||
|
||||
if roles or permissions or policies:
|
||||
Router.authorize(roles, permissions, policies, match)(fn)
|
||||
|
||||
return self
|
||||
|
||||
def with_websocket(
|
||||
self,
|
||||
path: str,
|
||||
fn: TEndpoint,
|
||||
authentication: bool = False,
|
||||
roles: list[str | Enum] = None,
|
||||
permissions: list[str | Enum] = None,
|
||||
policies: list[str] = None,
|
||||
match: ValidationMatch = None,
|
||||
) -> Self:
|
||||
self._check_for_app()
|
||||
assert path is not None, "path must not be None"
|
||||
assert fn is not None, "fn must not be None"
|
||||
|
||||
Router.websocket(path, registry=self._routes)(fn)
|
||||
|
||||
if authentication:
|
||||
Router.authenticate()(fn)
|
||||
|
||||
if roles or permissions or policies:
|
||||
Router.authorize(roles, permissions, policies, match)(fn)
|
||||
|
||||
return self
|
||||
|
||||
def with_middleware(self, middleware: PartialMiddleware) -> Self:
|
||||
self._check_for_app()
|
||||
|
||||
if isinstance(middleware, Middleware):
|
||||
self._middleware.append(inject(middleware))
|
||||
elif callable(middleware):
|
||||
self._middleware.append(Middleware(inject(middleware)))
|
||||
else:
|
||||
raise ValueError("middleware must be of type starlette.middleware.Middleware or a callable")
|
||||
|
||||
return self
|
||||
|
||||
def with_authentication(self) -> Self:
|
||||
self.with_middleware(AuthenticationMiddleware)
|
||||
return self
|
||||
|
||||
def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self:
|
||||
self._check_for_app()
|
||||
if policies:
|
||||
_policies = []
|
||||
|
||||
if not isinstance(policies, list):
|
||||
policies = list(policies)
|
||||
|
||||
for i, policy in enumerate(policies):
|
||||
if isinstance(policy, dict):
|
||||
for name, resolver in policy.items():
|
||||
if not isinstance(name, str):
|
||||
self._logger.warning(f"Skipping policy at index {i}, name must be a string")
|
||||
continue
|
||||
|
||||
if not callable(resolver):
|
||||
self._logger.warning(f"Skipping policy {name}, resolver must be callable")
|
||||
continue
|
||||
|
||||
_policies.append(Policy(name, resolver))
|
||||
continue
|
||||
|
||||
_policies.append(policy)
|
||||
|
||||
self._policies.extend(_policies)
|
||||
|
||||
self.with_middleware(AuthorizationMiddleware)
|
||||
return self
|
||||
|
||||
async def _log_before_startup(self):
|
||||
self._logger.info(f"Start API on {self._api_settings.host}:{self._api_settings.port}")
|
||||
|
||||
async def main(self):
|
||||
self._logger.debug(f"Preparing API")
|
||||
self._validate_policies()
|
||||
|
||||
if self._app is None:
|
||||
routes = [route.to_starlette(inject) for route in self._routes.all()]
|
||||
|
||||
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
|
||||
|
||||
await self._log_before_startup()
|
||||
|
||||
config = uvicorn.Config(
|
||||
app, host=self._api_settings.host, port=self._api_settings.port, log_config=None, loop="asyncio"
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
self._logger.info("Shutdown API")
|
||||
46
src/api/cpl/api/error.py
Normal file
46
src/api/cpl/api/error.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from http.client import HTTPException
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import Scope, Receive, Send
|
||||
|
||||
|
||||
class APIError(HTTPException):
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, message: str = ""):
|
||||
HTTPException.__init__(self, self.status_code, message)
|
||||
self._message = message
|
||||
|
||||
@property
|
||||
def error_message(self) -> str:
|
||||
if self._message:
|
||||
return f"{type(self).__name__}: {self._message}"
|
||||
|
||||
return f"{type(self).__name__}"
|
||||
|
||||
async def asgi_response(self, scope: Scope, receive: Receive, send: Send):
|
||||
r = JSONResponse({"error": self.error_message}, status_code=self.status_code)
|
||||
return await r(scope, receive, send)
|
||||
|
||||
def response(self):
|
||||
return JSONResponse({"error": self.error_message}, status_code=self.status_code)
|
||||
|
||||
|
||||
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
|
||||
7
src/api/cpl/api/logger.py
Normal file
7
src/api/cpl/api/logger.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from cpl.core.log.wrapped_logger import WrappedLogger
|
||||
|
||||
|
||||
class APILogger(WrappedLogger):
|
||||
|
||||
def __init__(self):
|
||||
WrappedLogger.__init__(self, "api")
|
||||
4
src/api/cpl/api/middleware/__init__.py
Normal file
4
src/api/cpl/api/middleware/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .authentication import AuthenticationMiddleware
|
||||
from .authorization import AuthorizationMiddleware
|
||||
from .logging import LoggingMiddleware
|
||||
from .request import RequestMiddleware
|
||||
93
src/api/cpl/api/middleware/authentication.py
Normal file
93
src/api/cpl/api/middleware/authentication.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from keycloak import KeycloakAuthenticationError
|
||||
from starlette.types import Scope, Receive, Send
|
||||
|
||||
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
|
||||
from cpl.api.error import Unauthorized
|
||||
from cpl.api.logger import APILogger
|
||||
from cpl.api.middleware.request import get_request
|
||||
from cpl.api.router import Router
|
||||
from cpl.auth.keycloak import KeycloakClient
|
||||
from cpl.auth.schema import UserDao, User
|
||||
from cpl.core.ctx import set_user
|
||||
|
||||
|
||||
class AuthenticationMiddleware(ASGIMiddleware):
|
||||
|
||||
def __init__(self, app, logger: APILogger, keycloak: KeycloakClient, user_dao: UserDao):
|
||||
ASGIMiddleware.__init__(self, app)
|
||||
|
||||
self._logger = logger
|
||||
|
||||
self._keycloak = keycloak
|
||||
self._user_dao = user_dao
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
request = get_request()
|
||||
url = request.url.path
|
||||
|
||||
if url not in Router.get_auth_required_routes():
|
||||
self._logger.trace(f"No authentication required for {url}")
|
||||
return await self._app(scope, receive, send)
|
||||
|
||||
user = getattr(request.state, "user", None)
|
||||
if not user or user.deleted:
|
||||
self._logger.debug(f"Unauthorized access to {url}, user missing or deleted")
|
||||
return await Unauthorized("Unauthorized").asgi_response(scope, receive, send)
|
||||
|
||||
return await self._call_next(scope, receive, send)
|
||||
|
||||
async def _old_call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
request = get_request()
|
||||
url = request.url.path
|
||||
|
||||
if url not in Router.get_auth_required_routes():
|
||||
self._logger.trace(f"No authentication required for {url}")
|
||||
return await self._app(scope, receive, send)
|
||||
|
||||
if not request.headers.get("Authorization"):
|
||||
self._logger.debug(f"Unauthorized access to {url}, missing Authorization header")
|
||||
return await Unauthorized(f"Missing header Authorization").asgi_response(scope, receive, send)
|
||||
|
||||
auth_header = request.headers.get("Authorization", None)
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return await Unauthorized("Invalid Authorization header").asgi_response(scope, receive, send)
|
||||
|
||||
token = auth_header.split("Bearer ")[1]
|
||||
if not await self._verify_login(token):
|
||||
self._logger.debug(f"Unauthorized access to {url}, invalid token")
|
||||
return await Unauthorized("Invalid token").asgi_response(scope, receive, send)
|
||||
|
||||
# check user exists in db, if not create
|
||||
keycloak_id = self._keycloak.get_user_id(token)
|
||||
if keycloak_id is None:
|
||||
return await Unauthorized("Failed to get user id from token").asgi_response(scope, receive, send)
|
||||
|
||||
user = await self._get_or_crate_user(keycloak_id)
|
||||
if user.deleted:
|
||||
self._logger.debug(f"Unauthorized access to {url}, user is deleted")
|
||||
return await Unauthorized("User is deleted").asgi_response(scope, receive, send)
|
||||
|
||||
request.state.user = user
|
||||
set_user(user)
|
||||
|
||||
return await self._call_next(scope, receive, send)
|
||||
|
||||
async def _get_or_crate_user(self, keycloak_id: str) -> User:
|
||||
existing = await self._user_dao.find_by_keycloak_id(keycloak_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
user = User(0, keycloak_id)
|
||||
uid = await self._user_dao.create(user)
|
||||
return await self._user_dao.get_by_id(uid)
|
||||
|
||||
async def _verify_login(self, token: str) -> bool:
|
||||
try:
|
||||
token_info = self._keycloak.introspect(token)
|
||||
return token_info.get("active", False)
|
||||
except KeycloakAuthenticationError as e:
|
||||
self._logger.debug(f"Keycloak authentication error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self._logger.error(f"Unexpected error during token verification: {e}")
|
||||
return False
|
||||
71
src/api/cpl/api/middleware/authorization.py
Normal file
71
src/api/cpl/api/middleware/authorization.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from starlette.types import Scope, Receive, Send
|
||||
|
||||
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
|
||||
from cpl.api.error import Unauthorized, Forbidden
|
||||
from cpl.api.logger import APILogger
|
||||
from cpl.api.middleware.request import get_request
|
||||
from cpl.api.model.validation_match import ValidationMatch
|
||||
from cpl.api.registry.policy import PolicyRegistry
|
||||
from cpl.api.router import Router
|
||||
from cpl.auth.schema._administration.user_dao import UserDao
|
||||
from cpl.core.ctx.user_context import get_user
|
||||
|
||||
|
||||
class AuthorizationMiddleware(ASGIMiddleware):
|
||||
|
||||
def __init__(self, app, logger: APILogger, policies: PolicyRegistry, user_dao: UserDao):
|
||||
ASGIMiddleware.__init__(self, app)
|
||||
|
||||
self._logger = logger
|
||||
|
||||
self._policies = policies
|
||||
self._user_dao = user_dao
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
request = get_request()
|
||||
url = request.url.path
|
||||
|
||||
if url not in Router.get_authorization_rules_paths():
|
||||
self._logger.trace(f"No authorization required for {url}")
|
||||
return await self._app(scope, receive, send)
|
||||
|
||||
user = get_user()
|
||||
if not user:
|
||||
return await Unauthorized(f"Unknown user").asgi_response(scope, receive, send)
|
||||
|
||||
roles = await user.roles
|
||||
request.state.roles = roles
|
||||
role_names = [r.name for r in roles]
|
||||
|
||||
perms = await user.permissions
|
||||
request.state.permissions = perms
|
||||
perm_names = [p.name for p in perms]
|
||||
|
||||
for rule in Router.get_authorization_rules():
|
||||
match = rule["match"]
|
||||
if rule["roles"]:
|
||||
if match == ValidationMatch.all and not all(r in role_names for r in rule["roles"]):
|
||||
return await Forbidden(f"missing roles: {rule["roles"]}").asgi_response(scope, receive, send)
|
||||
if match == ValidationMatch.any and not any(r in role_names for r in rule["roles"]):
|
||||
return await Forbidden(f"missing roles: {rule["roles"]}").asgi_response(scope, receive, send)
|
||||
|
||||
if rule["permissions"]:
|
||||
if match == ValidationMatch.all and not all(p in perm_names for p in rule["permissions"]):
|
||||
return await Forbidden(f"missing permissions: {rule["permissions"]}").asgi_response(
|
||||
scope, receive, send
|
||||
)
|
||||
if match == ValidationMatch.any and not any(p in perm_names for p in rule["permissions"]):
|
||||
return await Forbidden(f"missing permissions: {rule["permissions"]}").asgi_response(
|
||||
scope, receive, send
|
||||
)
|
||||
|
||||
for policy_name in rule["policies"]:
|
||||
policy = self._policies.get(policy_name)
|
||||
if not policy:
|
||||
self._logger.warning(f"Authorization policy '{policy_name}' not found")
|
||||
continue
|
||||
|
||||
if not await policy.resolve(user):
|
||||
return await Forbidden(f"policy {policy.name} failed").asgi_response(scope, receive, send)
|
||||
|
||||
return await self._call_next(scope, receive, send)
|
||||
85
src/api/cpl/api/middleware/logging.py
Normal file
85
src/api/cpl/api/middleware/logging.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import time
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
|
||||
from cpl.api.logger import APILogger
|
||||
from cpl.api.middleware.request import get_request
|
||||
|
||||
|
||||
class LoggingMiddleware(ASGIMiddleware):
|
||||
|
||||
def __init__(self, app, logger: APILogger):
|
||||
ASGIMiddleware.__init__(self, app)
|
||||
|
||||
self._logger = logger
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
if scope["type"] != "http":
|
||||
await self._call_next(scope, receive, send)
|
||||
return
|
||||
|
||||
request = get_request()
|
||||
await self._log_request(request)
|
||||
start_time = time.time()
|
||||
|
||||
response_body = b""
|
||||
status_code = 500
|
||||
|
||||
async def send_wrapper(message):
|
||||
nonlocal response_body, status_code
|
||||
if message["type"] == "http.response.start":
|
||||
status_code = message["status"]
|
||||
if message["type"] == "http.response.body":
|
||||
response_body += message.get("body", b"")
|
||||
await send(message)
|
||||
|
||||
await self._call_next(scope, receive, send_wrapper)
|
||||
|
||||
duration = (time.time() - start_time) * 1000
|
||||
await self._log_after_request(request, status_code, duration)
|
||||
|
||||
@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}
|
||||
|
||||
async def _log_request(self, request: Request):
|
||||
self._logger.debug(
|
||||
f"Request {getattr(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": self._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
|
||||
),
|
||||
}
|
||||
|
||||
self._logger.trace(f"Request {getattr(request.state, 'request_id', '-')}: {request_info}")
|
||||
|
||||
async def _log_after_request(self, request: Request, status_code: int, duration: float):
|
||||
self._logger.info(
|
||||
f"Request finished {getattr(request.state, 'request_id', '-')}: {status_code}-{request.method}@{request.url.path} from {request.client.host} in {duration:.2f}ms"
|
||||
)
|
||||
98
src/api/cpl/api/middleware/request.py
Normal file
98
src/api/cpl/api/middleware/request.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import time
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.types import Scope, Receive, Send
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
|
||||
from cpl.api.logger import APILogger
|
||||
from cpl.api.typing import TRequest
|
||||
from cpl.auth.keycloak.keycloak_client import KeycloakClient
|
||||
from cpl.auth.schema import User
|
||||
from cpl.auth.schema._administration.user_dao import UserDao
|
||||
from cpl.core.ctx import set_user
|
||||
from cpl.dependency.inject import inject
|
||||
from cpl.dependency.service_provider import ServiceProvider
|
||||
|
||||
_request_context: ContextVar[Union[TRequest, None]] = ContextVar("request", default=None)
|
||||
|
||||
|
||||
class RequestMiddleware(ASGIMiddleware):
|
||||
|
||||
def __init__(self, app, provider: ServiceProvider, logger: APILogger, keycloak: KeycloakClient, user_dao: UserDao):
|
||||
ASGIMiddleware.__init__(self, app)
|
||||
|
||||
self._provider = provider
|
||||
self._logger = logger
|
||||
|
||||
self._keycloak = keycloak
|
||||
self._user_dao = user_dao
|
||||
|
||||
self._ctx_token = None
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
request = Request(scope, receive, send) if scope["type"] != "websocket" else WebSocket(scope, receive, send)
|
||||
await self.set_request_data(request)
|
||||
|
||||
try:
|
||||
await self._try_set_user(request)
|
||||
with self._provider.create_scope():
|
||||
inject(await self._app(scope, receive, send))
|
||||
finally:
|
||||
await self.clean_request_data()
|
||||
|
||||
async def set_request_data(self, request: TRequest):
|
||||
request.state.request_id = uuid4()
|
||||
request.state.start_time = time.time()
|
||||
self._logger.trace(f"Set new current request: {request.state.request_id}")
|
||||
|
||||
self._ctx_token = _request_context.set(request)
|
||||
|
||||
async def clean_request_data(self):
|
||||
request = get_request()
|
||||
if request is None:
|
||||
return
|
||||
|
||||
if self._ctx_token is None:
|
||||
return
|
||||
|
||||
self._logger.trace(f"Clearing current request: {request.state.request_id}")
|
||||
_request_context.reset(self._ctx_token)
|
||||
|
||||
async def _try_set_user(self, request: Request):
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return
|
||||
|
||||
token = auth_header.split("Bearer ")[1]
|
||||
try:
|
||||
token_info = self._keycloak.introspect(token)
|
||||
if not token_info.get("active", False):
|
||||
return
|
||||
|
||||
keycloak_id = self._keycloak.get_user_id(token)
|
||||
if not keycloak_id:
|
||||
return
|
||||
|
||||
user = await self._user_dao.find_by_keycloak_id(keycloak_id)
|
||||
if not user:
|
||||
user = User(0, keycloak_id)
|
||||
uid = await self._user_dao.create(user)
|
||||
user = await self._user_dao.get_by_id(uid)
|
||||
|
||||
if user.deleted:
|
||||
return
|
||||
|
||||
request.state.user = user
|
||||
set_user(user)
|
||||
self._logger.trace(f"User {user.id} bound to request {request.state.request_id}")
|
||||
|
||||
except Exception as e:
|
||||
self._logger.debug(f"Silent user binding failed: {e}")
|
||||
|
||||
|
||||
def get_request() -> Optional[TRequest]:
|
||||
return _request_context.get()
|
||||
3
src/api/cpl/api/model/__init__.py
Normal file
3
src/api/cpl/api/model/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .api_route import ApiRoute
|
||||
from .policy import Policy
|
||||
from .validation_match import ValidationMatch
|
||||
43
src/api/cpl/api/model/api_route.py
Normal file
43
src/api/cpl/api/model/api_route.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from typing import Callable
|
||||
|
||||
from starlette.routing import Route
|
||||
|
||||
from cpl.api.typing import HTTPMethods
|
||||
|
||||
|
||||
class ApiRoute:
|
||||
|
||||
def __init__(self, path: str, fn: Callable, method: HTTPMethods, **kwargs):
|
||||
self._path = path
|
||||
self._fn = fn
|
||||
self._method = method
|
||||
|
||||
self._kwargs = kwargs
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._fn.__name__
|
||||
|
||||
@property
|
||||
def fn(self) -> Callable:
|
||||
return self._fn
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def method(self) -> HTTPMethods:
|
||||
return self._method
|
||||
|
||||
@property
|
||||
def kwargs(self) -> dict:
|
||||
return self._kwargs
|
||||
|
||||
def to_starlette(self, wrap_endpoint: Callable = None) -> Route:
|
||||
return Route(
|
||||
self._path,
|
||||
self._fn if not wrap_endpoint else wrap_endpoint(self._fn),
|
||||
methods=[self._method],
|
||||
**self._kwargs,
|
||||
)
|
||||
34
src/api/cpl/api/model/policy.py
Normal file
34
src/api/cpl/api/model/policy.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from asyncio import iscoroutinefunction
|
||||
from typing import Optional
|
||||
|
||||
from cpl.api.typing import PolicyResolver
|
||||
from cpl.core.ctx import get_user
|
||||
|
||||
|
||||
class Policy:
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
resolver: PolicyResolver = None,
|
||||
):
|
||||
self._name = name
|
||||
self._resolver: Optional[PolicyResolver] = resolver
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def resolvers(self) -> PolicyResolver:
|
||||
return self._resolver
|
||||
|
||||
async def resolve(self, *args, **kwargs) -> bool:
|
||||
if not self._resolver:
|
||||
return True
|
||||
|
||||
if callable(self._resolver):
|
||||
if iscoroutinefunction(self._resolver):
|
||||
return await self._resolver(get_user())
|
||||
|
||||
return self._resolver(get_user())
|
||||
return False
|
||||
6
src/api/cpl/api/model/validation_match.py
Normal file
6
src/api/cpl/api/model/validation_match.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ValidationMatch(Enum):
|
||||
any = "any"
|
||||
all = "all"
|
||||
31
src/api/cpl/api/model/websocket_route.py
Normal file
31
src/api/cpl/api/model/websocket_route.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from typing import Callable
|
||||
|
||||
import starlette.routing
|
||||
|
||||
|
||||
class WebSocketRoute:
|
||||
|
||||
def __init__(self, path: str, fn: Callable, **kwargs):
|
||||
self._path = path
|
||||
self._fn = fn
|
||||
|
||||
self._kwargs = kwargs
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._fn.__name__
|
||||
|
||||
@property
|
||||
def fn(self) -> Callable:
|
||||
return self._fn
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def kwargs(self) -> dict:
|
||||
return self._kwargs
|
||||
|
||||
def to_starlette(self, *args) -> starlette.routing.WebSocketRoute:
|
||||
return starlette.routing.WebSocketRoute(self._path, self._fn)
|
||||
2
src/api/cpl/api/registry/__init__.py
Normal file
2
src/api/cpl/api/registry/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .policy import PolicyRegistry
|
||||
from .route import RouteRegistry
|
||||
28
src/api/cpl/api/registry/policy.py
Normal file
28
src/api/cpl/api/registry/policy.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import Optional
|
||||
|
||||
from cpl.api.model.policy import Policy
|
||||
from cpl.core.abc.registry_abc import RegistryABC
|
||||
|
||||
|
||||
class PolicyRegistry(RegistryABC):
|
||||
|
||||
def __init__(self):
|
||||
RegistryABC.__init__(self)
|
||||
|
||||
def extend(self, items: list[Policy]):
|
||||
for policy in items:
|
||||
self.add(policy)
|
||||
|
||||
def add(self, item: Policy):
|
||||
assert isinstance(item, Policy), "policy must be an instance of Policy"
|
||||
|
||||
if item.name in self._items:
|
||||
raise ValueError(f"Policy {item.name} is already registered")
|
||||
|
||||
self._items[item.name] = item
|
||||
|
||||
def get(self, key: str) -> Optional[Policy]:
|
||||
return self._items.get(key)
|
||||
|
||||
def all(self) -> list[Policy]:
|
||||
return list(self._items.values())
|
||||
35
src/api/cpl/api/registry/route.py
Normal file
35
src/api/cpl/api/registry/route.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from cpl.api.model.api_route import ApiRoute
|
||||
from cpl.api.model.websocket_route import WebSocketRoute
|
||||
from cpl.core.abc.registry_abc import RegistryABC
|
||||
|
||||
TRoute = Union[ApiRoute, WebSocketRoute]
|
||||
|
||||
|
||||
class RouteRegistry(RegistryABC):
|
||||
|
||||
def __init__(self):
|
||||
RegistryABC.__init__(self)
|
||||
|
||||
def extend(self, items: list[TRoute]):
|
||||
for policy in items:
|
||||
self.add(policy)
|
||||
|
||||
def add(self, item: TRoute):
|
||||
assert isinstance(item, (ApiRoute, WebSocketRoute)), "route must be an instance of ApiRoute"
|
||||
|
||||
if item.path in self._items:
|
||||
raise ValueError(f"ApiRoute {item.path} is already registered")
|
||||
|
||||
self._items[item.path] = item
|
||||
|
||||
def set(self, item: TRoute):
|
||||
assert isinstance(item, ApiRoute), "route must be an instance of ApiRoute"
|
||||
self._items[item.path] = item
|
||||
|
||||
def get(self, key: str) -> Optional[TRoute]:
|
||||
return self._items.get(key)
|
||||
|
||||
def all(self) -> list[TRoute]:
|
||||
return list(self._items.values())
|
||||
178
src/api/cpl/api/router.py
Normal file
178
src/api/cpl/api/router.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from enum import Enum
|
||||
|
||||
from cpl.api.model.validation_match import ValidationMatch
|
||||
from cpl.api.registry.route import RouteRegistry
|
||||
from cpl.api.typing import HTTPMethods
|
||||
from cpl.dependency import get_provider
|
||||
|
||||
|
||||
class Router:
|
||||
_auth_required: list[str] = []
|
||||
_authorization_rules: dict[str, dict] = {}
|
||||
|
||||
@classmethod
|
||||
def get_auth_required_routes(cls) -> list[str]:
|
||||
return cls._auth_required
|
||||
|
||||
@classmethod
|
||||
def get_authorization_rules_paths(cls) -> list[str]:
|
||||
return list(cls._authorization_rules.keys())
|
||||
|
||||
@classmethod
|
||||
def get_authorization_rules(cls) -> list[dict]:
|
||||
return list(cls._authorization_rules.values())
|
||||
|
||||
@classmethod
|
||||
def authenticate(cls):
|
||||
"""
|
||||
Decorator to mark a route as requiring authentication.
|
||||
Usage:
|
||||
@Route.authenticate()
|
||||
@Route.get("/example")
|
||||
async def example_endpoint(request: TRequest):
|
||||
...
|
||||
"""
|
||||
|
||||
def inner(fn):
|
||||
route_path = getattr(fn, "_route_path", None)
|
||||
if route_path and route_path not in cls._auth_required:
|
||||
cls._auth_required.append(route_path)
|
||||
return fn
|
||||
|
||||
return inner
|
||||
|
||||
@classmethod
|
||||
def authorize(
|
||||
cls,
|
||||
roles: list[str | Enum] = None,
|
||||
permissions: list[str | Enum] = None,
|
||||
policies: list[str] = None,
|
||||
match: ValidationMatch = None,
|
||||
):
|
||||
"""
|
||||
Decorator to mark a route as requiring authorization.
|
||||
Usage:
|
||||
@Route.authorize()
|
||||
@Route.get("/example")
|
||||
async def example_endpoint(request: TRequest):
|
||||
...
|
||||
"""
|
||||
assert roles is None or isinstance(roles, list), "roles must be a list of strings"
|
||||
assert permissions is None or isinstance(permissions, list), "permissions must be a list of strings"
|
||||
assert policies is None or isinstance(policies, list), "policies must be a list of strings"
|
||||
assert match is None or isinstance(match, ValidationMatch), "match must be an instance of ValidationMatch"
|
||||
|
||||
if roles is not None:
|
||||
for role in roles:
|
||||
if isinstance(role, Enum):
|
||||
roles[roles.index(role)] = role.value
|
||||
|
||||
if permissions is not None:
|
||||
for perm in permissions:
|
||||
if isinstance(perm, Enum):
|
||||
permissions[permissions.index(perm)] = perm.value
|
||||
|
||||
def inner(fn):
|
||||
path = getattr(fn, "_route_path", None)
|
||||
if not path:
|
||||
return fn
|
||||
|
||||
if path in cls._authorization_rules:
|
||||
raise ValueError(f"Route {path} is already registered for authorization")
|
||||
|
||||
cls._authorization_rules[path] = {
|
||||
"roles": roles or [],
|
||||
"permissions": permissions or [],
|
||||
"policies": policies or [],
|
||||
"match": match or ValidationMatch.all,
|
||||
}
|
||||
|
||||
return fn
|
||||
|
||||
return inner
|
||||
|
||||
@classmethod
|
||||
def websocket(cls, path: str, registry: RouteRegistry = None, **kwargs):
|
||||
from cpl.api.model.websocket_route import WebSocketRoute
|
||||
|
||||
if not registry:
|
||||
routes = get_provider().get_service(RouteRegistry)
|
||||
else:
|
||||
routes = registry
|
||||
|
||||
def inner(fn):
|
||||
routes.add(WebSocketRoute(path, fn, **kwargs))
|
||||
setattr(fn, "_route_path", path)
|
||||
return fn
|
||||
|
||||
return inner
|
||||
|
||||
@classmethod
|
||||
def route(cls, path: str, method: HTTPMethods, registry: RouteRegistry = None, **kwargs):
|
||||
from cpl.api.model.api_route import ApiRoute
|
||||
|
||||
if not registry:
|
||||
routes = get_provider().get_service(RouteRegistry)
|
||||
else:
|
||||
routes = registry
|
||||
|
||||
def inner(fn):
|
||||
routes.add(ApiRoute(path, fn, method, **kwargs))
|
||||
setattr(fn, "_route_path", path)
|
||||
return fn
|
||||
|
||||
return inner
|
||||
|
||||
@classmethod
|
||||
def get(cls, path: str, **kwargs):
|
||||
return cls.route(path, "GET", **kwargs)
|
||||
|
||||
@classmethod
|
||||
def head(cls, path: str, **kwargs):
|
||||
return cls.route(path, "HEAD", **kwargs)
|
||||
|
||||
@classmethod
|
||||
def post(cls, path: str, **kwargs):
|
||||
return cls.route(path, "POST", **kwargs)
|
||||
|
||||
@classmethod
|
||||
def put(cls, path: str, **kwargs):
|
||||
return cls.route(path, "PUT", **kwargs)
|
||||
|
||||
@classmethod
|
||||
def patch(cls, path: str, **kwargs):
|
||||
return cls.route(path, "PATCH", **kwargs)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, path: str, **kwargs):
|
||||
return cls.route(path, "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):
|
||||
...
|
||||
"""
|
||||
|
||||
from cpl.api.model.api_route import ApiRoute
|
||||
|
||||
routes = get_provider().get_service(RouteRegistry)
|
||||
|
||||
def inner(fn):
|
||||
path = getattr(fn, "_route_path", None)
|
||||
if path is None:
|
||||
raise ValueError("Cannot override a route that has not been registered yet")
|
||||
|
||||
route = routes.get(path)
|
||||
if route is None:
|
||||
raise ValueError(f"Cannot override a route that does not exist: {path}")
|
||||
|
||||
routes.add(ApiRoute(path, fn, route.method, **route.kwargs))
|
||||
setattr(fn, "_route_path", path)
|
||||
return fn
|
||||
|
||||
return inner
|
||||
13
src/api/cpl/api/settings.py
Normal file
13
src/api/cpl/api/settings.py
Normal 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):
|
||||
ConfigurationModelABC.__init__(self, src)
|
||||
|
||||
self.option("host", str, "0.0.0.0")
|
||||
self.option("port", int, 5000)
|
||||
self.option("allowed_origins", list[str])
|
||||
22
src/api/cpl/api/typing.py
Normal file
22
src/api/cpl/api/typing.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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
|
||||
|
||||
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
|
||||
from cpl.auth.schema import User
|
||||
|
||||
TRequest = Union[Request, WebSocket]
|
||||
TEndpoint = Callable[[TRequest, ...], Awaitable[Response]] | Callable[[TRequest, ...], Response]
|
||||
HTTPMethods = Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
PartialMiddleware = Union[
|
||||
ASGIMiddleware,
|
||||
Type[ASGIMiddleware],
|
||||
Middleware,
|
||||
Callable[[ASGIApp], ASGIApp],
|
||||
]
|
||||
PolicyResolver = Callable[[User], bool | Awaitable[bool]]
|
||||
PolicyInput = Union[dict[str, PolicyResolver], "Policy"]
|
||||
30
src/api/pyproject.toml
Normal file
30
src/api/pyproject.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=70.1.0", "wheel>=0.43.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "cpl-api"
|
||||
version = "2024.7.0"
|
||||
description = "CPL api"
|
||||
readme ="CPL api package"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" }
|
||||
]
|
||||
keywords = ["cpl", "api", "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"] }
|
||||
|
||||
|
||||
1
src/api/requirements.dev.txt
Normal file
1
src/api/requirements.dev.txt
Normal file
@@ -0,0 +1 @@
|
||||
black==25.1.0
|
||||
7
src/api/requirements.txt
Normal file
7
src/api/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
cpl-auth
|
||||
cpl-application
|
||||
cpl-core
|
||||
cpl-dependency
|
||||
starlette==0.48.0
|
||||
python-multipart==0.0.20
|
||||
uvicorn==0.35.0
|
||||
Reference in New Issue
Block a user