aegra-api 0.1.0__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.
- aegra_api/__init__.py +3 -0
- aegra_api/api/__init__.py +1 -0
- aegra_api/api/assistants.py +235 -0
- aegra_api/api/runs.py +1110 -0
- aegra_api/api/store.py +200 -0
- aegra_api/api/threads.py +761 -0
- aegra_api/config.py +204 -0
- aegra_api/constants.py +5 -0
- aegra_api/core/__init__.py +0 -0
- aegra_api/core/app_loader.py +91 -0
- aegra_api/core/auth_ctx.py +65 -0
- aegra_api/core/auth_deps.py +186 -0
- aegra_api/core/auth_handlers.py +248 -0
- aegra_api/core/auth_middleware.py +331 -0
- aegra_api/core/database.py +123 -0
- aegra_api/core/health.py +131 -0
- aegra_api/core/orm.py +165 -0
- aegra_api/core/route_merger.py +69 -0
- aegra_api/core/serializers/__init__.py +7 -0
- aegra_api/core/serializers/base.py +22 -0
- aegra_api/core/serializers/general.py +54 -0
- aegra_api/core/serializers/langgraph.py +102 -0
- aegra_api/core/sse.py +178 -0
- aegra_api/main.py +303 -0
- aegra_api/middleware/__init__.py +4 -0
- aegra_api/middleware/double_encoded_json.py +74 -0
- aegra_api/middleware/logger_middleware.py +95 -0
- aegra_api/models/__init__.py +76 -0
- aegra_api/models/assistants.py +81 -0
- aegra_api/models/auth.py +62 -0
- aegra_api/models/enums.py +29 -0
- aegra_api/models/errors.py +29 -0
- aegra_api/models/runs.py +124 -0
- aegra_api/models/store.py +67 -0
- aegra_api/models/threads.py +152 -0
- aegra_api/observability/__init__.py +1 -0
- aegra_api/observability/base.py +88 -0
- aegra_api/observability/otel.py +133 -0
- aegra_api/observability/setup.py +27 -0
- aegra_api/observability/targets/__init__.py +11 -0
- aegra_api/observability/targets/base.py +18 -0
- aegra_api/observability/targets/langfuse.py +33 -0
- aegra_api/observability/targets/otlp.py +38 -0
- aegra_api/observability/targets/phoenix.py +24 -0
- aegra_api/services/__init__.py +0 -0
- aegra_api/services/assistant_service.py +569 -0
- aegra_api/services/base_broker.py +59 -0
- aegra_api/services/broker.py +141 -0
- aegra_api/services/event_converter.py +157 -0
- aegra_api/services/event_store.py +196 -0
- aegra_api/services/graph_streaming.py +433 -0
- aegra_api/services/langgraph_service.py +456 -0
- aegra_api/services/streaming_service.py +362 -0
- aegra_api/services/thread_state_service.py +128 -0
- aegra_api/settings.py +124 -0
- aegra_api/utils/__init__.py +3 -0
- aegra_api/utils/assistants.py +23 -0
- aegra_api/utils/run_utils.py +60 -0
- aegra_api/utils/setup_logging.py +122 -0
- aegra_api/utils/sse_utils.py +26 -0
- aegra_api/utils/status_compat.py +57 -0
- aegra_api-0.1.0.dist-info/METADATA +244 -0
- aegra_api-0.1.0.dist-info/RECORD +64 -0
- aegra_api-0.1.0.dist-info/WHEEL +4 -0
aegra_api/config.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Configuration management for Aegra HTTP settings"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
from aegra_api.settings import settings
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CorsConfig(TypedDict, total=False):
|
|
15
|
+
"""CORS configuration options"""
|
|
16
|
+
|
|
17
|
+
allow_origins: list[str]
|
|
18
|
+
allow_methods: list[str]
|
|
19
|
+
allow_headers: list[str]
|
|
20
|
+
allow_credentials: bool
|
|
21
|
+
expose_headers: list[str]
|
|
22
|
+
max_age: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HttpConfig(TypedDict, total=False):
|
|
26
|
+
"""HTTP configuration options for custom routes"""
|
|
27
|
+
|
|
28
|
+
app: str
|
|
29
|
+
"""Import path for custom Starlette/FastAPI app to mount"""
|
|
30
|
+
enable_custom_route_auth: bool
|
|
31
|
+
"""Apply Aegra authentication dependency to custom routes (uses FastAPI dependencies, not middleware)"""
|
|
32
|
+
cors: CorsConfig | None
|
|
33
|
+
"""Custom CORS configuration"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StoreIndexConfig(TypedDict, total=False):
|
|
37
|
+
"""Configuration for vector embeddings in store.
|
|
38
|
+
|
|
39
|
+
Enables semantic similarity search using pgvector.
|
|
40
|
+
See: https://github.com/ibbybuilds/aegra/issues/104
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
dims: int
|
|
44
|
+
"""Embedding vector dimensions (e.g., 1536 for OpenAI text-embedding-3-small)"""
|
|
45
|
+
embed: str
|
|
46
|
+
"""Embedding model in format '<provider>:<model-id>'
|
|
47
|
+
Examples:
|
|
48
|
+
- openai:text-embedding-3-small (1536 dims)
|
|
49
|
+
- openai:text-embedding-3-large (3072 dims)
|
|
50
|
+
- bedrock:amazon.titan-embed-text-v2:0 (1024 dims)
|
|
51
|
+
- cohere:embed-english-v3.0 (1024 dims)
|
|
52
|
+
"""
|
|
53
|
+
fields: list[str] | None
|
|
54
|
+
"""JSON fields to embed. Defaults to ["$"] (entire document).
|
|
55
|
+
Examples:
|
|
56
|
+
- ["$"] - Embed entire document as one unit
|
|
57
|
+
- ["text", "summary"] - Embed specific top-level fields
|
|
58
|
+
- ["metadata.title", "content.text"] - JSON path notation
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class StoreConfig(TypedDict, total=False):
|
|
63
|
+
"""Store configuration options"""
|
|
64
|
+
|
|
65
|
+
index: StoreIndexConfig | None
|
|
66
|
+
"""Vector index configuration for semantic search"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AuthConfig(TypedDict, total=False):
|
|
70
|
+
"""Auth configuration options."""
|
|
71
|
+
|
|
72
|
+
path: str
|
|
73
|
+
"""Import path for auth handler in format './file.py:variable' or 'module:variable'.
|
|
74
|
+
Examples:
|
|
75
|
+
- './auth.py:auth' - Load 'auth' from auth.py in project root
|
|
76
|
+
- './src/auth/firebase.py:auth' - Load from nested path
|
|
77
|
+
- 'mypackage.auth:auth' - Load from installed package
|
|
78
|
+
"""
|
|
79
|
+
disable_studio_auth: bool
|
|
80
|
+
"""Disable authentication for LangGraph Studio connections"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_config_path() -> Path | None:
|
|
84
|
+
"""Resolve config file path using standard resolution order.
|
|
85
|
+
|
|
86
|
+
Resolution order:
|
|
87
|
+
1) AEGRA_CONFIG env var (absolute or relative path) - returned even if doesn't exist
|
|
88
|
+
2) aegra.json in CWD
|
|
89
|
+
3) langgraph.json in CWD (fallback for compatibility)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Path to config file or None if not found
|
|
93
|
+
"""
|
|
94
|
+
# 1) Env var override - return even if doesn't exist (let caller handle error)
|
|
95
|
+
if env_path := settings.app.AEGRA_CONFIG:
|
|
96
|
+
return Path(env_path)
|
|
97
|
+
|
|
98
|
+
# 2) aegra.json if present
|
|
99
|
+
aegra_path = Path("aegra.json")
|
|
100
|
+
if aegra_path.exists():
|
|
101
|
+
return aegra_path
|
|
102
|
+
|
|
103
|
+
# 3) fallback to langgraph.json
|
|
104
|
+
langgraph_path = Path("langgraph.json")
|
|
105
|
+
if langgraph_path.exists():
|
|
106
|
+
return langgraph_path
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_config() -> dict | None:
|
|
112
|
+
"""Load full config file using standard resolution order.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Full config dict or None if not found
|
|
116
|
+
"""
|
|
117
|
+
config_path = _resolve_config_path()
|
|
118
|
+
if not config_path:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with config_path.open() as f:
|
|
123
|
+
return json.load(f)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.warning(f"Failed to load config from {config_path}: {e}")
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_http_config() -> HttpConfig | None:
|
|
130
|
+
"""Load HTTP config from aegra.json or langgraph.json.
|
|
131
|
+
|
|
132
|
+
Uses standard config resolution order.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
HTTP configuration dict or None if not found
|
|
136
|
+
"""
|
|
137
|
+
config = load_config()
|
|
138
|
+
if config is None:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
http_config = config.get("http")
|
|
142
|
+
if http_config:
|
|
143
|
+
config_path = _resolve_config_path()
|
|
144
|
+
logger.info(f"Loaded HTTP config from {config_path}")
|
|
145
|
+
return http_config
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_store_config() -> StoreConfig | None:
|
|
151
|
+
"""Load store config from aegra.json or langgraph.json.
|
|
152
|
+
|
|
153
|
+
Uses standard config resolution order.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Store configuration dict or None if not found
|
|
157
|
+
"""
|
|
158
|
+
config = load_config()
|
|
159
|
+
if config is None:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
store_config = config.get("store")
|
|
163
|
+
if store_config:
|
|
164
|
+
config_path = _resolve_config_path()
|
|
165
|
+
logger.info(f"Loaded store config from {config_path}")
|
|
166
|
+
return store_config
|
|
167
|
+
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def load_auth_config() -> AuthConfig | None:
|
|
172
|
+
"""Load auth config from aegra.json or langgraph.json.
|
|
173
|
+
|
|
174
|
+
Uses standard config resolution order.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Auth configuration dict or None if not found
|
|
178
|
+
"""
|
|
179
|
+
config = load_config()
|
|
180
|
+
if config is None:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
auth_config = config.get("auth")
|
|
184
|
+
if auth_config:
|
|
185
|
+
config_path = _resolve_config_path()
|
|
186
|
+
logger.info(f"Loaded auth config from {config_path}")
|
|
187
|
+
return auth_config
|
|
188
|
+
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_config_dir() -> Path | None:
|
|
193
|
+
"""Get the directory containing the config file.
|
|
194
|
+
|
|
195
|
+
This is used to resolve relative paths in the config file
|
|
196
|
+
(graphs, http.app, auth.path) relative to the config location.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Path to config directory or None if no config found
|
|
200
|
+
"""
|
|
201
|
+
config_path = _resolve_config_path()
|
|
202
|
+
if config_path and config_path.exists():
|
|
203
|
+
return config_path.parent.resolve()
|
|
204
|
+
return None
|
aegra_api/constants.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Custom application loader for dynamic FastAPI/Starlette app imports"""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
logger = structlog.get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_custom_app(app_import: str, base_dir: Path | None = None) -> FastAPI | None:
|
|
14
|
+
"""Load custom FastAPI app from import path.
|
|
15
|
+
|
|
16
|
+
Supports both file-based and module-based imports:
|
|
17
|
+
- File path: "./custom_routes.py:app" or "/path/to/file.py:app"
|
|
18
|
+
- Module path: "my_package.custom:app"
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
app_import: Import path in format "path/to/file.py:variable" or "module.path:variable"
|
|
22
|
+
base_dir: Base directory for resolving relative file paths (e.g., config file directory)
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Loaded FastAPI app instance or None if path is invalid
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ImportError: If the module or file cannot be imported
|
|
29
|
+
AttributeError: If the specified variable is not found in the module
|
|
30
|
+
TypeError: If the loaded object is not a FastAPI application
|
|
31
|
+
"""
|
|
32
|
+
logger.info(f"Loading custom app from {app_import}")
|
|
33
|
+
|
|
34
|
+
if ":" not in app_import:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Invalid app import path format: {app_import}. "
|
|
37
|
+
"Expected format: 'path/to/file.py:variable' or 'module.path:variable'"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
path, name = app_import.rsplit(":", 1)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Determine if it's a file path or module path
|
|
44
|
+
path_obj = Path(path)
|
|
45
|
+
is_file_path = path_obj.suffix == ".py" or path.startswith("./") or path.startswith("../")
|
|
46
|
+
|
|
47
|
+
if is_file_path:
|
|
48
|
+
# Resolve relative paths from base_dir if provided
|
|
49
|
+
if not path_obj.is_absolute() and base_dir is not None:
|
|
50
|
+
path_obj = (base_dir / path_obj).resolve()
|
|
51
|
+
|
|
52
|
+
# Import from file path
|
|
53
|
+
if not path_obj.exists():
|
|
54
|
+
raise FileNotFoundError(f"Custom app file not found: {path_obj}")
|
|
55
|
+
|
|
56
|
+
spec = importlib.util.spec_from_file_location("custom_app_module", str(path_obj))
|
|
57
|
+
if spec is None or spec.loader is None:
|
|
58
|
+
raise ImportError(f"Cannot load spec from {path_obj}")
|
|
59
|
+
|
|
60
|
+
module = importlib.util.module_from_spec(spec)
|
|
61
|
+
spec.loader.exec_module(module)
|
|
62
|
+
else:
|
|
63
|
+
# Import as a normal module
|
|
64
|
+
module = importlib.import_module(path)
|
|
65
|
+
|
|
66
|
+
# Get the app instance from the module
|
|
67
|
+
if not hasattr(module, name):
|
|
68
|
+
raise AttributeError(
|
|
69
|
+
f"App '{name}' not found in module '{path}'. "
|
|
70
|
+
f"Available attributes: {[attr for attr in dir(module) if not attr.startswith('_')]}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
user_app = getattr(module, name)
|
|
74
|
+
|
|
75
|
+
# Validate it's a FastAPI application
|
|
76
|
+
if not isinstance(user_app, FastAPI):
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f"Object '{name}' in module '{path}' is not a FastAPI application. "
|
|
79
|
+
"Custom apps must be FastAPI instances for proper OpenAPI support.\n"
|
|
80
|
+
"Please initialize your app using:\n\n"
|
|
81
|
+
"from fastapi import FastAPI\n\n"
|
|
82
|
+
"app = FastAPI()\n\n"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
logger.info(f"Successfully loaded custom app '{name}' from {path}")
|
|
86
|
+
return user_app
|
|
87
|
+
|
|
88
|
+
except ImportError as e:
|
|
89
|
+
raise ImportError(f"Failed to import app module '{path}': {e}") from e
|
|
90
|
+
except AttributeError as e:
|
|
91
|
+
raise AttributeError(f"App '{name}' not found in module '{path}'") from e
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Lightweight context-var helpers for passing authenticated user info into graphs.
|
|
2
|
+
|
|
3
|
+
Graph nodes can access the current request's authentication context by calling
|
|
4
|
+
`get_auth_ctx()`. The server sets the context for the lifetime of a single run
|
|
5
|
+
(using an async context-manager) so the information is automatically scoped and
|
|
6
|
+
cleaned up.
|
|
7
|
+
|
|
8
|
+
The structure follows the standard auth context format so that
|
|
9
|
+
libraries expecting `Auth.types.BaseAuthContext` work unchanged.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextvars
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
|
|
18
|
+
from langgraph_sdk import Auth # type: ignore
|
|
19
|
+
from starlette.authentication import AuthCredentials, BaseUser
|
|
20
|
+
|
|
21
|
+
# Internal context-var storing the current auth context (or None when absent)
|
|
22
|
+
_AuthCtx: contextvars.ContextVar[Auth.types.BaseAuthContext | None] = contextvars.ContextVar( # type: ignore[attr-defined]
|
|
23
|
+
"AuthContext", default=None
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_auth_ctx() -> Auth.types.BaseAuthContext | None: # type: ignore[attr-defined]
|
|
28
|
+
"""Return the current authentication context or ``None`` if not set."""
|
|
29
|
+
return _AuthCtx.get()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def with_auth_ctx(
|
|
34
|
+
user: BaseUser | None,
|
|
35
|
+
permissions: list[str] | AuthCredentials | None = None,
|
|
36
|
+
) -> AsyncIterator[None]:
|
|
37
|
+
"""Temporarily set the auth context for the duration of an async block.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
user
|
|
42
|
+
The authenticated user (or ``None`` for anonymous access).
|
|
43
|
+
permissions
|
|
44
|
+
Either a Starlette ``AuthCredentials`` instance or a list of permission
|
|
45
|
+
strings. ``None`` means no permissions.
|
|
46
|
+
"""
|
|
47
|
+
# Normalize the permissions list
|
|
48
|
+
scopes: list[str] = []
|
|
49
|
+
if isinstance(permissions, AuthCredentials):
|
|
50
|
+
scopes = list(permissions.scopes)
|
|
51
|
+
elif isinstance(permissions, list):
|
|
52
|
+
scopes = permissions
|
|
53
|
+
|
|
54
|
+
if user is None and not scopes:
|
|
55
|
+
token = _AuthCtx.set(None)
|
|
56
|
+
else:
|
|
57
|
+
token = _AuthCtx.set(
|
|
58
|
+
Auth.types.BaseAuthContext( # type: ignore[attr-defined]
|
|
59
|
+
user=user, permissions=scopes
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
yield
|
|
64
|
+
finally:
|
|
65
|
+
_AuthCtx.reset(token)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Authentication dependencies for FastAPI endpoints"""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
from aegra_api.core.auth_middleware import get_auth_backend
|
|
8
|
+
from aegra_api.models.auth import User
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _extract_user_data(user_obj: Any) -> dict[str, Any]:
|
|
12
|
+
"""Extract user data from various object types.
|
|
13
|
+
|
|
14
|
+
Handles dict, objects with to_dict(), and objects with dict() methods.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
user_obj: User object from authentication middleware
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Dictionary containing user data
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(user_obj, dict):
|
|
23
|
+
return user_obj
|
|
24
|
+
if hasattr(user_obj, "to_dict"):
|
|
25
|
+
return user_obj.to_dict()
|
|
26
|
+
if hasattr(user_obj, "dict"):
|
|
27
|
+
return user_obj.dict()
|
|
28
|
+
# Fallback: try to extract known attributes
|
|
29
|
+
return {
|
|
30
|
+
"identity": getattr(user_obj, "identity", str(user_obj)),
|
|
31
|
+
"is_authenticated": getattr(user_obj, "is_authenticated", True),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_user_model(user: Any) -> User:
|
|
36
|
+
"""Convert auth result to User model.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
user: User object from auth backend (LangGraphUser, dict, etc.)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
User model instance with all fields preserved
|
|
43
|
+
"""
|
|
44
|
+
user_data = _extract_user_data(user)
|
|
45
|
+
|
|
46
|
+
# Ensure identity exists
|
|
47
|
+
if "identity" not in user_data:
|
|
48
|
+
raise HTTPException(status_code=401, detail="User identity not provided")
|
|
49
|
+
|
|
50
|
+
# Set display_name default if not provided
|
|
51
|
+
if user_data.get("display_name") is None:
|
|
52
|
+
user_data["display_name"] = user_data["identity"]
|
|
53
|
+
|
|
54
|
+
# Pass all fields through to User model (extra fields allowed via ConfigDict)
|
|
55
|
+
return User(**user_data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def require_auth(request: Request) -> User:
|
|
59
|
+
"""FastAPI dependency for authentication.
|
|
60
|
+
|
|
61
|
+
Replaces Starlette AuthenticationMiddleware by calling the auth backend directly.
|
|
62
|
+
This allows FastAPI to properly track dependencies for OpenAPI generation.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
request: FastAPI request object
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
User object with authentication context including any extra fields
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
HTTPException: If user is not authenticated
|
|
72
|
+
"""
|
|
73
|
+
backend = get_auth_backend()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
result = await backend.authenticate(request)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
raise HTTPException(status_code=401, detail=str(e)) from e
|
|
79
|
+
|
|
80
|
+
if result is None:
|
|
81
|
+
# No auth configured - for now, require auth if backend exists
|
|
82
|
+
# (This will be handled by noop auth in the backend)
|
|
83
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
84
|
+
|
|
85
|
+
credentials, user = result
|
|
86
|
+
|
|
87
|
+
# Set request.scope for backward compatibility
|
|
88
|
+
# (Some code might still read request.scope["user"] or request.user)
|
|
89
|
+
request.scope["user"] = user
|
|
90
|
+
request.scope["auth"] = credentials
|
|
91
|
+
# Also set request.user for Starlette compatibility
|
|
92
|
+
if not hasattr(request, "user"):
|
|
93
|
+
request.user = user
|
|
94
|
+
|
|
95
|
+
# Convert to User model
|
|
96
|
+
return _to_user_model(user)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Type alias for cleaner route signatures
|
|
100
|
+
AuthenticatedUser = Annotated[User, Depends(require_auth)]
|
|
101
|
+
|
|
102
|
+
# For applying to entire routers
|
|
103
|
+
auth_dependency = [Depends(require_auth)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_current_user(request: Request) -> User:
|
|
107
|
+
"""
|
|
108
|
+
Legacy: Extract current user from request context set by middleware or dependency.
|
|
109
|
+
|
|
110
|
+
This function reads from request.scope["user"] which is set by either:
|
|
111
|
+
- The new require_auth() dependency (preferred)
|
|
112
|
+
- The old AuthenticationMiddleware (for backward compatibility)
|
|
113
|
+
|
|
114
|
+
This function passes ALL fields from auth handlers through to the User model,
|
|
115
|
+
allowing custom auth handlers to return extra fields (e.g., subscription_tier,
|
|
116
|
+
team_id) that will be accessible on the User object.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
request: FastAPI request object
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
User object with authentication context including any extra fields
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
HTTPException: If user is not authenticated
|
|
126
|
+
"""
|
|
127
|
+
# Try reading from request.scope first (set by require_auth dependency)
|
|
128
|
+
user = request.scope.get("user")
|
|
129
|
+
if user is None:
|
|
130
|
+
# Fallback to request.user (set by middleware)
|
|
131
|
+
if not hasattr(request, "user") or request.user is None:
|
|
132
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
133
|
+
user = request.user
|
|
134
|
+
|
|
135
|
+
if hasattr(user, "is_authenticated") and not user.is_authenticated:
|
|
136
|
+
raise HTTPException(status_code=401, detail="Invalid authentication")
|
|
137
|
+
|
|
138
|
+
# Convert to User model
|
|
139
|
+
return _to_user_model(user)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_user_id(user: User = Depends(get_current_user)) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Helper dependency to get user ID safely.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
user: User object from get_current_user dependency
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
User identity string
|
|
151
|
+
"""
|
|
152
|
+
return user.identity
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def require_permission(permission: str):
|
|
156
|
+
"""
|
|
157
|
+
Create a dependency that requires a specific permission.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
permission: Required permission string
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Dependency function that checks for the permission
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
@app.get("/admin")
|
|
167
|
+
def admin_endpoint(user: User = Depends(require_permission("admin"))):
|
|
168
|
+
return {"message": "Admin access granted"}
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def permission_dependency(user: User = Depends(get_current_user)) -> User:
|
|
172
|
+
if permission not in user.permissions:
|
|
173
|
+
raise HTTPException(status_code=403, detail=f"Permission '{permission}' required")
|
|
174
|
+
return user
|
|
175
|
+
|
|
176
|
+
return permission_dependency
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def require_authenticated(request: Request) -> User:
|
|
180
|
+
"""
|
|
181
|
+
Simplified dependency that just ensures user is authenticated.
|
|
182
|
+
|
|
183
|
+
This is equivalent to get_current_user but with a clearer name
|
|
184
|
+
for endpoints that just need any authenticated user.
|
|
185
|
+
"""
|
|
186
|
+
return get_current_user(request)
|