svc-infra 0.1.640__py3-none-any.whl → 0.1.664__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 svc-infra might be problematic. Click here for more details.
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +8 -5
- svc_infra/api/fastapi/db/sql/crud_router.py +4 -4
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/setup.py +10 -12
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +25 -11
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +20 -5
- svc_infra/docs/acceptance-matrix.md +17 -0
- svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +127 -0
- svc_infra/docs/storage.md +982 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/METADATA +8 -3
- {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/RECORD +33 -19
- {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
|
@@ -7,7 +7,6 @@ from fastapi import FastAPI
|
|
|
7
7
|
|
|
8
8
|
from svc_infra.apf_payments.provider.registry import get_provider_registry
|
|
9
9
|
from svc_infra.api.fastapi.apf_payments.router import build_payments_routers
|
|
10
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
11
10
|
|
|
12
11
|
logger = logging.getLogger(__name__)
|
|
13
12
|
|
|
@@ -51,7 +50,6 @@ def add_payments(
|
|
|
51
50
|
- Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
|
|
52
51
|
"""
|
|
53
52
|
_maybe_register_default_providers(register_default_providers, adapters)
|
|
54
|
-
add_prefixed_docs(app, prefix=prefix, title="Payments")
|
|
55
53
|
|
|
56
54
|
for r in build_payments_routers(prefix=prefix):
|
|
57
55
|
app.include_router(
|
|
@@ -17,7 +17,6 @@ from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, USER_PREFIX
|
|
|
17
17
|
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
18
18
|
from svc_infra.db.sql.apikey import bind_apikey_model
|
|
19
19
|
|
|
20
|
-
from ..docs.scoped import add_prefixed_docs
|
|
21
20
|
from .policy import AuthPolicy, DefaultAuthPolicy
|
|
22
21
|
from .providers import providers_from_settings
|
|
23
22
|
from .settings import get_auth_settings
|
|
@@ -293,9 +292,6 @@ def add_auth_users(
|
|
|
293
292
|
https_only=bool(getattr(settings_obj, "session_cookie_secure", False)),
|
|
294
293
|
)
|
|
295
294
|
|
|
296
|
-
add_prefixed_docs(app, prefix=user_prefix, title="Users")
|
|
297
|
-
add_prefixed_docs(app, prefix=auth_prefix, title="Auth")
|
|
298
|
-
|
|
299
295
|
if enable_password:
|
|
300
296
|
setup_password_authentication(
|
|
301
297
|
app,
|
|
@@ -373,9 +373,8 @@ async def _update_provider_account(
|
|
|
373
373
|
def _determine_final_redirect_url(request: Request, provider: str, post_login_redirect: str) -> str:
|
|
374
374
|
"""Determine the final redirect URL after successful authentication."""
|
|
375
375
|
st = get_auth_settings()
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
)
|
|
376
|
+
# Prioritize the parameter passed to the router over settings
|
|
377
|
+
redirect_url = str(post_login_redirect or getattr(st, "post_login_redirect", "/"))
|
|
379
378
|
allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
|
|
380
379
|
require_https = bool(getattr(st, "session_cookie_secure", False))
|
|
381
380
|
|
|
@@ -669,7 +668,23 @@ def _create_oauth_router(
|
|
|
669
668
|
ip_hash=None,
|
|
670
669
|
)
|
|
671
670
|
|
|
672
|
-
#
|
|
671
|
+
# Generate JWT token for the response
|
|
672
|
+
strategy = auth_backend.get_strategy()
|
|
673
|
+
jwt_token = await strategy.write_token(user)
|
|
674
|
+
|
|
675
|
+
# If redirecting to a different origin, append token as URL fragment for frontend to extract
|
|
676
|
+
# This handles cross-port scenarios like localhost:8000 -> localhost:3000
|
|
677
|
+
parsed_redirect = urlparse(redirect_url)
|
|
678
|
+
request_origin = f"{request.url.scheme}://{request.url.netloc}"
|
|
679
|
+
redirect_origin = f"{parsed_redirect.scheme}://{parsed_redirect.netloc}"
|
|
680
|
+
|
|
681
|
+
if redirect_origin and redirect_origin != request_origin:
|
|
682
|
+
# Cross-origin redirect: append token as URL fragment
|
|
683
|
+
# Fragment is not sent to server, only accessible to client-side JS
|
|
684
|
+
separator = "#" if not parsed_redirect.fragment else "&"
|
|
685
|
+
redirect_url = f"{redirect_url}{separator}access_token={jwt_token}"
|
|
686
|
+
|
|
687
|
+
# Create response with auth + refresh cookies (for same-origin requests)
|
|
673
688
|
resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
|
674
689
|
await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=raw_refresh)
|
|
675
690
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
|
|
1
3
|
from fastapi import FastAPI
|
|
2
4
|
|
|
3
5
|
from svc_infra.cache.backend import shutdown_cache
|
|
@@ -5,10 +7,12 @@ from svc_infra.cache.decorators import init_cache
|
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
def setup_caching(app: FastAPI) -> None:
|
|
8
|
-
@
|
|
9
|
-
async def
|
|
10
|
+
@asynccontextmanager
|
|
11
|
+
async def lifespan(_app: FastAPI):
|
|
10
12
|
init_cache()
|
|
13
|
+
try:
|
|
14
|
+
yield
|
|
15
|
+
finally:
|
|
16
|
+
await shutdown_cache()
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
async def _shutdown():
|
|
14
|
-
await shutdown_cache()
|
|
18
|
+
app.router.lifespan_context = lifespan
|
|
@@ -38,8 +38,8 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
|
|
41
|
-
@
|
|
42
|
-
async def
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def lifespan(_app: FastAPI):
|
|
43
43
|
if not os.getenv(dsn_env):
|
|
44
44
|
raise RuntimeError(f"Missing environment variable {dsn_env} for Mongo URL")
|
|
45
45
|
await init_mongo()
|
|
@@ -47,10 +47,12 @@ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
|
|
|
47
47
|
db = await acquire_db()
|
|
48
48
|
if expected and db.name != expected:
|
|
49
49
|
raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
await close_mongo()
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
async def _shutdown() -> None:
|
|
53
|
-
await close_mongo()
|
|
55
|
+
app.router.lifespan_context = lifespan
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
def add_mongo_health(
|
|
@@ -62,46 +64,50 @@ def add_mongo_health(
|
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
|
|
65
|
-
for
|
|
67
|
+
for resource in resources:
|
|
66
68
|
repo = NoSqlRepository(
|
|
67
|
-
collection_name=
|
|
68
|
-
id_field=
|
|
69
|
-
soft_delete=
|
|
70
|
-
soft_delete_field=
|
|
71
|
-
soft_delete_flag_field=
|
|
69
|
+
collection_name=resource.resolved_collection(),
|
|
70
|
+
id_field=resource.id_field,
|
|
71
|
+
soft_delete=resource.soft_delete,
|
|
72
|
+
soft_delete_field=resource.soft_delete_field,
|
|
73
|
+
soft_delete_flag_field=resource.soft_delete_flag_field,
|
|
72
74
|
)
|
|
73
|
-
svc =
|
|
75
|
+
svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
|
|
74
76
|
|
|
75
|
-
if
|
|
76
|
-
Read, Create, Update =
|
|
77
|
-
|
|
77
|
+
if resource.read_schema and resource.create_schema and resource.update_schema:
|
|
78
|
+
Read, Create, Update = (
|
|
79
|
+
resource.read_schema,
|
|
80
|
+
resource.create_schema,
|
|
81
|
+
resource.update_schema,
|
|
82
|
+
)
|
|
83
|
+
elif resource.document_model is not None:
|
|
78
84
|
# CRITICAL: teach Pydantic to dump ObjectId/PyObjectId
|
|
79
85
|
Read, Create, Update = make_document_crud_schemas(
|
|
80
|
-
|
|
81
|
-
create_exclude=
|
|
82
|
-
read_name=
|
|
83
|
-
create_name=
|
|
84
|
-
update_name=
|
|
85
|
-
read_exclude=
|
|
86
|
-
update_exclude=
|
|
86
|
+
resource.document_model,
|
|
87
|
+
create_exclude=resource.create_exclude,
|
|
88
|
+
read_name=resource.read_name,
|
|
89
|
+
create_name=resource.create_name,
|
|
90
|
+
update_name=resource.update_name,
|
|
91
|
+
read_exclude=resource.read_exclude,
|
|
92
|
+
update_exclude=resource.update_exclude,
|
|
87
93
|
json_encoders={ObjectId: str, PyObjectId: str},
|
|
88
94
|
)
|
|
89
95
|
else:
|
|
90
96
|
raise RuntimeError(
|
|
91
|
-
f"Resource for collection '{
|
|
97
|
+
f"Resource for collection '{resource.collection}' requires either explicit schemas "
|
|
92
98
|
f"(read/create/update) or a 'document_model' to derive them."
|
|
93
99
|
)
|
|
94
100
|
|
|
95
101
|
router = make_crud_router_plus_mongo(
|
|
96
|
-
collection=
|
|
102
|
+
collection=resource.resolved_collection(),
|
|
97
103
|
repo=repo,
|
|
98
104
|
service=svc,
|
|
99
105
|
read_schema=Read,
|
|
100
106
|
create_schema=Create,
|
|
101
107
|
update_schema=Update,
|
|
102
|
-
prefix=
|
|
103
|
-
tags=
|
|
104
|
-
search_fields=
|
|
108
|
+
prefix=resource.prefix,
|
|
109
|
+
tags=resource.tags,
|
|
110
|
+
search_fields=resource.search_fields,
|
|
105
111
|
default_ordering=None,
|
|
106
112
|
allowed_order_fields=None,
|
|
107
113
|
)
|
|
@@ -86,16 +86,19 @@ def add_sql_db(app: FastAPI, *, url: Optional[str] = None, dsn_env: str = "SQL_U
|
|
|
86
86
|
app.router.lifespan_context = lifespan
|
|
87
87
|
return
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
# Use lifespan context manager instead of deprecated on_event
|
|
90
|
+
@asynccontextmanager
|
|
91
|
+
async def lifespan(_app: FastAPI):
|
|
91
92
|
env_url = os.getenv(dsn_env)
|
|
92
93
|
if not env_url:
|
|
93
94
|
raise RuntimeError(f"Missing environment variable {dsn_env} for database URL")
|
|
94
95
|
initialize_session(env_url)
|
|
96
|
+
try:
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
await dispose_session()
|
|
95
100
|
|
|
96
|
-
|
|
97
|
-
async def _shutdown() -> None: # noqa: ANN202
|
|
98
|
-
await dispose_session()
|
|
101
|
+
app.router.lifespan_context = lifespan
|
|
99
102
|
|
|
100
103
|
|
|
101
104
|
def add_sql_health(
|
|
@@ -122,7 +122,7 @@ def make_crud_router_plus_sql(
|
|
|
122
122
|
)
|
|
123
123
|
async def create_item(
|
|
124
124
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
125
|
-
payload:
|
|
125
|
+
payload: create_schema = Body(...),
|
|
126
126
|
):
|
|
127
127
|
if isinstance(payload, BaseModel):
|
|
128
128
|
data = payload.model_dump(exclude_unset=True)
|
|
@@ -141,7 +141,7 @@ def make_crud_router_plus_sql(
|
|
|
141
141
|
async def update_item(
|
|
142
142
|
item_id: Any,
|
|
143
143
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
144
|
-
payload:
|
|
144
|
+
payload: update_schema = Body(...),
|
|
145
145
|
):
|
|
146
146
|
if isinstance(payload, BaseModel):
|
|
147
147
|
data = payload.model_dump(exclude_unset=True)
|
|
@@ -266,7 +266,7 @@ def make_tenant_crud_router_plus_sql(
|
|
|
266
266
|
async def create_item(
|
|
267
267
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
268
268
|
tenant_id: TenantId,
|
|
269
|
-
payload:
|
|
269
|
+
payload: create_schema = Body(...),
|
|
270
270
|
):
|
|
271
271
|
svc = await _svc(session, tenant_id)
|
|
272
272
|
if isinstance(payload, BaseModel):
|
|
@@ -282,7 +282,7 @@ def make_tenant_crud_router_plus_sql(
|
|
|
282
282
|
item_id: Any,
|
|
283
283
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
284
284
|
tenant_id: TenantId,
|
|
285
|
-
payload:
|
|
285
|
+
payload: update_schema = Body(...),
|
|
286
286
|
):
|
|
287
287
|
svc = await _svc(session, tenant_id)
|
|
288
288
|
if isinstance(payload, BaseModel):
|
|
@@ -65,11 +65,18 @@ def _close_over_component_refs(
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
def _prune_to_paths(
|
|
68
|
-
full_schema: Dict,
|
|
68
|
+
full_schema: Dict,
|
|
69
|
+
keep_paths: Dict[str, dict],
|
|
70
|
+
title_suffix: Optional[str],
|
|
71
|
+
server_prefix: Optional[str] = None,
|
|
69
72
|
) -> Dict:
|
|
70
73
|
schema = copy.deepcopy(full_schema)
|
|
71
74
|
schema["paths"] = keep_paths
|
|
72
75
|
|
|
76
|
+
# Set server URL for scoped docs
|
|
77
|
+
if server_prefix is not None:
|
|
78
|
+
schema["servers"] = [{"url": server_prefix}]
|
|
79
|
+
|
|
73
80
|
used_tags: Set[str] = set()
|
|
74
81
|
direct_refs: Set[Tuple[str, str]] = set()
|
|
75
82
|
used_security_schemes: Set[str] = set()
|
|
@@ -124,7 +131,26 @@ def _build_filtered_schema(
|
|
|
124
131
|
keep_paths = {
|
|
125
132
|
p: v for p, v in paths.items() if _path_included(p, include_prefixes, exclude_prefixes)
|
|
126
133
|
}
|
|
127
|
-
|
|
134
|
+
|
|
135
|
+
# Determine the server prefix for scoped docs
|
|
136
|
+
server_prefix = None
|
|
137
|
+
if include_prefixes and len(include_prefixes) == 1:
|
|
138
|
+
# Single include prefix = scoped docs
|
|
139
|
+
server_prefix = include_prefixes[0].rstrip("/") or "/"
|
|
140
|
+
|
|
141
|
+
# Strip prefix from paths to make them relative to the server
|
|
142
|
+
stripped_paths = {}
|
|
143
|
+
for path, spec in keep_paths.items():
|
|
144
|
+
if path.startswith(server_prefix) and path != server_prefix:
|
|
145
|
+
# Remove prefix, keeping the leading slash
|
|
146
|
+
relative_path = path[len(server_prefix) :]
|
|
147
|
+
stripped_paths[relative_path] = spec
|
|
148
|
+
else:
|
|
149
|
+
# Path equals prefix or doesn't start with it
|
|
150
|
+
stripped_paths[path] = spec
|
|
151
|
+
keep_paths = stripped_paths
|
|
152
|
+
|
|
153
|
+
return _prune_to_paths(full_schema, keep_paths, title_suffix, server_prefix=server_prefix)
|
|
128
154
|
|
|
129
155
|
|
|
130
156
|
def _ensure_original_openapi_saved(app: FastAPI) -> None:
|
|
@@ -175,11 +201,23 @@ def add_prefixed_docs(
|
|
|
175
201
|
auto_exclude_from_root: bool = True,
|
|
176
202
|
visible_envs: Optional[Iterable[Environment | str]] = (LOCAL_ENV, DEV_ENV),
|
|
177
203
|
) -> None:
|
|
204
|
+
scope = prefix.rstrip("/") or "/"
|
|
205
|
+
|
|
206
|
+
# Always exclude from root if requested, regardless of environment
|
|
207
|
+
if auto_exclude_from_root:
|
|
208
|
+
_ensure_original_openapi_saved(app)
|
|
209
|
+
# Add to exclusion list for root docs
|
|
210
|
+
if not hasattr(app.state, "_scoped_root_exclusions"):
|
|
211
|
+
app.state._scoped_root_exclusions = []
|
|
212
|
+
if scope not in app.state._scoped_root_exclusions:
|
|
213
|
+
app.state._scoped_root_exclusions.append(scope)
|
|
214
|
+
_install_root_filter(app, app.state._scoped_root_exclusions)
|
|
215
|
+
|
|
216
|
+
# Only create scoped docs in allowed environments
|
|
178
217
|
allow = _normalize_envs(visible_envs)
|
|
179
218
|
if allow is not None and CURRENT_ENVIRONMENT not in allow:
|
|
180
219
|
return
|
|
181
220
|
|
|
182
|
-
scope = prefix.rstrip("/") or "/"
|
|
183
221
|
openapi_path = f"{scope}/openapi.json"
|
|
184
222
|
swagger_path = f"{scope}/docs"
|
|
185
223
|
redoc_path = f"{scope}/redoc"
|
|
@@ -211,9 +249,6 @@ def add_prefixed_docs(
|
|
|
211
249
|
|
|
212
250
|
DOC_SCOPES.append((scope, swagger_path, redoc_path, openapi_path, title))
|
|
213
251
|
|
|
214
|
-
if auto_exclude_from_root:
|
|
215
|
-
_ensure_root_excludes_registered_scopes(app)
|
|
216
|
-
|
|
217
252
|
|
|
218
253
|
def replace_root_openapi_with_exclusions(app: FastAPI, *, exclude_prefixes: List[str]) -> None:
|
|
219
254
|
_install_root_filter(app, exclude_prefixes)
|
svc_infra/api/fastapi/setup.py
CHANGED
|
@@ -158,8 +158,7 @@ def _build_parent_app(
|
|
|
158
158
|
root_server_url: str | None = None,
|
|
159
159
|
root_include_api_key: bool = False,
|
|
160
160
|
) -> FastAPI:
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
# Root docs are now enabled in all environments to match root card visibility
|
|
163
162
|
parent = FastAPI(
|
|
164
163
|
title=service.name,
|
|
165
164
|
version=service.release,
|
|
@@ -167,9 +166,9 @@ def _build_parent_app(
|
|
|
167
166
|
license_info=_dump_or_none(service.license),
|
|
168
167
|
terms_of_service=service.terms_of_service,
|
|
169
168
|
description=service.description,
|
|
170
|
-
docs_url=
|
|
171
|
-
redoc_url=
|
|
172
|
-
openapi_url=
|
|
169
|
+
docs_url="/docs",
|
|
170
|
+
redoc_url="/redoc",
|
|
171
|
+
openapi_url="/openapi.json",
|
|
173
172
|
)
|
|
174
173
|
|
|
175
174
|
_setup_cors(parent, public_cors_origins)
|
|
@@ -247,14 +246,13 @@ def setup_service_api(
|
|
|
247
246
|
cards: list[CardSpec] = []
|
|
248
247
|
is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
|
|
249
248
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
256
|
-
)
|
|
249
|
+
# Root card - always show in all environments
|
|
250
|
+
cards.append(
|
|
251
|
+
CardSpec(
|
|
252
|
+
tag="",
|
|
253
|
+
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
257
254
|
)
|
|
255
|
+
)
|
|
258
256
|
|
|
259
257
|
# Version cards
|
|
260
258
|
for spec in versions:
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for capturing routers from add_* functions for versioned routing.
|
|
3
|
+
|
|
4
|
+
This module provides helpers to use integration functions (add_banking, add_payments, etc.)
|
|
5
|
+
under versioned routing without creating separate documentation cards.
|
|
6
|
+
|
|
7
|
+
See: svc-infra/docs/versioned-integrations.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, TypeVar
|
|
13
|
+
from unittest.mock import patch
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, FastAPI
|
|
16
|
+
|
|
17
|
+
__all__ = ["extract_router"]
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_router(
|
|
23
|
+
add_function: Callable[..., T],
|
|
24
|
+
*,
|
|
25
|
+
prefix: str,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> tuple[APIRouter, T]:
|
|
28
|
+
"""
|
|
29
|
+
Capture the router from an add_* function for versioned mounting.
|
|
30
|
+
|
|
31
|
+
This allows you to use integration functions like add_banking(), add_payments(),
|
|
32
|
+
etc. under versioned routing (e.g., /v0/banking) without creating separate
|
|
33
|
+
documentation cards.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
add_function: The add_* function to capture from (e.g., add_banking)
|
|
37
|
+
prefix: URL prefix for the routes (e.g., "/banking")
|
|
38
|
+
**kwargs: Arguments to pass to the add_function
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (router, return_value) where:
|
|
42
|
+
- router: The captured APIRouter with all routes
|
|
43
|
+
- return_value: The original return value from add_function (e.g., provider instance)
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
```python
|
|
47
|
+
# In routers/v0/banking.py
|
|
48
|
+
from svc_infra.api.fastapi.versioned import extract_router
|
|
49
|
+
from fin_infra.banking import add_banking
|
|
50
|
+
|
|
51
|
+
router, banking_provider = extract_router(
|
|
52
|
+
add_banking,
|
|
53
|
+
prefix="/banking",
|
|
54
|
+
provider="plaid",
|
|
55
|
+
cache_ttl=60,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# svc-infra auto-discovers 'router' and mounts at /v0/banking
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Pattern:
|
|
62
|
+
1. Creates a mock FastAPI app
|
|
63
|
+
2. Intercepts include_router to capture the router
|
|
64
|
+
3. Patches add_prefixed_docs to prevent separate card creation
|
|
65
|
+
4. Calls the add_function which creates all routes
|
|
66
|
+
5. Returns the captured router for auto-discovery
|
|
67
|
+
|
|
68
|
+
See Also:
|
|
69
|
+
- docs/versioned-integrations.md: Full pattern documentation
|
|
70
|
+
- api/fastapi/dual/public.py: Similar pattern for dual routers
|
|
71
|
+
"""
|
|
72
|
+
# Create mock app to capture router
|
|
73
|
+
mock_app = FastAPI()
|
|
74
|
+
captured_router: APIRouter | None = None
|
|
75
|
+
|
|
76
|
+
def _capture_router(router: APIRouter, **_kwargs: Any) -> None:
|
|
77
|
+
"""Intercept include_router to capture instead of mount."""
|
|
78
|
+
nonlocal captured_router
|
|
79
|
+
captured_router = router
|
|
80
|
+
|
|
81
|
+
mock_app.include_router = _capture_router # type: ignore[assignment]
|
|
82
|
+
|
|
83
|
+
# Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
|
|
84
|
+
def _noop_docs(*args: Any, **kwargs: Any) -> None:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# Call add_function with patches active
|
|
88
|
+
with patch("svc_infra.api.fastapi.docs.scoped.add_prefixed_docs", _noop_docs):
|
|
89
|
+
result = add_function(
|
|
90
|
+
mock_app,
|
|
91
|
+
prefix=prefix,
|
|
92
|
+
**kwargs,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if captured_router is None:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"Failed to capture router from {add_function.__name__}. "
|
|
98
|
+
f"The function may not call app.include_router()."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return captured_router, result
|
|
@@ -129,62 +129,13 @@ for _ix in make_unique_sql_indexes(
|
|
|
129
129
|
# Registered with Table metadata (alembic/autogenerate will pick them up)
|
|
130
130
|
pass
|
|
131
131
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
class
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
- Optionally stores tokens for later API calls (refresh_token encrypted at rest)
|
|
140
|
-
"""
|
|
141
|
-
__tablename__ = "provider_accounts"
|
|
142
|
-
|
|
143
|
-
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
144
|
-
|
|
145
|
-
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
146
|
-
GUID(), ForeignKey("${auth_table_name}.id", ondelete="CASCADE"), nullable=False
|
|
147
|
-
)
|
|
148
|
-
user: Mapped["${AuthEntity}"] = relationship(
|
|
149
|
-
back_populates="provider_accounts",
|
|
150
|
-
lazy="selectin",
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
provider: Mapped[str] = mapped_column(String(50), nullable=False) # "google"|"github"|"linkedin"|"microsoft"|...
|
|
154
|
-
provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False) # sub/oid (OIDC) or id (github/linkedin)
|
|
155
|
-
|
|
156
|
-
# Optional token material
|
|
157
|
-
access_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
158
|
-
|
|
159
|
-
# Store encrypted refresh_token in the same column name for DB compatibility.
|
|
160
|
-
_refresh_token: Mapped[Optional[str]] = mapped_column("refresh_token", Text, nullable=True)
|
|
161
|
-
|
|
162
|
-
@property
|
|
163
|
-
def refresh_token(self) -> Optional[str]:
|
|
164
|
-
return _decrypt(self._refresh_token)
|
|
165
|
-
|
|
166
|
-
@refresh_token.setter
|
|
167
|
-
def refresh_token(self, value: Optional[str]) -> None:
|
|
168
|
-
self._refresh_token = _encrypt(value)
|
|
169
|
-
|
|
170
|
-
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
171
|
-
raw_claims: Mapped[Optional[dict]] = mapped_column(MutableDict.as_mutable(JSON), nullable=True)
|
|
172
|
-
|
|
173
|
-
created_at = mapped_column(
|
|
174
|
-
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
175
|
-
)
|
|
176
|
-
updated_at = mapped_column(
|
|
177
|
-
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"),
|
|
178
|
-
onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
__table_args__ = (
|
|
182
|
-
UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
|
|
183
|
-
Index("ix_provider_accounts_user_id", "user_id"),
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
def __repr__(self) -> str:
|
|
187
|
-
return f"<ProviderAccount provider={self.provider!r} provider_account_id={self.provider_account_id!r} user_id={self.user_id}>"
|
|
132
|
+
# NOTE: ProviderAccount model is imported from svc_infra.security.oauth_models
|
|
133
|
+
# It's an opt-in OAuth model that links users to providers (Google, GitHub, etc.)
|
|
134
|
+
# The relationship 'provider_accounts' is defined above in the ${AuthEntity} class.
|
|
135
|
+
# To enable OAuth in your project:
|
|
136
|
+
# 1. Set ALEMBIC_ENABLE_OAUTH=true in your .env
|
|
137
|
+
# 2. Pass provider_account_model=ProviderAccount to add_auth_users()
|
|
138
|
+
# 3. Import: from svc_infra.security.oauth_models import ProviderAccount
|
|
188
139
|
|
|
189
140
|
# --- Auth service factory ------------------------------------------------------
|
|
190
141
|
|
|
@@ -6,13 +6,10 @@ from typing import List, Tuple
|
|
|
6
6
|
import sys, pathlib, importlib, pkgutil, traceback
|
|
7
7
|
|
|
8
8
|
from alembic import context
|
|
9
|
+
from sqlalchemy import MetaData
|
|
9
10
|
from sqlalchemy.engine import make_url, URL
|
|
10
|
-
from sqlalchemy.ext.asyncio import create_async_engine
|
|
11
11
|
|
|
12
|
-
from svc_infra.db.sql.utils import
|
|
13
|
-
get_database_url_from_env,
|
|
14
|
-
_ensure_ssl_default_async as _ensure_ssl_default,
|
|
15
|
-
)
|
|
12
|
+
from svc_infra.db.sql.utils import get_database_url_from_env
|
|
16
13
|
|
|
17
14
|
try:
|
|
18
15
|
from svc_infra.db.sql.types import GUID as _GUID # type: ignore
|
|
@@ -105,7 +102,6 @@ def _coerce_to_async(u: URL) -> URL:
|
|
|
105
102
|
|
|
106
103
|
u = make_url(effective_url)
|
|
107
104
|
u = _coerce_to_async(u)
|
|
108
|
-
u = _ensure_ssl_default(u)
|
|
109
105
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
110
106
|
|
|
111
107
|
# feature flags
|
|
@@ -131,15 +127,16 @@ def _collect_metadata() -> list[object]:
|
|
|
131
127
|
|
|
132
128
|
def _maybe_add(obj: object) -> None:
|
|
133
129
|
md = getattr(obj, "metadata", None) or obj
|
|
134
|
-
|
|
130
|
+
# Strict check: must be actual MetaData instance
|
|
131
|
+
if isinstance(md, MetaData) and md.tables:
|
|
135
132
|
found.append(md)
|
|
136
133
|
|
|
137
134
|
def _scan_module_objects(mod: object) -> None:
|
|
138
135
|
try:
|
|
139
136
|
for val in vars(mod).values():
|
|
140
|
-
|
|
141
|
-
if
|
|
142
|
-
found.append(
|
|
137
|
+
# Strict check: must be actual MetaData instance
|
|
138
|
+
if isinstance(val, MetaData) and val.tables:
|
|
139
|
+
found.append(val)
|
|
143
140
|
except Exception:
|
|
144
141
|
pass
|
|
145
142
|
|
|
@@ -229,6 +226,21 @@ def _collect_metadata() -> list[object]:
|
|
|
229
226
|
except Exception:
|
|
230
227
|
_note("ModelBase import", False, traceback.format_exc())
|
|
231
228
|
|
|
229
|
+
# Core security models (AuthSession, RefreshToken, etc.)
|
|
230
|
+
try:
|
|
231
|
+
import svc_infra.security.models # noqa: F401
|
|
232
|
+
_note("svc_infra.security.models", True, None)
|
|
233
|
+
except Exception:
|
|
234
|
+
_note("svc_infra.security.models", False, traceback.format_exc())
|
|
235
|
+
|
|
236
|
+
# OAuth models (opt-in via environment variable)
|
|
237
|
+
if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
|
|
238
|
+
try:
|
|
239
|
+
import svc_infra.security.oauth_models # noqa: F401
|
|
240
|
+
_note("svc_infra.security.oauth_models", True, None)
|
|
241
|
+
except Exception:
|
|
242
|
+
_note("svc_infra.security.oauth_models", False, traceback.format_exc())
|
|
243
|
+
|
|
232
244
|
try:
|
|
233
245
|
from svc_infra.db.sql.apikey import try_autobind_apikey_model
|
|
234
246
|
try_autobind_apikey_model(require_env=False)
|
|
@@ -360,7 +372,9 @@ def _do_run_migrations(connection):
|
|
|
360
372
|
|
|
361
373
|
async def run_migrations_online() -> None:
|
|
362
374
|
url = config.get_main_option("sqlalchemy.url")
|
|
363
|
-
|
|
375
|
+
# Use build_engine to ensure proper driver-specific handling (e.g., asyncpg SSL)
|
|
376
|
+
from svc_infra.db.sql.utils import build_engine
|
|
377
|
+
engine = build_engine(url)
|
|
364
378
|
async with engine.connect() as connection:
|
|
365
379
|
await connection.run_sync(_do_run_migrations)
|
|
366
380
|
await engine.dispose()
|