fastapi-factory-utilities 0.2.0__py3-none-any.whl → 0.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fastapi-factory-utilities might be problematic. Click here for more details.
- fastapi_factory_utilities/core/api/__init__.py +1 -1
- fastapi_factory_utilities/core/api/v1/sys/health.py +1 -1
- fastapi_factory_utilities/core/app/__init__.py +12 -3
- fastapi_factory_utilities/core/app/application.py +24 -26
- fastapi_factory_utilities/core/app/builder.py +23 -37
- fastapi_factory_utilities/core/app/config.py +22 -1
- fastapi_factory_utilities/core/app/fastapi_builder.py +3 -2
- fastapi_factory_utilities/core/exceptions.py +58 -22
- fastapi_factory_utilities/core/plugins/__init__.py +2 -31
- fastapi_factory_utilities/core/plugins/abstracts.py +40 -0
- fastapi_factory_utilities/core/plugins/aiopika/__init__.py +25 -0
- fastapi_factory_utilities/core/plugins/aiopika/abstract.py +48 -0
- fastapi_factory_utilities/core/plugins/aiopika/configs.py +85 -0
- fastapi_factory_utilities/core/plugins/aiopika/depends.py +20 -0
- fastapi_factory_utilities/core/plugins/aiopika/exceptions.py +29 -0
- fastapi_factory_utilities/core/plugins/aiopika/exchange.py +70 -0
- fastapi_factory_utilities/core/plugins/aiopika/listener/__init__.py +7 -0
- fastapi_factory_utilities/core/plugins/aiopika/listener/abstract.py +72 -0
- fastapi_factory_utilities/core/plugins/aiopika/message.py +86 -0
- fastapi_factory_utilities/core/plugins/aiopika/plugins.py +84 -0
- fastapi_factory_utilities/core/plugins/aiopika/publisher/__init__.py +7 -0
- fastapi_factory_utilities/core/plugins/aiopika/publisher/abstract.py +66 -0
- fastapi_factory_utilities/core/plugins/aiopika/queue.py +86 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/__init__.py +25 -153
- fastapi_factory_utilities/core/plugins/odm_plugin/builder.py +59 -31
- fastapi_factory_utilities/core/plugins/odm_plugin/configs.py +1 -1
- fastapi_factory_utilities/core/plugins/odm_plugin/documents.py +2 -1
- fastapi_factory_utilities/core/plugins/odm_plugin/helpers.py +16 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/plugins.py +155 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/repositories.py +112 -3
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/__init__.py +8 -115
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/builder.py +65 -14
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/configs.py +13 -0
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/instruments/__init__.py +85 -0
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/plugins.py +137 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/__init__.py +29 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/configs.py +12 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/depends.py +51 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/exceptions.py +13 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/plugin.py +41 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/schedulers.py +187 -0
- fastapi_factory_utilities/core/protocols.py +1 -54
- fastapi_factory_utilities/core/security/jwt.py +159 -0
- fastapi_factory_utilities/core/security/kratos.py +98 -0
- fastapi_factory_utilities/core/services/hydra/__init__.py +13 -0
- fastapi_factory_utilities/core/services/hydra/exceptions.py +15 -0
- fastapi_factory_utilities/core/services/hydra/objects.py +26 -0
- fastapi_factory_utilities/core/services/hydra/services.py +122 -0
- fastapi_factory_utilities/core/services/kratos/__init__.py +13 -0
- fastapi_factory_utilities/core/services/kratos/enums.py +11 -0
- fastapi_factory_utilities/core/services/kratos/exceptions.py +15 -0
- fastapi_factory_utilities/core/services/kratos/objects.py +43 -0
- fastapi_factory_utilities/core/services/kratos/services.py +86 -0
- fastapi_factory_utilities/core/services/status/__init__.py +2 -2
- fastapi_factory_utilities/core/utils/status.py +2 -1
- fastapi_factory_utilities/core/utils/uvicorn.py +36 -0
- fastapi_factory_utilities/core/utils/yaml_reader.py +2 -2
- fastapi_factory_utilities/example/app.py +15 -5
- fastapi_factory_utilities/example/entities/books/__init__.py +1 -1
- fastapi_factory_utilities/example/models/books/__init__.py +1 -1
- fastapi_factory_utilities/py.typed +0 -0
- {fastapi_factory_utilities-0.2.0.dist-info → fastapi_factory_utilities-0.7.1.dist-info}/METADATA +23 -14
- fastapi_factory_utilities-0.7.1.dist-info/RECORD +101 -0
- {fastapi_factory_utilities-0.2.0.dist-info → fastapi_factory_utilities-0.7.1.dist-info}/WHEEL +1 -1
- fastapi_factory_utilities/core/app/plugin_manager/__init__.py +0 -15
- fastapi_factory_utilities/core/app/plugin_manager/exceptions.py +0 -33
- fastapi_factory_utilities/core/app/plugin_manager/plugin_manager.py +0 -190
- fastapi_factory_utilities/core/plugins/example/__init__.py +0 -31
- fastapi_factory_utilities/core/plugins/httpx_plugin/__init__.py +0 -31
- fastapi_factory_utilities-0.2.0.dist-info/RECORD +0 -70
- {fastapi_factory_utilities-0.2.0.dist-info → fastapi_factory_utilities-0.7.1.dist-info}/entry_points.txt +0 -0
- {fastapi_factory_utilities-0.2.0.dist-info → fastapi_factory_utilities-0.7.1.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Scheduler module for fastapi_factory_utilities.
|
|
2
|
+
|
|
3
|
+
This module provides components and utilities for scheduling tasks using Taskiq, FastAPI, and Redis.
|
|
4
|
+
It enables registration, configuration, and management of scheduled tasks in FastAPI applications.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from collections.abc import Coroutine
|
|
9
|
+
from typing import Any, Self, cast
|
|
10
|
+
|
|
11
|
+
import taskiq_fastapi
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from structlog.stdlib import get_logger
|
|
14
|
+
from taskiq import (
|
|
15
|
+
AsyncBroker,
|
|
16
|
+
AsyncTaskiqDecoratedTask,
|
|
17
|
+
ScheduleSource,
|
|
18
|
+
TaskiqScheduler,
|
|
19
|
+
)
|
|
20
|
+
from taskiq.api import run_receiver_task, run_scheduler_task
|
|
21
|
+
from taskiq.scheduler.created_schedule import CreatedSchedule
|
|
22
|
+
from taskiq.scheduler.scheduled_task import ScheduledTask
|
|
23
|
+
from taskiq_redis import (
|
|
24
|
+
ListRedisScheduleSource,
|
|
25
|
+
RedisAsyncResultBackend,
|
|
26
|
+
RedisStreamBroker,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_logger = get_logger(__package__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SchedulerComponent:
|
|
33
|
+
"""Scheduler component."""
|
|
34
|
+
|
|
35
|
+
NAME_SUFFIX: str = "tiktok_integration"
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
"""Initialize the scheduler component."""
|
|
39
|
+
self._result_backend: RedisAsyncResultBackend[Any] | None = None
|
|
40
|
+
self._stream_broker: RedisStreamBroker | None = None
|
|
41
|
+
self._scheduler: TaskiqScheduler | None = None
|
|
42
|
+
self._scheduler_source: ListRedisScheduleSource | None = None
|
|
43
|
+
self._dyn_task: AsyncTaskiqDecoratedTask[Any, Any] | None = None
|
|
44
|
+
self._schedule_cron: ScheduledTask | None = None
|
|
45
|
+
self._schedulers_tasks: dict[str, AsyncTaskiqDecoratedTask[Any, Any]] = {}
|
|
46
|
+
|
|
47
|
+
def register_task(self, task: Coroutine[Any, Any, Any], task_name: str) -> None:
|
|
48
|
+
"""Register a task.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
task: The task to register.
|
|
52
|
+
task_name: The name of the task.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If the task is already registered.
|
|
56
|
+
ValueError: If the stream broker is not initialized.
|
|
57
|
+
"""
|
|
58
|
+
if self._stream_broker is None:
|
|
59
|
+
raise ValueError("Stream broker is not initialized")
|
|
60
|
+
|
|
61
|
+
if task_name in self._schedulers_tasks:
|
|
62
|
+
raise ValueError(f"Task {task_name} already registered")
|
|
63
|
+
|
|
64
|
+
self._schedulers_tasks[task_name] = self._stream_broker.register_task(task, task_name) # type: ignore
|
|
65
|
+
|
|
66
|
+
def get_task(self, task_name: str) -> AsyncTaskiqDecoratedTask[Any, Any]:
|
|
67
|
+
"""Get a task.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
task_name: The name of the task.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
AsyncTaskiqDecoratedTask: The task.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If the task is not registered.
|
|
77
|
+
"""
|
|
78
|
+
if task_name not in self._schedulers_tasks:
|
|
79
|
+
raise ValueError(f"Task {task_name} not registered")
|
|
80
|
+
return self._schedulers_tasks[task_name]
|
|
81
|
+
|
|
82
|
+
def configure(self, redis_connection_string: str, app: FastAPI) -> Self:
|
|
83
|
+
"""Configure the scheduler component."""
|
|
84
|
+
self._result_backend = RedisAsyncResultBackend(
|
|
85
|
+
redis_url=redis_connection_string,
|
|
86
|
+
prefix_str=f"velmios_taskiq_result_backend_{self.NAME_SUFFIX}",
|
|
87
|
+
result_ex_time=120,
|
|
88
|
+
)
|
|
89
|
+
self._stream_broker = RedisStreamBroker(
|
|
90
|
+
url=redis_connection_string,
|
|
91
|
+
queue_name=f"velmios_taskiq_stream_broker_{self.NAME_SUFFIX}",
|
|
92
|
+
consumer_group_name=f"velmios_taskiq_consumer_group_{self.NAME_SUFFIX}",
|
|
93
|
+
).with_result_backend(self._result_backend)
|
|
94
|
+
|
|
95
|
+
taskiq_fastapi.populate_dependency_context(self._stream_broker, app)
|
|
96
|
+
|
|
97
|
+
self._scheduler_source = ListRedisScheduleSource(
|
|
98
|
+
url=redis_connection_string,
|
|
99
|
+
prefix=f"velmios_taskiq_schedule_source_{self.NAME_SUFFIX}",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
self._scheduler = TaskiqScheduler(
|
|
103
|
+
broker=self._stream_broker,
|
|
104
|
+
sources=[self._scheduler_source],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
async def startup(self, app: FastAPI) -> None:
|
|
110
|
+
"""Start the scheduler."""
|
|
111
|
+
if self._result_backend is None:
|
|
112
|
+
raise ValueError("Result backend is not initialized")
|
|
113
|
+
if self._stream_broker is None:
|
|
114
|
+
raise ValueError("Stream broker is not initialized")
|
|
115
|
+
if self._scheduler is None:
|
|
116
|
+
raise ValueError("Scheduler is not initialized")
|
|
117
|
+
if self._scheduler_source is None:
|
|
118
|
+
raise ValueError("Scheduler source is not initialized")
|
|
119
|
+
|
|
120
|
+
_logger.info("Starting scheduler")
|
|
121
|
+
await self._result_backend.startup()
|
|
122
|
+
await self._stream_broker.startup()
|
|
123
|
+
await self._scheduler.startup()
|
|
124
|
+
_logger.info("Scheduler started")
|
|
125
|
+
_logger.info("Scheduling task")
|
|
126
|
+
schedules: list[ScheduledTask] = await self._scheduler_source.get_schedules()
|
|
127
|
+
_logger.info("Schedules retrieved", schedules=schedules)
|
|
128
|
+
|
|
129
|
+
self._schedule_cron = next(filter(lambda x: x.task_name == "heartbeat", schedules), None)
|
|
130
|
+
|
|
131
|
+
if self._schedule_cron is None:
|
|
132
|
+
_logger.info("No schedules found, scheduling task")
|
|
133
|
+
self._dyn_task = self.get_task("heartbeat")
|
|
134
|
+
task_created: CreatedSchedule[Any] = await self._dyn_task.schedule_by_cron(
|
|
135
|
+
source=self._scheduler_source, cron="* * * * *", msg="every minute"
|
|
136
|
+
)
|
|
137
|
+
self._schedule_cron = task_created.task
|
|
138
|
+
_logger.info("Task scheduled")
|
|
139
|
+
else:
|
|
140
|
+
_logger.info("Schedules found, skipping scheduling")
|
|
141
|
+
|
|
142
|
+
_logger.info("Starting worker and scheduler tasks")
|
|
143
|
+
taskiq_fastapi.populate_dependency_context(self._stream_broker, app, app.state) # type: ignore
|
|
144
|
+
self._worker_task: asyncio.Task[None] = asyncio.create_task(run_receiver_task(self._stream_broker))
|
|
145
|
+
self._scheduler_task: asyncio.Task[None] = asyncio.create_task(run_scheduler_task(self._scheduler))
|
|
146
|
+
_logger.info("Worker and scheduler tasks started")
|
|
147
|
+
|
|
148
|
+
async def shutdown(self) -> None:
|
|
149
|
+
"""Stop the scheduler."""
|
|
150
|
+
_logger.info("Stopping worker")
|
|
151
|
+
self._worker_task.cancel()
|
|
152
|
+
self._scheduler_task.cancel()
|
|
153
|
+
try:
|
|
154
|
+
await self._worker_task
|
|
155
|
+
except (asyncio.CancelledError, RuntimeError) as e:
|
|
156
|
+
_logger.info("Worker task cancelled", error=e)
|
|
157
|
+
try:
|
|
158
|
+
await self._scheduler_task
|
|
159
|
+
except (asyncio.CancelledError, RuntimeError) as e:
|
|
160
|
+
_logger.info("Scheduler task cancelled", error=e)
|
|
161
|
+
|
|
162
|
+
while not self._worker_task.done() or not self._scheduler_task.done():
|
|
163
|
+
await asyncio.sleep(0.1)
|
|
164
|
+
|
|
165
|
+
_logger.info("Stopping scheduler")
|
|
166
|
+
if self._scheduler is not None:
|
|
167
|
+
await self._scheduler.shutdown()
|
|
168
|
+
if self._stream_broker is not None:
|
|
169
|
+
await self._stream_broker.shutdown()
|
|
170
|
+
if self._result_backend is not None:
|
|
171
|
+
await self._result_backend.shutdown()
|
|
172
|
+
_logger.info("Scheduler stopped")
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def scheduler(self) -> TaskiqScheduler:
|
|
176
|
+
"""Get the scheduler."""
|
|
177
|
+
return cast(TaskiqScheduler, self._scheduler)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def broker(self) -> AsyncBroker:
|
|
181
|
+
"""Get the broker."""
|
|
182
|
+
return cast(AsyncBroker, self._stream_broker)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def scheduler_source(self) -> ScheduleSource:
|
|
186
|
+
"""Get the scheduler source."""
|
|
187
|
+
return cast(ScheduleSource, self._scheduler_source)
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
"""Protocols for the base application."""
|
|
2
2
|
|
|
3
3
|
from abc import abstractmethod
|
|
4
|
-
from typing import TYPE_CHECKING, ClassVar, Protocol
|
|
4
|
+
from typing import TYPE_CHECKING, ClassVar, Protocol
|
|
5
5
|
|
|
6
6
|
from beanie import Document
|
|
7
7
|
from fastapi import FastAPI
|
|
8
8
|
|
|
9
|
-
from fastapi_factory_utilities.core.plugins import PluginsEnum
|
|
10
9
|
from fastapi_factory_utilities.core.services.status.services import StatusService
|
|
11
10
|
|
|
12
11
|
if TYPE_CHECKING:
|
|
13
12
|
from fastapi_factory_utilities.core.app.config import RootConfig
|
|
14
|
-
from fastapi_factory_utilities.core.plugins import PluginState
|
|
15
13
|
|
|
16
14
|
|
|
17
15
|
class ApplicationAbstractProtocol(Protocol):
|
|
@@ -21,8 +19,6 @@ class ApplicationAbstractProtocol(Protocol):
|
|
|
21
19
|
|
|
22
20
|
ODM_DOCUMENT_MODELS: ClassVar[list[type[Document]]]
|
|
23
21
|
|
|
24
|
-
DEFAULT_PLUGINS_ACTIVATED: ClassVar[list[PluginsEnum]]
|
|
25
|
-
|
|
26
22
|
@abstractmethod
|
|
27
23
|
def get_config(self) -> "RootConfig":
|
|
28
24
|
"""Get the application configuration."""
|
|
@@ -34,52 +30,3 @@ class ApplicationAbstractProtocol(Protocol):
|
|
|
34
30
|
@abstractmethod
|
|
35
31
|
def get_status_service(self) -> StatusService:
|
|
36
32
|
"""Get the status service."""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@runtime_checkable
|
|
40
|
-
class PluginProtocol(Protocol):
|
|
41
|
-
"""Defines the protocol for the plugins."""
|
|
42
|
-
|
|
43
|
-
@abstractmethod
|
|
44
|
-
def pre_conditions_check(self, application: ApplicationAbstractProtocol) -> bool:
|
|
45
|
-
"""Check the pre-conditions for the plugin.
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
application (BaseApplicationProtocol): The application.
|
|
49
|
-
|
|
50
|
-
Returns:
|
|
51
|
-
bool: True if the pre-conditions are met, False otherwise.
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
@abstractmethod
|
|
55
|
-
def on_load(self, application: ApplicationAbstractProtocol) -> list["PluginState"] | None:
|
|
56
|
-
"""The actions to perform on load for the plugin.
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
application (BaseApplicationProtocol): The application.
|
|
60
|
-
|
|
61
|
-
Returns:
|
|
62
|
-
None
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
@abstractmethod
|
|
66
|
-
async def on_startup(self, application: ApplicationAbstractProtocol) -> list["PluginState"] | None:
|
|
67
|
-
"""The actions to perform on startup for the plugin.
|
|
68
|
-
|
|
69
|
-
Args:
|
|
70
|
-
application (BaseApplicationProtocol): The application.
|
|
71
|
-
|
|
72
|
-
Returns:
|
|
73
|
-
None
|
|
74
|
-
"""
|
|
75
|
-
|
|
76
|
-
@abstractmethod
|
|
77
|
-
async def on_shutdown(self, application: ApplicationAbstractProtocol) -> None:
|
|
78
|
-
"""The actions to perform on shutdown for the plugin.
|
|
79
|
-
|
|
80
|
-
Args:
|
|
81
|
-
application (BaseApplicationProtocol): The application.
|
|
82
|
-
|
|
83
|
-
Returns:
|
|
84
|
-
None
|
|
85
|
-
"""
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Provides security-related functions for the API."""
|
|
2
|
+
|
|
3
|
+
from asyncio import Task, TaskGroup
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
from typing import Any, ClassVar, NewType, cast
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
import pydantic
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
from fastapi.exceptions import HTTPException
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
Scope = NewType("Scope", str)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JWTBearerDecoded(BaseModel):
|
|
17
|
+
"""JWT bearer token decoded."""
|
|
18
|
+
|
|
19
|
+
model_config: ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(
|
|
20
|
+
arbitrary_types_allowed=True,
|
|
21
|
+
extra="forbid",
|
|
22
|
+
frozen=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
scopes: list[str] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JWTBearerAuthentication:
|
|
29
|
+
"""JWT Bearer Authentication.
|
|
30
|
+
|
|
31
|
+
This class is used to authenticate users using JWT tokens.
|
|
32
|
+
It extracts the token from the request, decodes it, and verifies its validity.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, scopes: list[Scope] | None = None, jwt_raw: str | None = None) -> None:
|
|
36
|
+
"""Initialize the OAuth2 class.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
scopes (SecurityScopes): Security scopes for the OAuth2.
|
|
40
|
+
jwt_raw (str): JWT token to be used for authentication.
|
|
41
|
+
"""
|
|
42
|
+
self.jwt_raw: str | None = jwt_raw
|
|
43
|
+
self.scopes: list[Scope] | None = scopes
|
|
44
|
+
|
|
45
|
+
def _extract_raw_token(self, request: Request) -> str:
|
|
46
|
+
"""Extract the raw token from the request.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
request (Request): FastAPI request object.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
str: Raw token.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
HTTPException: If the token is missing or invalid.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
authorization_header: str | None = request.headers.get("Authorization")
|
|
59
|
+
except (AttributeError, KeyError) as e:
|
|
60
|
+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Missing Credentials") from e
|
|
61
|
+
|
|
62
|
+
if not authorization_header:
|
|
63
|
+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Missing Credentials")
|
|
64
|
+
|
|
65
|
+
if not authorization_header.startswith("Bearer "):
|
|
66
|
+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Invalid Credentials")
|
|
67
|
+
|
|
68
|
+
return authorization_header.split(sep=" ")[1]
|
|
69
|
+
|
|
70
|
+
async def _decode_jwt(self, jwt_raw: str) -> JWTBearerDecoded:
|
|
71
|
+
"""Decode the JWT token.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
jwt_raw (str): Raw JWT token.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
JWTBearerDecoded: Decoded JWT token.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
HTTPException: If the token is invalid or expired.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
jwt_decoded: dict[str, Any] = cast(
|
|
84
|
+
dict[str, Any],
|
|
85
|
+
jwt.decode(
|
|
86
|
+
jwt=jwt_raw,
|
|
87
|
+
algorithms=["HS256", "RS256"],
|
|
88
|
+
options={"verify_signature": True},
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
return JWTBearerDecoded(**jwt_decoded)
|
|
92
|
+
except jwt.ExpiredSignatureError as e:
|
|
93
|
+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Token expired") from e
|
|
94
|
+
except jwt.InvalidTokenError as e:
|
|
95
|
+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Invalid token") from e
|
|
96
|
+
except pydantic.ValidationError as e:
|
|
97
|
+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=f"Invalid token: {e.json()}") from e
|
|
98
|
+
|
|
99
|
+
async def _verify(self, jwt_raw: str) -> None:
|
|
100
|
+
"""Verify the JWT token.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
jwt_raw (str): Raw JWT token.
|
|
104
|
+
"""
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
def _has_scope(self, jwt_decoded: JWTBearerDecoded) -> None:
|
|
108
|
+
"""Check if the JWT token has the required scope.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
jwt_decoded (JWTBearerDecoded): Decoded JWT token.
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
# Just Authentication (no scopes, no authorization)
|
|
115
|
+
if not self.scopes:
|
|
116
|
+
return
|
|
117
|
+
# JWT without scopes (no authorization)
|
|
118
|
+
if not jwt_decoded.scopes:
|
|
119
|
+
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Unauthorized")
|
|
120
|
+
# Check if all required scopes are present
|
|
121
|
+
if not all(scope in jwt_decoded.scopes for scope in (self.scopes or [])):
|
|
122
|
+
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Unauthorized")
|
|
123
|
+
|
|
124
|
+
# All scopes are valid (authorization)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
async def __call__(self, request: Request | None = None) -> JWTBearerDecoded:
|
|
128
|
+
"""Call the JWT bearer authentication.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
request (Request): FastAPI request object.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
JWTBearerDecoded: Decoded JWT token.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
HTTPException: If the token is missing or invalid.
|
|
138
|
+
"""
|
|
139
|
+
# Ensure that the jwt will be provided
|
|
140
|
+
# by the request or by the jwt parameter
|
|
141
|
+
if self.jwt_raw is None and request is None:
|
|
142
|
+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Missing Credentials")
|
|
143
|
+
jwt_raw: str
|
|
144
|
+
if self.jwt_raw is None:
|
|
145
|
+
jwt_raw = self._extract_raw_token(request=request) # type: ignore[arg-type]
|
|
146
|
+
else:
|
|
147
|
+
jwt_raw = self.jwt_raw
|
|
148
|
+
|
|
149
|
+
# Execute the io bound and cpu bound tasks in parallel
|
|
150
|
+
async with TaskGroup() as tg:
|
|
151
|
+
# TODO: Can be disabled by configuration (for operation purposes)
|
|
152
|
+
# Ensure that the jwt is not revoked or expired
|
|
153
|
+
tg.create_task(self._verify(jwt_raw=jwt_raw), name="verify_jwt")
|
|
154
|
+
# Ensure that the jwt is not altered or expired
|
|
155
|
+
task_decode: Task[Any] = tg.create_task(self._decode_jwt(jwt_raw=jwt_raw), name="decode_jwt")
|
|
156
|
+
# Scope Validation
|
|
157
|
+
jwt_decoded: JWTBearerDecoded = task_decode.result()
|
|
158
|
+
self._has_scope(jwt_decoded=jwt_decoded)
|
|
159
|
+
return jwt_decoded
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Provide Kratos Session and Identity classes."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, HTTPException, Request
|
|
7
|
+
|
|
8
|
+
from fastapi_factory_utilities.core.services.kratos import (
|
|
9
|
+
KratosOperationError,
|
|
10
|
+
KratosService,
|
|
11
|
+
KratosSessionInvalidError,
|
|
12
|
+
KratosSessionObject,
|
|
13
|
+
depends_kratos_service,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KratosSessionAuthenticationErrors(StrEnum):
|
|
18
|
+
"""Kratos Session Authentication Errors."""
|
|
19
|
+
|
|
20
|
+
MISSING_CREDENTIALS = "Missing Credentials"
|
|
21
|
+
INVALID_CREDENTIALS = "Invalid Credentials"
|
|
22
|
+
INTERNAL_SERVER_ERROR = "Internal Server Error"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KratosSessionAuthentication:
|
|
26
|
+
"""Kratos Session class."""
|
|
27
|
+
|
|
28
|
+
DEFAULT_COOKIE_NAME: str = "ory_kratos_session"
|
|
29
|
+
|
|
30
|
+
def __init__(self, cookie_name: str = DEFAULT_COOKIE_NAME, raise_exception: bool = True) -> None:
|
|
31
|
+
"""Initialize the KratosSessionAuthentication class.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
cookie_name (str): Name of the cookie to extract the session
|
|
35
|
+
raise_exception (bool): Whether to raise an exception or return None
|
|
36
|
+
"""
|
|
37
|
+
self._cookie_name: str = cookie_name
|
|
38
|
+
self._raise_exception: bool = raise_exception
|
|
39
|
+
|
|
40
|
+
def _extract_cookie(self, request: Request) -> str | None:
|
|
41
|
+
"""Extract the cookie from the request.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
request (Request): FastAPI request object.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
str | None: Cookie value or None if not found.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
HTTPException: If the cookie is missing.
|
|
51
|
+
"""
|
|
52
|
+
return request.cookies.get(self._cookie_name, None)
|
|
53
|
+
|
|
54
|
+
async def __call__(
|
|
55
|
+
self, request: Request, kratos_service: Annotated[KratosService, Depends(depends_kratos_service)]
|
|
56
|
+
) -> KratosSessionObject | KratosSessionAuthenticationErrors:
|
|
57
|
+
"""Extract the Kratos session from the request.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
request (Request): FastAPI request object.
|
|
61
|
+
kratos_service (KratosService): Kratos service object.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
KratosSessionObject | KratosSessionAuthenticationErrors: Kratos session object or error.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
HTTPException: If the session is invalid and raise_exception is True.
|
|
68
|
+
"""
|
|
69
|
+
cookie: str | None = self._extract_cookie(request)
|
|
70
|
+
if not cookie:
|
|
71
|
+
if self._raise_exception:
|
|
72
|
+
raise HTTPException(
|
|
73
|
+
status_code=401,
|
|
74
|
+
detail=KratosSessionAuthenticationErrors.MISSING_CREDENTIALS,
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
return KratosSessionAuthenticationErrors.MISSING_CREDENTIALS
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
session: KratosSessionObject = await kratos_service.whoami(cookie_value=cookie)
|
|
81
|
+
except KratosSessionInvalidError as e:
|
|
82
|
+
if self._raise_exception:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=401,
|
|
85
|
+
detail="Invalid Credentials",
|
|
86
|
+
) from e
|
|
87
|
+
else:
|
|
88
|
+
return KratosSessionAuthenticationErrors.INVALID_CREDENTIALS
|
|
89
|
+
except KratosOperationError as e:
|
|
90
|
+
if self._raise_exception:
|
|
91
|
+
raise HTTPException(
|
|
92
|
+
status_code=500,
|
|
93
|
+
detail="Internal Server Error",
|
|
94
|
+
) from e
|
|
95
|
+
else:
|
|
96
|
+
return KratosSessionAuthenticationErrors.INTERNAL_SERVER_ERROR
|
|
97
|
+
|
|
98
|
+
return session
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Hydra service module."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import HydraOperationError, HydraTokenInvalidError
|
|
4
|
+
from .objects import HydraTokenIntrospectObject
|
|
5
|
+
from .services import HydraService, depends_hydra_service
|
|
6
|
+
|
|
7
|
+
__all__: list[str] = [
|
|
8
|
+
"HydraOperationError",
|
|
9
|
+
"HydraService",
|
|
10
|
+
"HydraTokenIntrospectObject",
|
|
11
|
+
"HydraTokenInvalidError",
|
|
12
|
+
"depends_hydra_service",
|
|
13
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Python exceptions for the Hydra service."""
|
|
2
|
+
|
|
3
|
+
from fastapi_factory_utilities.core.exceptions import FastAPIFactoryUtilitiesError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HydraError(FastAPIFactoryUtilitiesError):
|
|
7
|
+
"""Base class for all exceptions raised by the Hydra service."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HydraOperationError(HydraError):
|
|
11
|
+
"""Exception raised when a Hydra operation fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HydraTokenInvalidError(HydraError):
|
|
15
|
+
"""Exception raised when a Hydra token is invalid."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Provides the objects for the Hydra service."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HydraTokenIntrospectObject(BaseModel):
|
|
9
|
+
"""Represents the object returned by the Hydra token introspection."""
|
|
10
|
+
|
|
11
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
|
|
12
|
+
|
|
13
|
+
active: bool
|
|
14
|
+
aud: list[str]
|
|
15
|
+
client_id: str
|
|
16
|
+
exp: int
|
|
17
|
+
ext: dict[str, str] | None = None
|
|
18
|
+
iat: int
|
|
19
|
+
iss: str
|
|
20
|
+
nbf: int
|
|
21
|
+
obfuscated_subject: str | None = None
|
|
22
|
+
scope: str
|
|
23
|
+
sub: str
|
|
24
|
+
token_type: str
|
|
25
|
+
token_use: str
|
|
26
|
+
username: str | None = None
|