mdb-engine 0.2.3__py3-none-any.whl → 0.3.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.
- mdb_engine/__init__.py +8 -1
- mdb_engine/auth/README.md +6 -0
- mdb_engine/auth/shared_middleware.py +330 -119
- mdb_engine/core/engine.py +417 -5
- mdb_engine/core/manifest.py +114 -0
- mdb_engine/embeddings/service.py +37 -8
- mdb_engine/memory/README.md +93 -2
- mdb_engine/memory/service.py +348 -1096
- mdb_engine/utils/__init__.py +3 -1
- mdb_engine/utils/mongo.py +117 -0
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.3.0.dist-info}/METADATA +193 -14
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.3.0.dist-info}/RECORD +16 -15
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.3.0.dist-info}/WHEEL +1 -1
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.3.0.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.3.0.dist-info}/top_level.txt +0 -0
mdb_engine/core/engine.py
CHANGED
|
@@ -1095,13 +1095,14 @@ class MongoDBEngine:
|
|
|
1095
1095
|
| None = None,
|
|
1096
1096
|
on_shutdown: Callable[["FastAPI", "MongoDBEngine", dict[str, Any]], Awaitable[None]]
|
|
1097
1097
|
| None = None,
|
|
1098
|
+
is_sub_app: bool = False,
|
|
1098
1099
|
**fastapi_kwargs: Any,
|
|
1099
1100
|
) -> "FastAPI":
|
|
1100
1101
|
"""
|
|
1101
1102
|
Create a FastAPI application with proper lifespan management.
|
|
1102
1103
|
|
|
1103
1104
|
This method creates a FastAPI app that:
|
|
1104
|
-
1. Initializes the engine on startup
|
|
1105
|
+
1. Initializes the engine on startup (unless is_sub_app=True)
|
|
1105
1106
|
2. Loads and registers the manifest
|
|
1106
1107
|
3. Auto-detects multi-site mode from manifest
|
|
1107
1108
|
4. Auto-configures auth based on manifest auth.mode:
|
|
@@ -1119,6 +1120,9 @@ class MongoDBEngine:
|
|
|
1119
1120
|
Signature: async def callback(app, engine, manifest) -> None
|
|
1120
1121
|
on_shutdown: Optional async callback called before engine shutdown.
|
|
1121
1122
|
Signature: async def callback(app, engine, manifest) -> None
|
|
1123
|
+
is_sub_app: If True, skip engine initialization and lifespan management.
|
|
1124
|
+
Used when mounting as a child app in create_multi_app().
|
|
1125
|
+
Defaults to False for backward compatibility.
|
|
1122
1126
|
**fastapi_kwargs: Additional arguments passed to FastAPI()
|
|
1123
1127
|
|
|
1124
1128
|
Returns:
|
|
@@ -1177,8 +1181,9 @@ class MongoDBEngine:
|
|
|
1177
1181
|
"""Lifespan context manager for initialization and cleanup."""
|
|
1178
1182
|
nonlocal app_manifest, is_multi_site
|
|
1179
1183
|
|
|
1180
|
-
# Initialize engine
|
|
1181
|
-
|
|
1184
|
+
# Initialize engine (skip if sub-app - parent manages lifecycle)
|
|
1185
|
+
if not is_sub_app:
|
|
1186
|
+
await engine.initialize()
|
|
1182
1187
|
|
|
1183
1188
|
# Load and register manifest
|
|
1184
1189
|
app_manifest = await engine.load_manifest(manifest_path)
|
|
@@ -1207,7 +1212,20 @@ class MongoDBEngine:
|
|
|
1207
1212
|
logger.info(f"Shared auth mode for '{slug}' - SSO enabled")
|
|
1208
1213
|
# Initialize shared user pool and set on app.state
|
|
1209
1214
|
# Middleware was already added at app creation time (lazy version)
|
|
1210
|
-
|
|
1215
|
+
# For sub-apps, check if parent already initialized user pool
|
|
1216
|
+
if is_sub_app:
|
|
1217
|
+
# Check if parent app has user_pool (set by parent's initialization)
|
|
1218
|
+
# If not, initialize it (shouldn't happen, but handle gracefully)
|
|
1219
|
+
if not hasattr(app.state, "user_pool") or app.state.user_pool is None:
|
|
1220
|
+
logger.warning(
|
|
1221
|
+
f"Sub-app '{slug}' uses shared auth but user_pool not found. "
|
|
1222
|
+
"Initializing now (parent should have initialized it)."
|
|
1223
|
+
)
|
|
1224
|
+
await engine._initialize_shared_user_pool(app, app_manifest)
|
|
1225
|
+
else:
|
|
1226
|
+
logger.debug(f"Sub-app '{slug}' using shared user_pool from parent app")
|
|
1227
|
+
else:
|
|
1228
|
+
await engine._initialize_shared_user_pool(app, app_manifest)
|
|
1211
1229
|
else:
|
|
1212
1230
|
logger.info(f"Per-app auth mode for '{slug}'")
|
|
1213
1231
|
# Auto-retrieve app token for "app" mode
|
|
@@ -1416,7 +1434,9 @@ class MongoDBEngine:
|
|
|
1416
1434
|
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1417
1435
|
logger.warning(f"on_shutdown callback failed for '{slug}': {e}")
|
|
1418
1436
|
|
|
1419
|
-
|
|
1437
|
+
# Shutdown engine (skip if sub-app - parent manages lifecycle)
|
|
1438
|
+
if not is_sub_app:
|
|
1439
|
+
await engine.shutdown()
|
|
1420
1440
|
|
|
1421
1441
|
# Create FastAPI app
|
|
1422
1442
|
app = FastAPI(title=app_title, lifespan=lifespan, **fastapi_kwargs)
|
|
@@ -1502,6 +1522,398 @@ class MongoDBEngine:
|
|
|
1502
1522
|
|
|
1503
1523
|
return app
|
|
1504
1524
|
|
|
1525
|
+
def _validate_path_prefixes(self, apps: list[dict[str, Any]]) -> tuple[bool, list[str]]:
|
|
1526
|
+
"""
|
|
1527
|
+
Validate path prefixes for multi-app mounting.
|
|
1528
|
+
|
|
1529
|
+
Checks:
|
|
1530
|
+
- All prefixes start with '/'
|
|
1531
|
+
- No prefix is a prefix of another (e.g., '/app' conflicts with '/app/v2')
|
|
1532
|
+
- No conflicts with reserved paths ('/health', '/docs', '/openapi.json')
|
|
1533
|
+
|
|
1534
|
+
Args:
|
|
1535
|
+
apps: List of app configs with 'path_prefix' keys
|
|
1536
|
+
|
|
1537
|
+
Returns:
|
|
1538
|
+
Tuple of (is_valid, list_of_errors)
|
|
1539
|
+
"""
|
|
1540
|
+
errors: list[str] = []
|
|
1541
|
+
reserved_paths = {"/health", "/docs", "/openapi.json", "/redoc"}
|
|
1542
|
+
|
|
1543
|
+
# Extract path prefixes
|
|
1544
|
+
path_prefixes: list[str] = []
|
|
1545
|
+
for app_config in apps:
|
|
1546
|
+
path_prefix = app_config.get("path_prefix", f"/{app_config.get('slug', 'unknown')}")
|
|
1547
|
+
if not path_prefix.startswith("/"):
|
|
1548
|
+
app_slug = app_config.get("slug", "unknown")
|
|
1549
|
+
errors.append(f"Path prefix '{path_prefix}' must start with '/' (app: {app_slug})")
|
|
1550
|
+
continue
|
|
1551
|
+
path_prefixes.append(path_prefix)
|
|
1552
|
+
|
|
1553
|
+
# Check for conflicts with reserved paths
|
|
1554
|
+
for prefix in path_prefixes:
|
|
1555
|
+
if prefix in reserved_paths:
|
|
1556
|
+
errors.append(
|
|
1557
|
+
f"Path prefix '{prefix}' conflicts with reserved path. "
|
|
1558
|
+
"Reserved paths: /health, /docs, /openapi.json, /redoc"
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
# Check for prefix conflicts (one prefix being a prefix of another)
|
|
1562
|
+
path_prefixes_sorted = sorted(path_prefixes)
|
|
1563
|
+
for i, prefix1 in enumerate(path_prefixes_sorted):
|
|
1564
|
+
for prefix2 in path_prefixes_sorted[i + 1 :]:
|
|
1565
|
+
if prefix1.startswith(prefix2) or prefix2.startswith(prefix1):
|
|
1566
|
+
errors.append(
|
|
1567
|
+
f"Path prefix conflict: '{prefix1}' and '{prefix2}' overlap. "
|
|
1568
|
+
"One cannot be a prefix of another."
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
# Check for duplicates
|
|
1572
|
+
if len(path_prefixes) != len(set(path_prefixes)):
|
|
1573
|
+
seen = set()
|
|
1574
|
+
for prefix in path_prefixes:
|
|
1575
|
+
if prefix in seen:
|
|
1576
|
+
errors.append(f"Duplicate path prefix: '{prefix}'")
|
|
1577
|
+
seen.add(prefix)
|
|
1578
|
+
|
|
1579
|
+
return len(errors) == 0, errors
|
|
1580
|
+
|
|
1581
|
+
def create_multi_app(
|
|
1582
|
+
self,
|
|
1583
|
+
apps: list[dict[str, Any]] | None = None,
|
|
1584
|
+
multi_app_manifest: Path | None = None,
|
|
1585
|
+
title: str = "Multi-App API",
|
|
1586
|
+
root_path: str = "",
|
|
1587
|
+
**fastapi_kwargs: Any,
|
|
1588
|
+
) -> "FastAPI":
|
|
1589
|
+
"""
|
|
1590
|
+
Create a parent FastAPI app that mounts multiple child apps.
|
|
1591
|
+
|
|
1592
|
+
Each child app is mounted at a path prefix (e.g., /auth-hub, /pwd-zero) and
|
|
1593
|
+
maintains its own routes, middleware, and state while sharing the engine instance.
|
|
1594
|
+
|
|
1595
|
+
Args:
|
|
1596
|
+
apps: List of app configurations. Each dict should have:
|
|
1597
|
+
- slug: App slug (required)
|
|
1598
|
+
- manifest: Path to manifest.json (required)
|
|
1599
|
+
- path_prefix: Optional path prefix (defaults to /{slug})
|
|
1600
|
+
- on_startup: Optional startup callback function
|
|
1601
|
+
- on_shutdown: Optional shutdown callback function
|
|
1602
|
+
multi_app_manifest: Path to a multi-app manifest.json that defines all apps.
|
|
1603
|
+
Format:
|
|
1604
|
+
{
|
|
1605
|
+
"multi_app": {
|
|
1606
|
+
"enabled": true,
|
|
1607
|
+
"apps": [
|
|
1608
|
+
{
|
|
1609
|
+
"slug": "app1",
|
|
1610
|
+
"manifest": "./app1/manifest.json",
|
|
1611
|
+
"path_prefix": "/app1",
|
|
1612
|
+
}
|
|
1613
|
+
]
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
title: Title for the parent FastAPI app
|
|
1617
|
+
root_path: Root path prefix for all mounted apps (optional)
|
|
1618
|
+
**fastapi_kwargs: Additional arguments passed to FastAPI()
|
|
1619
|
+
|
|
1620
|
+
Returns:
|
|
1621
|
+
Parent FastAPI application with all child apps mounted
|
|
1622
|
+
|
|
1623
|
+
Raises:
|
|
1624
|
+
ValueError: If configuration is invalid or path prefixes conflict
|
|
1625
|
+
RuntimeError: If engine is not initialized
|
|
1626
|
+
|
|
1627
|
+
Example:
|
|
1628
|
+
# Programmatic approach
|
|
1629
|
+
engine = MongoDBEngine(mongo_uri=..., db_name=...)
|
|
1630
|
+
app = engine.create_multi_app(
|
|
1631
|
+
apps=[
|
|
1632
|
+
{
|
|
1633
|
+
"slug": "auth-hub",
|
|
1634
|
+
"manifest": Path("./auth-hub/manifest.json"),
|
|
1635
|
+
"path_prefix": "/auth-hub",
|
|
1636
|
+
},
|
|
1637
|
+
{
|
|
1638
|
+
"slug": "pwd-zero",
|
|
1639
|
+
"manifest": Path("./pwd-zero/manifest.json"),
|
|
1640
|
+
"path_prefix": "/pwd-zero",
|
|
1641
|
+
},
|
|
1642
|
+
]
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
# Manifest-based approach
|
|
1646
|
+
app = engine.create_multi_app(
|
|
1647
|
+
multi_app_manifest=Path("./multi_app_manifest.json")
|
|
1648
|
+
)
|
|
1649
|
+
"""
|
|
1650
|
+
import json
|
|
1651
|
+
|
|
1652
|
+
from fastapi import FastAPI
|
|
1653
|
+
|
|
1654
|
+
engine = self
|
|
1655
|
+
|
|
1656
|
+
# Load configuration from manifest or apps parameter
|
|
1657
|
+
if multi_app_manifest:
|
|
1658
|
+
manifest_path = Path(multi_app_manifest)
|
|
1659
|
+
with open(manifest_path) as f:
|
|
1660
|
+
multi_app_config = json.load(f)
|
|
1661
|
+
|
|
1662
|
+
multi_app_section = multi_app_config.get("multi_app", {})
|
|
1663
|
+
if not multi_app_section.get("enabled", False):
|
|
1664
|
+
raise ValueError(
|
|
1665
|
+
"multi_app.enabled must be True in multi_app_manifest to use multi-app mode"
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
apps_config = multi_app_section.get("apps", [])
|
|
1669
|
+
if not apps_config:
|
|
1670
|
+
raise ValueError("multi_app.apps must contain at least one app")
|
|
1671
|
+
|
|
1672
|
+
# Resolve manifest paths relative to multi_app_manifest location
|
|
1673
|
+
manifest_dir = manifest_path.parent
|
|
1674
|
+
apps = []
|
|
1675
|
+
for app_config in apps_config:
|
|
1676
|
+
manifest_rel_path = app_config.get("manifest")
|
|
1677
|
+
if not manifest_rel_path:
|
|
1678
|
+
raise ValueError(f"App '{app_config.get('slug')}' missing 'manifest' field")
|
|
1679
|
+
|
|
1680
|
+
# Resolve relative to multi_app_manifest location
|
|
1681
|
+
manifest_full_path = (manifest_dir / manifest_rel_path).resolve()
|
|
1682
|
+
slug = app_config.get("slug")
|
|
1683
|
+
path_prefix = app_config.get("path_prefix", f"/{slug}")
|
|
1684
|
+
|
|
1685
|
+
apps.append(
|
|
1686
|
+
{
|
|
1687
|
+
"slug": slug,
|
|
1688
|
+
"manifest": manifest_full_path,
|
|
1689
|
+
"path_prefix": path_prefix,
|
|
1690
|
+
}
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
elif apps is not None:
|
|
1694
|
+
apps_config = apps
|
|
1695
|
+
# Convert Path objects to Path if they're strings
|
|
1696
|
+
apps = []
|
|
1697
|
+
for app_config in apps_config:
|
|
1698
|
+
manifest = app_config.get("manifest")
|
|
1699
|
+
if isinstance(manifest, str):
|
|
1700
|
+
manifest = Path(manifest)
|
|
1701
|
+
apps.append(
|
|
1702
|
+
{
|
|
1703
|
+
"slug": app_config.get("slug"),
|
|
1704
|
+
"manifest": manifest,
|
|
1705
|
+
"path_prefix": app_config.get("path_prefix", f"/{app_config.get('slug')}"),
|
|
1706
|
+
"on_startup": app_config.get("on_startup"),
|
|
1707
|
+
"on_shutdown": app_config.get("on_shutdown"),
|
|
1708
|
+
}
|
|
1709
|
+
)
|
|
1710
|
+
else:
|
|
1711
|
+
raise ValueError("Either 'apps' or 'multi_app_manifest' must be provided")
|
|
1712
|
+
|
|
1713
|
+
if not apps:
|
|
1714
|
+
raise ValueError("At least one app must be configured")
|
|
1715
|
+
|
|
1716
|
+
# Validate path prefixes
|
|
1717
|
+
is_valid, errors = self._validate_path_prefixes(apps)
|
|
1718
|
+
if not is_valid:
|
|
1719
|
+
raise ValueError(
|
|
1720
|
+
"Path prefix validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
# Check if any app uses shared auth
|
|
1724
|
+
has_shared_auth = False
|
|
1725
|
+
for app_config in apps:
|
|
1726
|
+
try:
|
|
1727
|
+
manifest_path = app_config["manifest"]
|
|
1728
|
+
with open(manifest_path) as f:
|
|
1729
|
+
app_manifest_pre = json.load(f)
|
|
1730
|
+
auth_config = app_manifest_pre.get("auth", {})
|
|
1731
|
+
if auth_config.get("mode") == "shared":
|
|
1732
|
+
has_shared_auth = True
|
|
1733
|
+
break
|
|
1734
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
1735
|
+
logger.warning(f"Could not check auth mode for app '{app_config.get('slug')}': {e}")
|
|
1736
|
+
|
|
1737
|
+
# State for parent app
|
|
1738
|
+
mounted_apps: list[dict[str, Any]] = []
|
|
1739
|
+
shared_user_pool_initialized = False
|
|
1740
|
+
|
|
1741
|
+
@asynccontextmanager
|
|
1742
|
+
async def lifespan(app: FastAPI):
|
|
1743
|
+
"""Lifespan context manager for parent app."""
|
|
1744
|
+
nonlocal mounted_apps, shared_user_pool_initialized
|
|
1745
|
+
|
|
1746
|
+
# Initialize engine
|
|
1747
|
+
await engine.initialize()
|
|
1748
|
+
|
|
1749
|
+
# Initialize shared user pool once if any app uses shared auth
|
|
1750
|
+
if has_shared_auth:
|
|
1751
|
+
logger.info("Initializing shared user pool for multi-app deployment")
|
|
1752
|
+
# Find first app with shared auth to get manifest for initialization
|
|
1753
|
+
for app_config in apps:
|
|
1754
|
+
try:
|
|
1755
|
+
manifest_path = app_config["manifest"]
|
|
1756
|
+
with open(manifest_path) as f:
|
|
1757
|
+
app_manifest_pre = json.load(f)
|
|
1758
|
+
auth_config = app_manifest_pre.get("auth", {})
|
|
1759
|
+
if auth_config.get("mode") == "shared":
|
|
1760
|
+
await engine._initialize_shared_user_pool(app, app_manifest_pre)
|
|
1761
|
+
shared_user_pool_initialized = True
|
|
1762
|
+
logger.info("Shared user pool initialized for multi-app deployment")
|
|
1763
|
+
break
|
|
1764
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
1765
|
+
app_slug = app_config.get("slug", "unknown")
|
|
1766
|
+
logger.warning(
|
|
1767
|
+
f"Could not initialize shared user pool from app '{app_slug}': {e}"
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1770
|
+
# Mount each child app
|
|
1771
|
+
for app_config in apps:
|
|
1772
|
+
slug = app_config["slug"]
|
|
1773
|
+
manifest_path = app_config["manifest"]
|
|
1774
|
+
path_prefix = app_config["path_prefix"]
|
|
1775
|
+
on_startup = app_config.get("on_startup")
|
|
1776
|
+
on_shutdown = app_config.get("on_shutdown")
|
|
1777
|
+
|
|
1778
|
+
try:
|
|
1779
|
+
# Create child app as sub-app (shares engine and lifecycle)
|
|
1780
|
+
child_app = engine.create_app(
|
|
1781
|
+
slug=slug,
|
|
1782
|
+
manifest=manifest_path,
|
|
1783
|
+
is_sub_app=True, # Important: marks as sub-app
|
|
1784
|
+
on_startup=on_startup,
|
|
1785
|
+
on_shutdown=on_shutdown,
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
# Share user_pool with child app if shared auth is enabled
|
|
1789
|
+
if shared_user_pool_initialized and hasattr(app.state, "user_pool"):
|
|
1790
|
+
child_app.state.user_pool = app.state.user_pool
|
|
1791
|
+
# Also share audit_log if available
|
|
1792
|
+
if hasattr(app.state, "audit_log"):
|
|
1793
|
+
child_app.state.audit_log = app.state.audit_log
|
|
1794
|
+
logger.debug(f"Shared user_pool with child app '{slug}'")
|
|
1795
|
+
|
|
1796
|
+
# Mount child app at path prefix
|
|
1797
|
+
app.mount(path_prefix, child_app)
|
|
1798
|
+
mounted_apps.append(
|
|
1799
|
+
{
|
|
1800
|
+
"slug": slug,
|
|
1801
|
+
"path_prefix": path_prefix,
|
|
1802
|
+
"status": "mounted",
|
|
1803
|
+
}
|
|
1804
|
+
)
|
|
1805
|
+
logger.info(f"Mounted app '{slug}' at path prefix '{path_prefix}'")
|
|
1806
|
+
|
|
1807
|
+
except (
|
|
1808
|
+
FileNotFoundError,
|
|
1809
|
+
json.JSONDecodeError,
|
|
1810
|
+
ValueError,
|
|
1811
|
+
KeyError,
|
|
1812
|
+
RuntimeError,
|
|
1813
|
+
) as e:
|
|
1814
|
+
logger.error(f"Failed to mount app '{slug}': {e}", exc_info=True)
|
|
1815
|
+
mounted_apps.append(
|
|
1816
|
+
{
|
|
1817
|
+
"slug": slug,
|
|
1818
|
+
"path_prefix": path_prefix,
|
|
1819
|
+
"status": "failed",
|
|
1820
|
+
"error": str(e),
|
|
1821
|
+
}
|
|
1822
|
+
)
|
|
1823
|
+
# Continue with other apps even if one fails
|
|
1824
|
+
continue
|
|
1825
|
+
|
|
1826
|
+
# Expose engine and mounted apps info on parent app state
|
|
1827
|
+
app.state.engine = engine
|
|
1828
|
+
app.state.mounted_apps = mounted_apps
|
|
1829
|
+
app.state.is_multi_app = True
|
|
1830
|
+
|
|
1831
|
+
yield
|
|
1832
|
+
|
|
1833
|
+
# Shutdown is handled by parent app
|
|
1834
|
+
await engine.shutdown()
|
|
1835
|
+
|
|
1836
|
+
# Create parent FastAPI app
|
|
1837
|
+
parent_app = FastAPI(title=title, lifespan=lifespan, root_path=root_path, **fastapi_kwargs)
|
|
1838
|
+
|
|
1839
|
+
# Add request scope middleware
|
|
1840
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
1841
|
+
|
|
1842
|
+
from ..di import ScopeManager
|
|
1843
|
+
|
|
1844
|
+
class RequestScopeMiddleware(BaseHTTPMiddleware):
|
|
1845
|
+
"""Middleware that manages request-scoped DI instances."""
|
|
1846
|
+
|
|
1847
|
+
async def dispatch(self, request, call_next):
|
|
1848
|
+
ScopeManager.begin_request()
|
|
1849
|
+
try:
|
|
1850
|
+
response = await call_next(request)
|
|
1851
|
+
return response
|
|
1852
|
+
finally:
|
|
1853
|
+
ScopeManager.end_request()
|
|
1854
|
+
|
|
1855
|
+
parent_app.add_middleware(RequestScopeMiddleware)
|
|
1856
|
+
logger.debug("RequestScopeMiddleware added for parent app")
|
|
1857
|
+
|
|
1858
|
+
# Add shared CORS middleware if configured
|
|
1859
|
+
# (Individual apps can add their own CORS, but parent-level is useful)
|
|
1860
|
+
try:
|
|
1861
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
1862
|
+
|
|
1863
|
+
parent_app.add_middleware(
|
|
1864
|
+
CORSMiddleware,
|
|
1865
|
+
allow_origins=["*"], # Can be configured via manifest later
|
|
1866
|
+
allow_credentials=True,
|
|
1867
|
+
allow_methods=["*"],
|
|
1868
|
+
allow_headers=["*"],
|
|
1869
|
+
)
|
|
1870
|
+
logger.debug("CORS middleware added for parent app")
|
|
1871
|
+
except ImportError:
|
|
1872
|
+
logger.warning("CORS middleware not available")
|
|
1873
|
+
|
|
1874
|
+
# Add unified health check endpoint
|
|
1875
|
+
@parent_app.get("/health")
|
|
1876
|
+
async def health_check():
|
|
1877
|
+
"""Unified health check for all mounted apps."""
|
|
1878
|
+
from ..observability import check_engine_health, check_mongodb_health
|
|
1879
|
+
|
|
1880
|
+
# Both are async functions
|
|
1881
|
+
engine_health = await check_engine_health(engine)
|
|
1882
|
+
mongo_health = await check_mongodb_health(engine.mongo_client)
|
|
1883
|
+
|
|
1884
|
+
mounted_status = {}
|
|
1885
|
+
for mounted_app_info in mounted_apps:
|
|
1886
|
+
mounted_status[mounted_app_info["slug"]] = {
|
|
1887
|
+
"path_prefix": mounted_app_info["path_prefix"],
|
|
1888
|
+
"status": mounted_app_info["status"],
|
|
1889
|
+
}
|
|
1890
|
+
if "error" in mounted_app_info:
|
|
1891
|
+
mounted_status[mounted_app_info["slug"]]["error"] = mounted_app_info["error"]
|
|
1892
|
+
|
|
1893
|
+
overall_status = (
|
|
1894
|
+
"healthy"
|
|
1895
|
+
if engine_health.status.value == "healthy"
|
|
1896
|
+
and mongo_health.status.value == "healthy"
|
|
1897
|
+
else "unhealthy"
|
|
1898
|
+
)
|
|
1899
|
+
|
|
1900
|
+
return {
|
|
1901
|
+
"status": overall_status,
|
|
1902
|
+
"engine": {
|
|
1903
|
+
"status": engine_health.status.value,
|
|
1904
|
+
"message": engine_health.message,
|
|
1905
|
+
},
|
|
1906
|
+
"mongodb": {
|
|
1907
|
+
"status": mongo_health.status.value,
|
|
1908
|
+
"message": mongo_health.message,
|
|
1909
|
+
},
|
|
1910
|
+
"mounted_apps": mounted_status,
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
logger.info(f"Multi-app parent created with {len(apps)} app(s) configured")
|
|
1914
|
+
|
|
1915
|
+
return parent_app
|
|
1916
|
+
|
|
1505
1917
|
async def _initialize_shared_user_pool(
|
|
1506
1918
|
self,
|
|
1507
1919
|
app: "FastAPI",
|
mdb_engine/core/manifest.py
CHANGED
|
@@ -184,6 +184,27 @@ MANIFEST_SCHEMA_V2 = {
|
|
|
184
184
|
"Works in both auth modes."
|
|
185
185
|
),
|
|
186
186
|
},
|
|
187
|
+
"auth_hub_url": {
|
|
188
|
+
"type": "string",
|
|
189
|
+
"format": "uri",
|
|
190
|
+
"description": (
|
|
191
|
+
"URL of the authentication hub for SSO apps (shared mode only). "
|
|
192
|
+
"Used for redirecting unauthenticated users to login. "
|
|
193
|
+
"Example: 'http://localhost:8000' or 'https://auth.example.com'. "
|
|
194
|
+
"Can be overridden via AUTH_HUB_URL environment variable."
|
|
195
|
+
),
|
|
196
|
+
},
|
|
197
|
+
"related_apps": {
|
|
198
|
+
"type": "object",
|
|
199
|
+
"additionalProperties": {"type": "string", "format": "uri"},
|
|
200
|
+
"description": (
|
|
201
|
+
"Map of related app slugs to their URLs for cross-app navigation "
|
|
202
|
+
"(useful in shared auth mode). Keys are app slugs, values are URLs. "
|
|
203
|
+
"Example: {'dashboard': 'http://localhost:8001', "
|
|
204
|
+
"'click_tracker': 'http://localhost:8000'}. "
|
|
205
|
+
"Can be overridden via {APP_SLUG_UPPER}_URL environment variables."
|
|
206
|
+
),
|
|
207
|
+
},
|
|
187
208
|
"policy": {
|
|
188
209
|
"type": "object",
|
|
189
210
|
"properties": {
|
|
@@ -1711,6 +1732,99 @@ MANIFEST_SCHEMA_V2 = {
|
|
|
1711
1732
|
"additionalProperties": False,
|
|
1712
1733
|
"description": "Observability configuration (health checks, metrics, logging)",
|
|
1713
1734
|
},
|
|
1735
|
+
"multi_app": {
|
|
1736
|
+
"type": "object",
|
|
1737
|
+
"properties": {
|
|
1738
|
+
"enabled": {
|
|
1739
|
+
"type": "boolean",
|
|
1740
|
+
"default": False,
|
|
1741
|
+
"description": "Enable multi-app mounting mode",
|
|
1742
|
+
},
|
|
1743
|
+
"apps": {
|
|
1744
|
+
"type": "array",
|
|
1745
|
+
"items": {
|
|
1746
|
+
"type": "object",
|
|
1747
|
+
"properties": {
|
|
1748
|
+
"slug": {
|
|
1749
|
+
"type": "string",
|
|
1750
|
+
"pattern": "^[a-z0-9_-]+$",
|
|
1751
|
+
"description": (
|
|
1752
|
+
"App slug (lowercase alphanumeric, underscores, hyphens)"
|
|
1753
|
+
),
|
|
1754
|
+
},
|
|
1755
|
+
"manifest": {
|
|
1756
|
+
"type": "string",
|
|
1757
|
+
"description": (
|
|
1758
|
+
"Path to manifest.json file "
|
|
1759
|
+
"(relative to multi_app manifest or absolute)"
|
|
1760
|
+
),
|
|
1761
|
+
},
|
|
1762
|
+
"path_prefix": {
|
|
1763
|
+
"type": "string",
|
|
1764
|
+
"pattern": "^/.*",
|
|
1765
|
+
"default": "/{slug}",
|
|
1766
|
+
"description": (
|
|
1767
|
+
"Path prefix for mounting (defaults to /{slug}). "
|
|
1768
|
+
"Must start with '/' and be unique across all apps."
|
|
1769
|
+
),
|
|
1770
|
+
},
|
|
1771
|
+
"on_startup": {
|
|
1772
|
+
"type": "string",
|
|
1773
|
+
"description": (
|
|
1774
|
+
"Optional: Python function path for startup callback "
|
|
1775
|
+
"(e.g., 'module.function_name'). "
|
|
1776
|
+
"Not yet supported in manifest-based config."
|
|
1777
|
+
),
|
|
1778
|
+
},
|
|
1779
|
+
"on_shutdown": {
|
|
1780
|
+
"type": "string",
|
|
1781
|
+
"description": (
|
|
1782
|
+
"Optional: Python function path for shutdown callback "
|
|
1783
|
+
"(e.g., 'module.function_name'). "
|
|
1784
|
+
"Not yet supported in manifest-based config."
|
|
1785
|
+
),
|
|
1786
|
+
},
|
|
1787
|
+
},
|
|
1788
|
+
"required": ["slug", "manifest"],
|
|
1789
|
+
"additionalProperties": False,
|
|
1790
|
+
},
|
|
1791
|
+
"minItems": 1,
|
|
1792
|
+
"description": "List of apps to mount in multi-app mode",
|
|
1793
|
+
},
|
|
1794
|
+
"shared_middleware": {
|
|
1795
|
+
"type": "object",
|
|
1796
|
+
"properties": {
|
|
1797
|
+
"cors": {
|
|
1798
|
+
"type": "boolean",
|
|
1799
|
+
"default": True,
|
|
1800
|
+
"description": "Enable CORS middleware at parent level (default: true)",
|
|
1801
|
+
},
|
|
1802
|
+
"rate_limiting": {
|
|
1803
|
+
"type": "boolean",
|
|
1804
|
+
"default": True,
|
|
1805
|
+
"description": (
|
|
1806
|
+
"Enable rate limiting middleware at parent level (default: true)"
|
|
1807
|
+
),
|
|
1808
|
+
},
|
|
1809
|
+
"health_checks": {
|
|
1810
|
+
"type": "boolean",
|
|
1811
|
+
"default": True,
|
|
1812
|
+
"description": (
|
|
1813
|
+
"Enable unified health check endpoint at /health (default: true)"
|
|
1814
|
+
),
|
|
1815
|
+
},
|
|
1816
|
+
},
|
|
1817
|
+
"additionalProperties": False,
|
|
1818
|
+
"description": "Shared middleware configuration for parent app",
|
|
1819
|
+
},
|
|
1820
|
+
},
|
|
1821
|
+
"additionalProperties": False,
|
|
1822
|
+
"description": (
|
|
1823
|
+
"Multi-app mounting configuration. When enabled, allows mounting "
|
|
1824
|
+
"multiple FastAPI apps under a single parent app with path prefixes. "
|
|
1825
|
+
"Useful for deploying multiple apps (e.g., SSO apps) on a single service."
|
|
1826
|
+
),
|
|
1827
|
+
},
|
|
1714
1828
|
"initial_data": {
|
|
1715
1829
|
"type": "object",
|
|
1716
1830
|
"patternProperties": {
|
mdb_engine/embeddings/service.py
CHANGED
|
@@ -445,30 +445,39 @@ class EmbeddingService:
|
|
|
445
445
|
logger.error(f"Error chunking text: {e}", exc_info=True)
|
|
446
446
|
raise EmbeddingServiceError(f"Chunking failed: {str(e)}") from e
|
|
447
447
|
|
|
448
|
-
async def
|
|
448
|
+
async def embed(self, text: str | list[str], model: str | None = None) -> list[list[float]]:
|
|
449
449
|
"""
|
|
450
|
-
Generate embeddings for text
|
|
450
|
+
Generate embeddings for text or a list of texts.
|
|
451
451
|
|
|
452
|
-
|
|
452
|
+
Natural API that works with both single strings and lists.
|
|
453
453
|
|
|
454
454
|
Args:
|
|
455
|
-
|
|
455
|
+
text: A single string or list of strings to embed
|
|
456
456
|
model: Optional model identifier (passed to embedding provider)
|
|
457
457
|
|
|
458
458
|
Returns:
|
|
459
|
-
List of embedding vectors (each is a list of floats)
|
|
459
|
+
List of embedding vectors (each is a list of floats).
|
|
460
|
+
If input was a single string, returns a list containing one vector.
|
|
460
461
|
|
|
461
462
|
Example:
|
|
462
|
-
|
|
463
|
-
vectors = await service.
|
|
463
|
+
# Single string
|
|
464
|
+
vectors = await service.embed("Hello world", model="text-embedding-3-small")
|
|
465
|
+
# vectors is [[0.1, 0.2, ...]]
|
|
466
|
+
|
|
467
|
+
# List of strings (batch - more efficient)
|
|
468
|
+
vectors = await service.embed(["chunk 1", "chunk 2"], model="text-embedding-3-small")
|
|
469
|
+
# vectors is [[0.1, ...], [0.2, ...]]
|
|
464
470
|
"""
|
|
471
|
+
# Normalize to list
|
|
472
|
+
chunks = [text] if isinstance(text, str) else text
|
|
473
|
+
|
|
465
474
|
if not chunks:
|
|
466
475
|
return []
|
|
467
476
|
|
|
468
477
|
try:
|
|
469
478
|
# Use EmbeddingProvider's embed method (handles retries, logging, etc.)
|
|
470
479
|
vectors = await self.embedding_provider.embed(chunks, model=model)
|
|
471
|
-
logger.info(f"Generated {len(vectors)}
|
|
480
|
+
logger.info(f"Generated {len(vectors)} embedding(s)")
|
|
472
481
|
return vectors
|
|
473
482
|
except (
|
|
474
483
|
AttributeError,
|
|
@@ -481,6 +490,26 @@ class EmbeddingService:
|
|
|
481
490
|
logger.error(f"Error generating embeddings: {e}", exc_info=True)
|
|
482
491
|
raise EmbeddingServiceError(f"Embedding generation failed: {str(e)}") from e
|
|
483
492
|
|
|
493
|
+
async def embed_chunks(self, chunks: list[str], model: str | None = None) -> list[list[float]]:
|
|
494
|
+
"""
|
|
495
|
+
Generate embeddings for text chunks (list only).
|
|
496
|
+
|
|
497
|
+
DEPRECATED: Use embed() instead, which accepts both strings and lists.
|
|
498
|
+
This method is kept for backward compatibility.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
chunks: List of text chunks to embed
|
|
502
|
+
model: Optional model identifier (passed to embedding provider)
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
List of embedding vectors (each is a list of floats)
|
|
506
|
+
|
|
507
|
+
Example:
|
|
508
|
+
chunks = ["chunk 1", "chunk 2"]
|
|
509
|
+
vectors = await service.embed_chunks(chunks, model="text-embedding-3-small")
|
|
510
|
+
"""
|
|
511
|
+
return await self.embed(chunks, model=model)
|
|
512
|
+
|
|
484
513
|
async def process_and_store(
|
|
485
514
|
self,
|
|
486
515
|
text_content: str,
|