Compare commits
3 Commits
2025.09.19
...
2025.09.19
| Author | SHA1 | Date | |
|---|---|---|---|
| eceff6128b | |||
| 17dfb245bf | |||
| 4f698269b5 |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
uses: ./.gitea/workflows/package.yaml
|
uses: ./.gitea/workflows/package.yaml
|
||||||
needs: [ prepare, application, auth, core, dependency ]
|
needs: [ prepare, application, auth, core, dependency ]
|
||||||
with:
|
with:
|
||||||
working_directory: src/cpl-application
|
working_directory: src/cpl-api
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
application:
|
application:
|
||||||
|
|||||||
61
install.sh
Normal file
61
install.sh
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Find and combine requirements from src/cpl-*/requirements.txt,
|
||||||
|
# filtering out lines whose *package name* starts with "cpl-".
|
||||||
|
# Works with pinned versions, extras, markers, editable installs, and VCS refs.
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
req_files=(src/cpl-*/requirements.txt)
|
||||||
|
if ((${#req_files[@]} == 0)); then
|
||||||
|
echo "No requirements files found at src/cpl-*/requirements.txt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_combined="$(mktemp)"
|
||||||
|
trap 'rm -f "$tmp_combined"' EXIT
|
||||||
|
|
||||||
|
# Concatenate, trim comments/whitespace, filter out cpl-* packages, dedupe.
|
||||||
|
# We keep non-package options/flags/constraints as-is.
|
||||||
|
awk '
|
||||||
|
function trim(s){ sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); return s }
|
||||||
|
|
||||||
|
{
|
||||||
|
line=$0
|
||||||
|
# drop full-line comments and strip inline comments
|
||||||
|
if (line ~ /^[[:space:]]*#/) next
|
||||||
|
sub(/#[^!].*$/,"",line) # strip trailing comment (simple heuristic)
|
||||||
|
line=trim(line)
|
||||||
|
if (line == "") next
|
||||||
|
|
||||||
|
# Determine the package *name* even for "-e", extras, pins, markers, or VCS "@"
|
||||||
|
e = line
|
||||||
|
sub(/^-e[[:space:]]+/,"",e) # remove editable prefix
|
||||||
|
# Tokenize up to the first of these separators: space, [ < > = ! ~ ; @
|
||||||
|
token = e
|
||||||
|
sub(/\[.*/,"",token) # remove extras quickly
|
||||||
|
n = split(token, a, /[<>=!~;@[:space:]]/)
|
||||||
|
name = tolower(a[1])
|
||||||
|
|
||||||
|
# If the first token (name) starts with "cpl-", skip this requirement
|
||||||
|
if (name ~ /^cpl-/) next
|
||||||
|
|
||||||
|
print line
|
||||||
|
}
|
||||||
|
' "${req_files[@]}" | sort -u > "$tmp_combined"
|
||||||
|
|
||||||
|
if ! [ -s "$tmp_combined" ]; then
|
||||||
|
echo "Nothing to install after filtering out cpl-* packages." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing dependencies (excluding cpl-*) from:"
|
||||||
|
printf ' - %s\n' "${req_files[@]}"
|
||||||
|
echo
|
||||||
|
echo "Final set to install:"
|
||||||
|
cat "$tmp_combined"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Use python -m pip for reliability; change to python3 if needed.
|
||||||
|
python -m pip install -r "$tmp_combined"
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
from http.client import HTTPException
|
from http.client import HTTPException
|
||||||
|
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
class APIError(HTTPException):
|
class APIError(HTTPException):
|
||||||
status_code = 500
|
status_code = 500
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def response(cls):
|
||||||
|
return JSONResponse({"error": cls.__name__}, status_code=cls.status_code)
|
||||||
|
|
||||||
|
|
||||||
class Unauthorized(APIError):
|
class Unauthorized(APIError):
|
||||||
status_code = 401
|
status_code = 401
|
||||||
|
|||||||
49
src/cpl-api/cpl/api/middleware/authentication.py
Normal file
49
src/cpl-api/cpl/api/middleware/authentication.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from keycloak import KeycloakAuthenticationError
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from cpl.api.api_logger import APILogger
|
||||||
|
from cpl.api.error import Unauthorized
|
||||||
|
from cpl.api.router import Router
|
||||||
|
from cpl.auth.keycloak import KeycloakClient
|
||||||
|
from cpl.dependency import ServiceProviderABC
|
||||||
|
|
||||||
|
_logger = APILogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _verify_login(cls, token: str) -> bool:
|
||||||
|
keycloak = ServiceProviderABC.get_global_service(KeycloakClient)
|
||||||
|
try:
|
||||||
|
user_info = keycloak.userinfo(token)
|
||||||
|
if not user_info:
|
||||||
|
return False
|
||||||
|
except KeycloakAuthenticationError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
url = request.url.path
|
||||||
|
|
||||||
|
if url not in Router.get_auth_required_routes():
|
||||||
|
_logger.trace(f"No authentication required for {url}")
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if not request.headers.get("Authorization"):
|
||||||
|
_logger.debug(f"Unauthorized access to {url}, missing Authorization header")
|
||||||
|
return Unauthorized(f"Missing header Authorization").response()
|
||||||
|
|
||||||
|
|
||||||
|
auth_header = request.headers.get("Authorization", None)
|
||||||
|
if not auth_header or not auth_header.startswith("Bearer "):
|
||||||
|
return Unauthorized("Invalid Authorization header").response()
|
||||||
|
|
||||||
|
if not await self._verify_login(auth_header.split("Bearer ")[1]):
|
||||||
|
_logger.debug(f"Unauthorized access to {url}, invalid token")
|
||||||
|
return Unauthorized("Invalid token").response()
|
||||||
|
|
||||||
|
# check user exists in db, if not create
|
||||||
|
# unauthorized if user is deleted
|
||||||
|
return await call_next(request)
|
||||||
@@ -3,11 +3,35 @@ from starlette.routing import Route
|
|||||||
|
|
||||||
class Router:
|
class Router:
|
||||||
_registered_routes: list[Route] = []
|
_registered_routes: list[Route] = []
|
||||||
|
_auth_required: list[str] = []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_routes(cls) -> list[Route]:
|
def get_routes(cls) -> list[Route]:
|
||||||
return cls._registered_routes
|
return cls._registered_routes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_auth_required_routes(cls) -> list[str]:
|
||||||
|
return cls._auth_required
|
||||||
|
|
||||||
|
@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
|
@classmethod
|
||||||
def route(cls, path=None, **kwargs):
|
def route(cls, path=None, **kwargs):
|
||||||
def inner(fn):
|
def inner(fn):
|
||||||
@@ -57,4 +81,4 @@ class Router:
|
|||||||
|
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ HTTPMethods = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|||||||
PartialMiddleware = Union[
|
PartialMiddleware = Union[
|
||||||
Middleware,
|
Middleware,
|
||||||
Callable[[ASGIApp], ASGIApp],
|
Callable[[ASGIApp], ASGIApp],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from starlette.types import ExceptionHandler
|
|||||||
from cpl.api.api_logger import APILogger
|
from cpl.api.api_logger import APILogger
|
||||||
from cpl.api.api_settings import ApiSettings
|
from cpl.api.api_settings import ApiSettings
|
||||||
from cpl.api.error import APIError
|
from cpl.api.error import APIError
|
||||||
|
from cpl.api.middleware.authentication import AuthenticationMiddleware
|
||||||
from cpl.api.middleware.logging import LoggingMiddleware
|
from cpl.api.middleware.logging import LoggingMiddleware
|
||||||
from cpl.api.middleware.request import RequestMiddleware
|
from cpl.api.middleware.request import RequestMiddleware
|
||||||
from cpl.api.router import Router
|
from cpl.api.router import Router
|
||||||
@@ -24,7 +25,6 @@ from cpl.dependency.service_provider_abc import ServiceProviderABC
|
|||||||
_logger = APILogger("API")
|
_logger = APILogger("API")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class WebApp(ApplicationABC):
|
class WebApp(ApplicationABC):
|
||||||
def __init__(self, services: ServiceProviderABC):
|
def __init__(self, services: ServiceProviderABC):
|
||||||
super().__init__(services)
|
super().__init__(services)
|
||||||
@@ -37,18 +37,22 @@ class WebApp(ApplicationABC):
|
|||||||
Middleware(RequestMiddleware),
|
Middleware(RequestMiddleware),
|
||||||
Middleware(LoggingMiddleware),
|
Middleware(LoggingMiddleware),
|
||||||
]
|
]
|
||||||
self._exception_handlers: Mapping[Any, ExceptionHandler] = {Exception: self.handle_exception}
|
self._exception_handlers: Mapping[Any, ExceptionHandler] = {
|
||||||
|
Exception: self._handle_exception,
|
||||||
|
APIError: self._handle_exception,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def handle_exception(request: Request, exc: Exception):
|
async def _handle_exception(request: Request, exc: Exception):
|
||||||
|
if isinstance(exc, APIError):
|
||||||
|
_logger.error(exc)
|
||||||
|
return JSONResponse({"error": str(exc)}, status_code=exc.status_code)
|
||||||
|
|
||||||
if hasattr(request.state, "request_id"):
|
if hasattr(request.state, "request_id"):
|
||||||
_logger.error(f"Request {request.state.request_id}", exc)
|
_logger.error(f"Request {request.state.request_id}", exc)
|
||||||
else:
|
else:
|
||||||
_logger.error("Request unknown", exc)
|
_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)
|
return JSONResponse({"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
def _get_allowed_origins(self):
|
def _get_allowed_origins(self):
|
||||||
@@ -96,7 +100,15 @@ class WebApp(ApplicationABC):
|
|||||||
self._check_for_app()
|
self._check_for_app()
|
||||||
assert path is not None, "path must not be None"
|
assert path is not None, "path must not be None"
|
||||||
assert fn is not None, "fn 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"
|
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))
|
self._routes.append(Route(path, fn, methods=[method], **kwargs))
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -105,15 +117,20 @@ class WebApp(ApplicationABC):
|
|||||||
|
|
||||||
if isinstance(middleware, Middleware):
|
if isinstance(middleware, Middleware):
|
||||||
self._middleware.append(middleware)
|
self._middleware.append(middleware)
|
||||||
|
|
||||||
elif callable(middleware):
|
elif callable(middleware):
|
||||||
self._middleware.append(Middleware(middleware))
|
self._middleware.append(Middleware(middleware))
|
||||||
else:
|
else:
|
||||||
raise ValueError("middleware must be of type starlette.middleware.Middleware or a callable")
|
raise ValueError("middleware must be of type starlette.middleware.Middleware or a callable")
|
||||||
|
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def with_authentication(self):
|
||||||
|
self.with_middleware(AuthenticationMiddleware)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def with_authorization(self):
|
||||||
|
pass
|
||||||
|
|
||||||
def main(self):
|
def main(self):
|
||||||
_logger.debug(f"Preparing API")
|
_logger.debug(f"Preparing API")
|
||||||
if self._app is None:
|
if self._app is None:
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ requires = ["setuptools>=70.1.0", "wheel>=0.43.0"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "cpl-application"
|
name = "cpl-api"
|
||||||
version = "2024.7.0"
|
version = "2024.7.0"
|
||||||
description = "CPL application"
|
description = "CPL api"
|
||||||
readme ="CPL application package"
|
readme ="CPL api package"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" }
|
{ name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" }
|
||||||
]
|
]
|
||||||
keywords = ["cpl", "application", "backend", "shared", "library"]
|
keywords = ["cpl", "api", "backend", "shared", "library"]
|
||||||
|
|
||||||
dynamic = ["dependencies", "optional-dependencies"]
|
dynamic = ["dependencies", "optional-dependencies"]
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ cpl-application
|
|||||||
cpl-core
|
cpl-core
|
||||||
cpl-dependency
|
cpl-dependency
|
||||||
starlette==0.48.0
|
starlette==0.48.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
uvicorn==0.35.0
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
cpl-core
|
cpl-core
|
||||||
cpl-dependency
|
cpl-dependency
|
||||||
cpl-database
|
cpl-database
|
||||||
python-keycloak-5.8.1
|
python-keycloak==5.8.1
|
||||||
@@ -2,5 +2,4 @@ art==6.5
|
|||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
tabulate==0.9.0
|
tabulate==0.9.0
|
||||||
termcolor==3.1.0
|
termcolor==3.1.0
|
||||||
mysql-connector-python==9.4.0
|
|
||||||
pynput==1.8.1
|
pynput==1.8.1
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class ServiceProvider(ServiceProviderABC):
|
|||||||
|
|
||||||
return implementations
|
return implementations
|
||||||
|
|
||||||
def _build_by_signature(self, sig: Signature, origin_service_type: type=None) -> list[R]:
|
def _build_by_signature(self, sig: Signature, origin_service_type: type = None) -> list[R]:
|
||||||
params = []
|
params = []
|
||||||
for param in sig.parameters.items():
|
for param in sig.parameters.items():
|
||||||
parameter = param[1]
|
parameter = param[1]
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ServiceProviderABC(ABC):
|
|||||||
return cls._provider.get_services(instance_type, *args, **kwargs)
|
return cls._provider.get_services(instance_type, *args, **kwargs)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _build_by_signature(self, sig: Signature, origin_service_type: type=None) -> list[R]: ...
|
def _build_by_signature(self, sig: Signature, origin_service_type: type = None) -> list[R]: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _build_service(self, service_type: type, *args, **kwargs) -> object:
|
def _build_service(self, service_type: type, *args, **kwargs) -> object:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from starlette.responses import JSONResponse
|
|||||||
|
|
||||||
from cpl.api.web_app import WebApp
|
from cpl.api.web_app import WebApp
|
||||||
from cpl.application import ApplicationBuilder
|
from cpl.application import ApplicationBuilder
|
||||||
from custom.api.src.service import PingService
|
from service import PingService
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -15,6 +15,7 @@ def main():
|
|||||||
app.with_route(path="/route1", fn=lambda r: JSONResponse("route1"), method="GET")
|
app.with_route(path="/route1", fn=lambda r: JSONResponse("route1"), method="GET")
|
||||||
app.with_routes_directory("routes")
|
app.with_routes_directory("routes")
|
||||||
app.with_logging()
|
app.with_logging()
|
||||||
|
app.with_authentication()
|
||||||
|
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ from starlette.responses import JSONResponse
|
|||||||
|
|
||||||
from cpl.api.router import Router
|
from cpl.api.router import Router
|
||||||
from cpl.core.log import Logger
|
from cpl.core.log import Logger
|
||||||
from custom.api.src.service import PingService
|
from service import PingService
|
||||||
|
|
||||||
|
|
||||||
|
@Router.authenticate()
|
||||||
@Router.get(f"/ping")
|
@Router.get(f"/ping")
|
||||||
async def ping(r: Request, ping: PingService, logger: Logger):
|
async def ping(r: Request, ping: PingService, logger: Logger):
|
||||||
logger.info(f"Ping: {ping}")
|
logger.info(f"Ping: {ping}")
|
||||||
|
|||||||
Reference in New Issue
Block a user