mdb-engine 0.3.1__py3-none-any.whl → 0.4.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.
- mdb_engine/__init__.py +1 -1
- mdb_engine/auth/shared_middleware.py +25 -6
- mdb_engine/core/engine.py +718 -35
- {mdb_engine-0.3.1.dist-info → mdb_engine-0.4.1.dist-info}/METADATA +1 -1
- {mdb_engine-0.3.1.dist-info → mdb_engine-0.4.1.dist-info}/RECORD +9 -9
- {mdb_engine-0.3.1.dist-info → mdb_engine-0.4.1.dist-info}/WHEEL +0 -0
- {mdb_engine-0.3.1.dist-info → mdb_engine-0.4.1.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.3.1.dist-info → mdb_engine-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.3.1.dist-info → mdb_engine-0.4.1.dist-info}/top_level.txt +0 -0
mdb_engine/__init__.py
CHANGED
|
@@ -81,7 +81,7 @@ from .repositories import Entity, MongoRepository, Repository, UnitOfWork
|
|
|
81
81
|
# Utilities
|
|
82
82
|
from .utils import clean_mongo_doc, clean_mongo_docs
|
|
83
83
|
|
|
84
|
-
__version__ = "0.
|
|
84
|
+
__version__ = "0.4.1" # Patch version: Bug fixes for path prefix handling and test improvements
|
|
85
85
|
|
|
86
86
|
__all__ = [
|
|
87
87
|
# Core Engine
|
|
@@ -86,16 +86,35 @@ def _get_request_path(request: Request) -> str:
|
|
|
86
86
|
"""
|
|
87
87
|
Get the request path relative to the mount point.
|
|
88
88
|
|
|
89
|
-
For mounted apps (via create_multi_app),
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
For mounted apps (via create_multi_app), strips the path prefix from
|
|
90
|
+
request.url.path using request.state.app_base_path. For non-mounted apps,
|
|
91
|
+
uses request.scope["path"] if available, otherwise falls back to request.url.path.
|
|
92
92
|
|
|
93
93
|
This ensures public routes in manifests (which are relative paths like "/")
|
|
94
94
|
match correctly when apps are mounted at prefixes like "/auth-hub".
|
|
95
95
|
"""
|
|
96
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
# Check if this is a mounted app with a path prefix
|
|
97
|
+
app_base_path = getattr(request.state, "app_base_path", None)
|
|
98
|
+
# Ensure app_base_path is a string (not a MagicMock in tests)
|
|
99
|
+
if app_base_path and isinstance(app_base_path, str):
|
|
100
|
+
# Ensure request.url.path is a string before calling startswith
|
|
101
|
+
url_path = str(request.url.path) if hasattr(request.url, "path") else None
|
|
102
|
+
if url_path and url_path.startswith(app_base_path):
|
|
103
|
+
# Strip the path prefix to get relative path
|
|
104
|
+
relative_path = url_path[len(app_base_path) :]
|
|
105
|
+
# Ensure path starts with / (handle case where prefix is entire path)
|
|
106
|
+
return relative_path if relative_path else "/"
|
|
107
|
+
|
|
108
|
+
# Fall back to scope["path"] for mounted apps (if available)
|
|
109
|
+
# This handles cases where Starlette/FastAPI sets it correctly
|
|
110
|
+
if "path" in request.scope:
|
|
111
|
+
return request.scope["path"]
|
|
112
|
+
|
|
113
|
+
# Default to url.path for non-mounted apps
|
|
114
|
+
# Ensure we return a string
|
|
115
|
+
if hasattr(request.url, "path"):
|
|
116
|
+
return str(request.url.path)
|
|
117
|
+
return "/"
|
|
99
118
|
|
|
100
119
|
|
|
101
120
|
class SharedAuthMiddleware(BaseHTTPMiddleware):
|
mdb_engine/core/engine.py
CHANGED
|
@@ -1529,7 +1529,8 @@ class MongoDBEngine:
|
|
|
1529
1529
|
Checks:
|
|
1530
1530
|
- All prefixes start with '/'
|
|
1531
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')
|
|
1532
|
+
- No conflicts with reserved paths ('/health', '/docs', '/openapi.json', '/_mdb')
|
|
1533
|
+
- Slug matches manifest slug (if manifest is readable)
|
|
1533
1534
|
|
|
1534
1535
|
Args:
|
|
1535
1536
|
apps: List of app configs with 'path_prefix' keys
|
|
@@ -1537,17 +1538,27 @@ class MongoDBEngine:
|
|
|
1537
1538
|
Returns:
|
|
1538
1539
|
Tuple of (is_valid, list_of_errors)
|
|
1539
1540
|
"""
|
|
1541
|
+
|
|
1540
1542
|
errors: list[str] = []
|
|
1541
|
-
reserved_paths = {"/health", "/docs", "/openapi.json", "/redoc"}
|
|
1543
|
+
reserved_paths = {"/health", "/docs", "/openapi.json", "/redoc", "/_mdb"}
|
|
1542
1544
|
|
|
1543
1545
|
# Extract path prefixes
|
|
1544
1546
|
path_prefixes: list[str] = []
|
|
1545
1547
|
for app_config in apps:
|
|
1546
|
-
|
|
1548
|
+
slug = app_config.get("slug", "unknown")
|
|
1549
|
+
path_prefix = app_config.get("path_prefix", f"/{slug}")
|
|
1550
|
+
|
|
1547
1551
|
if not path_prefix.startswith("/"):
|
|
1548
|
-
|
|
1549
|
-
errors.append(f"Path prefix '{path_prefix}' must start with '/' (app: {app_slug})")
|
|
1552
|
+
errors.append(f"Path prefix '{path_prefix}' must start with '/' (app: '{slug}')")
|
|
1550
1553
|
continue
|
|
1554
|
+
|
|
1555
|
+
# Check for common mistakes
|
|
1556
|
+
if path_prefix.endswith("/") and path_prefix != "/":
|
|
1557
|
+
logger.warning(
|
|
1558
|
+
f"Path prefix '{path_prefix}' ends with '/'. "
|
|
1559
|
+
f"Consider removing trailing slash for app '{slug}'"
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1551
1562
|
path_prefixes.append(path_prefix)
|
|
1552
1563
|
|
|
1553
1564
|
# Check for conflicts with reserved paths
|
|
@@ -1555,33 +1566,171 @@ class MongoDBEngine:
|
|
|
1555
1566
|
if prefix in reserved_paths:
|
|
1556
1567
|
errors.append(
|
|
1557
1568
|
f"Path prefix '{prefix}' conflicts with reserved path. "
|
|
1558
|
-
"Reserved paths: /health, /docs, /openapi.json, /redoc"
|
|
1569
|
+
"Reserved paths: /health, /docs, /openapi.json, /redoc, /_mdb"
|
|
1559
1570
|
)
|
|
1560
1571
|
|
|
1561
1572
|
# Check for prefix conflicts (one prefix being a prefix of another)
|
|
1562
1573
|
path_prefixes_sorted = sorted(path_prefixes)
|
|
1563
1574
|
for i, prefix1 in enumerate(path_prefixes_sorted):
|
|
1564
1575
|
for prefix2 in path_prefixes_sorted[i + 1 :]:
|
|
1565
|
-
|
|
1576
|
+
# Normalize by ensuring both end with / for comparison
|
|
1577
|
+
p1_norm = prefix1 if prefix1.endswith("/") else prefix1 + "/"
|
|
1578
|
+
p2_norm = prefix2 if prefix2.endswith("/") else prefix2 + "/"
|
|
1579
|
+
|
|
1580
|
+
if p1_norm.startswith(p2_norm) or p2_norm.startswith(p1_norm):
|
|
1581
|
+
# Find which apps these belong to for better error message
|
|
1582
|
+
app1_slug = next(
|
|
1583
|
+
(a.get("slug", "unknown") for a in apps if a.get("path_prefix") == prefix1),
|
|
1584
|
+
"unknown",
|
|
1585
|
+
)
|
|
1586
|
+
app2_slug = next(
|
|
1587
|
+
(a.get("slug", "unknown") for a in apps if a.get("path_prefix") == prefix2),
|
|
1588
|
+
"unknown",
|
|
1589
|
+
)
|
|
1566
1590
|
errors.append(
|
|
1567
|
-
f"Path prefix conflict: '{prefix1}'
|
|
1591
|
+
f"Path prefix conflict: '{prefix1}' (app: '{app1_slug}') and "
|
|
1592
|
+
f"'{prefix2}' (app: '{app2_slug}') overlap. "
|
|
1568
1593
|
"One cannot be a prefix of another."
|
|
1569
1594
|
)
|
|
1570
1595
|
|
|
1571
1596
|
# Check for duplicates
|
|
1572
1597
|
if len(path_prefixes) != len(set(path_prefixes)):
|
|
1573
|
-
seen =
|
|
1574
|
-
for
|
|
1598
|
+
seen = {}
|
|
1599
|
+
for app_config in apps:
|
|
1600
|
+
prefix = app_config.get("path_prefix")
|
|
1601
|
+
slug = app_config.get("slug", "unknown")
|
|
1575
1602
|
if prefix in seen:
|
|
1576
|
-
|
|
1577
|
-
|
|
1603
|
+
first_slug = seen[prefix]
|
|
1604
|
+
errors.append(
|
|
1605
|
+
f"Duplicate path prefix: '{prefix}' used by both "
|
|
1606
|
+
f"'{first_slug}' and '{slug}'"
|
|
1607
|
+
)
|
|
1608
|
+
else:
|
|
1609
|
+
seen[prefix] = slug
|
|
1578
1610
|
|
|
1579
1611
|
return len(errors) == 0, errors
|
|
1580
1612
|
|
|
1581
|
-
def
|
|
1613
|
+
def _discover_apps_from_directory(
|
|
1614
|
+
self,
|
|
1615
|
+
apps_dir: Path,
|
|
1616
|
+
path_prefix_template: str | None = None,
|
|
1617
|
+
) -> list[dict[str, Any]]:
|
|
1618
|
+
"""
|
|
1619
|
+
Auto-discover apps by scanning directory for manifest.json files.
|
|
1620
|
+
|
|
1621
|
+
Args:
|
|
1622
|
+
apps_dir: Directory to scan for apps
|
|
1623
|
+
path_prefix_template: Template for path prefixes (e.g., "/app-{index}")
|
|
1624
|
+
|
|
1625
|
+
Returns:
|
|
1626
|
+
List of app configurations
|
|
1627
|
+
"""
|
|
1628
|
+
import json
|
|
1629
|
+
|
|
1630
|
+
apps_dir = Path(apps_dir)
|
|
1631
|
+
if not apps_dir.exists():
|
|
1632
|
+
raise ValueError(f"Apps directory does not exist: {apps_dir}")
|
|
1633
|
+
|
|
1634
|
+
discovered_apps = []
|
|
1635
|
+
manifest_files = list(apps_dir.rglob("manifest.json"))
|
|
1636
|
+
|
|
1637
|
+
if not manifest_files:
|
|
1638
|
+
raise ValueError(f"No manifest.json files found in {apps_dir}")
|
|
1639
|
+
|
|
1640
|
+
for idx, manifest_path in enumerate(sorted(manifest_files), start=1):
|
|
1641
|
+
try:
|
|
1642
|
+
with open(manifest_path) as f:
|
|
1643
|
+
manifest_data = json.load(f)
|
|
1644
|
+
|
|
1645
|
+
slug = manifest_data.get("slug")
|
|
1646
|
+
if not slug:
|
|
1647
|
+
logger.warning(f"Skipping manifest without slug: {manifest_path}")
|
|
1648
|
+
continue
|
|
1649
|
+
|
|
1650
|
+
# Generate path prefix
|
|
1651
|
+
if path_prefix_template:
|
|
1652
|
+
path_prefix = path_prefix_template.format(index=idx, slug=slug)
|
|
1653
|
+
else:
|
|
1654
|
+
path_prefix = f"/{slug}"
|
|
1655
|
+
|
|
1656
|
+
discovered_apps.append(
|
|
1657
|
+
{
|
|
1658
|
+
"slug": slug,
|
|
1659
|
+
"manifest": manifest_path,
|
|
1660
|
+
"path_prefix": path_prefix,
|
|
1661
|
+
}
|
|
1662
|
+
)
|
|
1663
|
+
logger.info(
|
|
1664
|
+
f"Discovered app '{slug}' at {manifest_path} " f"(will mount at {path_prefix})"
|
|
1665
|
+
)
|
|
1666
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
1667
|
+
logger.warning(f"Failed to read manifest at {manifest_path}: {e}")
|
|
1668
|
+
continue
|
|
1669
|
+
|
|
1670
|
+
if not discovered_apps:
|
|
1671
|
+
raise ValueError(f"No valid apps discovered in {apps_dir}")
|
|
1672
|
+
|
|
1673
|
+
return discovered_apps
|
|
1674
|
+
|
|
1675
|
+
def _validate_manifests(self, apps: list[dict[str, Any]], strict: bool) -> list[str]:
|
|
1676
|
+
"""Validate all app manifests."""
|
|
1677
|
+
import json
|
|
1678
|
+
|
|
1679
|
+
logger.info("Validating all manifests before mounting...")
|
|
1680
|
+
validation_errors = []
|
|
1681
|
+
for app_config in apps:
|
|
1682
|
+
slug = app_config.get("slug", "unknown")
|
|
1683
|
+
manifest_path = app_config.get("manifest")
|
|
1684
|
+
try:
|
|
1685
|
+
with open(manifest_path) as f:
|
|
1686
|
+
manifest_data = json.load(f)
|
|
1687
|
+
|
|
1688
|
+
# Validate manifest
|
|
1689
|
+
from .manifest import validate_manifest
|
|
1690
|
+
|
|
1691
|
+
is_valid, error_msg, error_paths = validate_manifest(manifest_data)
|
|
1692
|
+
|
|
1693
|
+
if not is_valid:
|
|
1694
|
+
error_detail = f"App '{slug}' at {manifest_path}: {error_msg}"
|
|
1695
|
+
if error_paths:
|
|
1696
|
+
error_detail += f" (paths: {', '.join(error_paths)})"
|
|
1697
|
+
validation_errors.append(error_detail)
|
|
1698
|
+
if strict:
|
|
1699
|
+
raise ValueError(
|
|
1700
|
+
f"Manifest validation failed for app '{slug}': {error_msg}"
|
|
1701
|
+
) from None
|
|
1702
|
+
|
|
1703
|
+
# Validate slug matches manifest slug
|
|
1704
|
+
manifest_slug = manifest_data.get("slug")
|
|
1705
|
+
if manifest_slug and manifest_slug != slug:
|
|
1706
|
+
error_msg = (
|
|
1707
|
+
f"Slug mismatch: config slug '{slug}' does not match "
|
|
1708
|
+
f"manifest slug '{manifest_slug}' in {manifest_path}"
|
|
1709
|
+
)
|
|
1710
|
+
validation_errors.append(error_msg)
|
|
1711
|
+
if strict:
|
|
1712
|
+
raise ValueError(error_msg) from None
|
|
1713
|
+
except FileNotFoundError as e:
|
|
1714
|
+
error_msg = f"Manifest file not found for app '{slug}': {manifest_path}"
|
|
1715
|
+
validation_errors.append(error_msg)
|
|
1716
|
+
if strict:
|
|
1717
|
+
raise ValueError(error_msg) from e
|
|
1718
|
+
except json.JSONDecodeError as e:
|
|
1719
|
+
error_msg = f"Invalid JSON in manifest for app '{slug}' at {manifest_path}: {e}"
|
|
1720
|
+
validation_errors.append(error_msg)
|
|
1721
|
+
if strict:
|
|
1722
|
+
raise ValueError(error_msg) from e
|
|
1723
|
+
|
|
1724
|
+
return validation_errors
|
|
1725
|
+
|
|
1726
|
+
def create_multi_app( # noqa: C901
|
|
1582
1727
|
self,
|
|
1583
1728
|
apps: list[dict[str, Any]] | None = None,
|
|
1584
1729
|
multi_app_manifest: Path | None = None,
|
|
1730
|
+
apps_dir: Path | None = None,
|
|
1731
|
+
path_prefix_template: str | None = None,
|
|
1732
|
+
validate: bool = False,
|
|
1733
|
+
strict: bool = False,
|
|
1585
1734
|
title: str = "Multi-App API",
|
|
1586
1735
|
root_path: str = "",
|
|
1587
1736
|
**fastapi_kwargs: Any,
|
|
@@ -1613,6 +1762,15 @@ class MongoDBEngine:
|
|
|
1613
1762
|
]
|
|
1614
1763
|
}
|
|
1615
1764
|
}
|
|
1765
|
+
apps_dir: Directory to scan for apps (auto-discovery). If provided and
|
|
1766
|
+
apps is None, will recursively scan for manifest.json files and
|
|
1767
|
+
auto-discover apps. Takes precedence over multi_app_manifest.
|
|
1768
|
+
path_prefix_template: Template for auto-generated path prefixes when using
|
|
1769
|
+
apps_dir. Use {index} for app index and {slug} for app slug.
|
|
1770
|
+
Example: "/app-{index}" or "/{slug}"
|
|
1771
|
+
validate: If True, validate all manifests before mounting (default: False)
|
|
1772
|
+
strict: If True, fail fast on any validation error (default: False).
|
|
1773
|
+
Only used when validate=True.
|
|
1616
1774
|
title: Title for the parent FastAPI app
|
|
1617
1775
|
root_path: Root path prefix for all mounted apps (optional)
|
|
1618
1776
|
**fastapi_kwargs: Additional arguments passed to FastAPI()
|
|
@@ -1624,6 +1782,19 @@ class MongoDBEngine:
|
|
|
1624
1782
|
ValueError: If configuration is invalid or path prefixes conflict
|
|
1625
1783
|
RuntimeError: If engine is not initialized
|
|
1626
1784
|
|
|
1785
|
+
Features:
|
|
1786
|
+
- Built-in app context helpers: Each mounted app has access to:
|
|
1787
|
+
- request.state.app_base_path: Path prefix (e.g., "/app-1")
|
|
1788
|
+
- request.state.auth_hub_url: Auth hub URL from manifest or env
|
|
1789
|
+
- request.state.app_slug: App slug
|
|
1790
|
+
- request.state.mounted_apps: Dict of all mounted apps with paths
|
|
1791
|
+
- request.state.engine: MongoDBEngine instance
|
|
1792
|
+
- request.state.manifest: App's manifest.json
|
|
1793
|
+
- Unified health check: GET /health aggregates health from all apps
|
|
1794
|
+
- Route introspection: GET /_mdb/routes lists all routes from all apps
|
|
1795
|
+
- OpenAPI aggregation: /docs combines docs from all apps
|
|
1796
|
+
- Per-app docs: /docs/{app_slug} for individual app documentation
|
|
1797
|
+
|
|
1627
1798
|
Example:
|
|
1628
1799
|
# Programmatic approach
|
|
1629
1800
|
engine = MongoDBEngine(mongo_uri=..., db_name=...)
|
|
@@ -1646,6 +1817,21 @@ class MongoDBEngine:
|
|
|
1646
1817
|
app = engine.create_multi_app(
|
|
1647
1818
|
multi_app_manifest=Path("./multi_app_manifest.json")
|
|
1648
1819
|
)
|
|
1820
|
+
|
|
1821
|
+
# Auto-discovery approach
|
|
1822
|
+
app = engine.create_multi_app(
|
|
1823
|
+
apps_dir=Path("./apps"),
|
|
1824
|
+
path_prefix_template="/app-{index}",
|
|
1825
|
+
validate=True,
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
# Access app context in routes
|
|
1829
|
+
@app.get("/my-route")
|
|
1830
|
+
async def my_route(request: Request):
|
|
1831
|
+
base_path = request.state.app_base_path # "/app-1"
|
|
1832
|
+
auth_url = request.state.auth_hub_url # "/auth-hub"
|
|
1833
|
+
slug = request.state.app_slug # "my-app"
|
|
1834
|
+
all_apps = request.state.mounted_apps # Dict of all apps
|
|
1649
1835
|
"""
|
|
1650
1836
|
import json
|
|
1651
1837
|
|
|
@@ -1653,6 +1839,14 @@ class MongoDBEngine:
|
|
|
1653
1839
|
|
|
1654
1840
|
engine = self
|
|
1655
1841
|
|
|
1842
|
+
# Auto-discovery: if apps_dir is provided and apps is None, discover apps
|
|
1843
|
+
if apps_dir and apps is None:
|
|
1844
|
+
logger.info(f"Auto-discovering apps from directory: {apps_dir}")
|
|
1845
|
+
apps = self._discover_apps_from_directory(
|
|
1846
|
+
apps_dir=apps_dir,
|
|
1847
|
+
path_prefix_template=path_prefix_template,
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1656
1850
|
# Load configuration from manifest or apps parameter
|
|
1657
1851
|
if multi_app_manifest:
|
|
1658
1852
|
manifest_path = Path(multi_app_manifest)
|
|
@@ -1708,12 +1902,26 @@ class MongoDBEngine:
|
|
|
1708
1902
|
}
|
|
1709
1903
|
)
|
|
1710
1904
|
else:
|
|
1711
|
-
raise ValueError("Either 'apps' or '
|
|
1905
|
+
raise ValueError("Either 'apps', 'multi_app_manifest', or 'apps_dir' must be provided")
|
|
1712
1906
|
|
|
1713
1907
|
if not apps:
|
|
1714
1908
|
raise ValueError("At least one app must be configured")
|
|
1715
1909
|
|
|
1716
|
-
# Validate
|
|
1910
|
+
# Validate manifests if requested
|
|
1911
|
+
if validate:
|
|
1912
|
+
validation_errors = self._validate_manifests(apps, strict)
|
|
1913
|
+
if validation_errors:
|
|
1914
|
+
logger.warning(
|
|
1915
|
+
"Manifest validation found issues:\n"
|
|
1916
|
+
+ "\n".join(f" - {e}" for e in validation_errors)
|
|
1917
|
+
)
|
|
1918
|
+
if strict:
|
|
1919
|
+
raise ValueError(
|
|
1920
|
+
"Manifest validation failed (strict mode):\n"
|
|
1921
|
+
+ "\n".join(f" - {e}" for e in validation_errors)
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
# Validate path prefixes (enhanced)
|
|
1717
1925
|
is_valid, errors = self._validate_path_prefixes(apps)
|
|
1718
1926
|
if not is_valid:
|
|
1719
1927
|
raise ValueError(
|
|
@@ -1734,6 +1942,23 @@ class MongoDBEngine:
|
|
|
1734
1942
|
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
1735
1943
|
logger.warning(f"Could not check auth mode for app '{app_config.get('slug')}': {e}")
|
|
1736
1944
|
|
|
1945
|
+
# Validate hooks before creating lifespan (fail fast)
|
|
1946
|
+
for app_config in apps:
|
|
1947
|
+
slug = app_config.get("slug", "unknown")
|
|
1948
|
+
on_startup = app_config.get("on_startup")
|
|
1949
|
+
on_shutdown = app_config.get("on_shutdown")
|
|
1950
|
+
|
|
1951
|
+
if on_startup is not None and not callable(on_startup):
|
|
1952
|
+
raise ValueError(
|
|
1953
|
+
f"on_startup hook for app '{slug}' must be callable, "
|
|
1954
|
+
f"got {type(on_startup).__name__}"
|
|
1955
|
+
)
|
|
1956
|
+
if on_shutdown is not None and not callable(on_shutdown):
|
|
1957
|
+
raise ValueError(
|
|
1958
|
+
f"on_shutdown hook for app '{slug}' must be callable, "
|
|
1959
|
+
f"got {type(on_shutdown).__name__}"
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1737
1962
|
# State for parent app
|
|
1738
1963
|
mounted_apps: list[dict[str, Any]] = []
|
|
1739
1964
|
shared_user_pool_initialized = False
|
|
@@ -1776,6 +2001,32 @@ class MongoDBEngine:
|
|
|
1776
2001
|
on_shutdown = app_config.get("on_shutdown")
|
|
1777
2002
|
|
|
1778
2003
|
try:
|
|
2004
|
+
# Load manifest for context helpers
|
|
2005
|
+
try:
|
|
2006
|
+
with open(manifest_path) as f:
|
|
2007
|
+
app_manifest_data = json.load(f)
|
|
2008
|
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
|
2009
|
+
raise ValueError(
|
|
2010
|
+
f"Failed to load manifest for app '{slug}' at {manifest_path}: {e}"
|
|
2011
|
+
) from e
|
|
2012
|
+
|
|
2013
|
+
# Log app configuration
|
|
2014
|
+
auth_config = app_manifest_data.get("auth", {})
|
|
2015
|
+
auth_mode = auth_config.get("mode", "app")
|
|
2016
|
+
public_routes = auth_config.get("public_routes", [])
|
|
2017
|
+
logger.info(
|
|
2018
|
+
f"Mounting app '{slug}' at '{path_prefix}': "
|
|
2019
|
+
f"auth_mode={auth_mode}, "
|
|
2020
|
+
f"public_routes={len(public_routes)} routes"
|
|
2021
|
+
)
|
|
2022
|
+
if public_routes:
|
|
2023
|
+
logger.debug(f" Public routes for '{slug}': {public_routes}")
|
|
2024
|
+
else:
|
|
2025
|
+
logger.warning(
|
|
2026
|
+
f" App '{slug}' has no public routes configured. "
|
|
2027
|
+
"All routes will require authentication."
|
|
2028
|
+
)
|
|
2029
|
+
|
|
1779
2030
|
# Create child app as sub-app (shares engine and lifecycle)
|
|
1780
2031
|
child_app = engine.create_app(
|
|
1781
2032
|
slug=slug,
|
|
@@ -1793,6 +2044,93 @@ class MongoDBEngine:
|
|
|
1793
2044
|
child_app.state.audit_log = app.state.audit_log
|
|
1794
2045
|
logger.debug(f"Shared user_pool with child app '{slug}'")
|
|
1795
2046
|
|
|
2047
|
+
# Add middleware for app context helpers
|
|
2048
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2049
|
+
from starlette.requests import Request
|
|
2050
|
+
|
|
2051
|
+
# Get auth_hub_url from manifest or env
|
|
2052
|
+
auth_hub_url = None
|
|
2053
|
+
if auth_config.get("mode") == "shared":
|
|
2054
|
+
auth_hub_url = auth_config.get("auth_hub_url")
|
|
2055
|
+
if not auth_hub_url:
|
|
2056
|
+
auth_hub_url = os.getenv("AUTH_HUB_URL", "/auth-hub")
|
|
2057
|
+
|
|
2058
|
+
# Store parent app reference and current app info for middleware
|
|
2059
|
+
child_app.state.parent_app = app
|
|
2060
|
+
child_app.state.app_slug = slug
|
|
2061
|
+
child_app.state.app_base_path = path_prefix
|
|
2062
|
+
child_app.state.app_auth_hub_url = auth_hub_url
|
|
2063
|
+
child_app.state.app_manifest = app_manifest_data
|
|
2064
|
+
|
|
2065
|
+
# Create middleware factory to properly capture loop variables
|
|
2066
|
+
def create_app_context_middleware(
|
|
2067
|
+
app_slug: str,
|
|
2068
|
+
app_path_prefix: str,
|
|
2069
|
+
app_auth_hub_url_val: str,
|
|
2070
|
+
app_manifest_data_val: dict[str, Any],
|
|
2071
|
+
) -> type[BaseHTTPMiddleware]:
|
|
2072
|
+
"""Create middleware class with captured variables."""
|
|
2073
|
+
|
|
2074
|
+
class _AppContextMiddleware(BaseHTTPMiddleware):
|
|
2075
|
+
"""Middleware that sets app context helpers on request.state."""
|
|
2076
|
+
|
|
2077
|
+
async def dispatch(self, request: Request, call_next):
|
|
2078
|
+
# Get parent app from child app state
|
|
2079
|
+
parent_app = getattr(request.app.state, "parent_app", None)
|
|
2080
|
+
|
|
2081
|
+
# Set app context helpers
|
|
2082
|
+
request.state.app_base_path = getattr(
|
|
2083
|
+
request.app.state,
|
|
2084
|
+
"app_base_path",
|
|
2085
|
+
app_path_prefix,
|
|
2086
|
+
)
|
|
2087
|
+
request.state.auth_hub_url = getattr(
|
|
2088
|
+
request.app.state,
|
|
2089
|
+
"app_auth_hub_url",
|
|
2090
|
+
app_auth_hub_url_val,
|
|
2091
|
+
)
|
|
2092
|
+
request.state.app_slug = getattr(
|
|
2093
|
+
request.app.state, "app_slug", app_slug
|
|
2094
|
+
)
|
|
2095
|
+
request.state.engine = engine
|
|
2096
|
+
request.state.manifest = getattr(
|
|
2097
|
+
request.app.state,
|
|
2098
|
+
"app_manifest",
|
|
2099
|
+
app_manifest_data_val,
|
|
2100
|
+
)
|
|
2101
|
+
|
|
2102
|
+
# Get mounted apps from parent app state
|
|
2103
|
+
if parent_app and hasattr(parent_app.state, "mounted_apps"):
|
|
2104
|
+
mounted_apps_list = parent_app.state.mounted_apps
|
|
2105
|
+
request.state.mounted_apps = {
|
|
2106
|
+
ma["slug"]: {
|
|
2107
|
+
"slug": ma["slug"],
|
|
2108
|
+
"path_prefix": ma.get("path_prefix"),
|
|
2109
|
+
"status": ma.get("status", "unknown"),
|
|
2110
|
+
}
|
|
2111
|
+
for ma in mounted_apps_list
|
|
2112
|
+
}
|
|
2113
|
+
else:
|
|
2114
|
+
# Fallback: create minimal dict with current app
|
|
2115
|
+
request.state.mounted_apps = {
|
|
2116
|
+
app_slug: {
|
|
2117
|
+
"slug": app_slug,
|
|
2118
|
+
"path_prefix": app_path_prefix,
|
|
2119
|
+
"status": "mounted",
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
response = await call_next(request)
|
|
2124
|
+
return response
|
|
2125
|
+
|
|
2126
|
+
return _AppContextMiddleware
|
|
2127
|
+
|
|
2128
|
+
middleware_class = create_app_context_middleware(
|
|
2129
|
+
slug, path_prefix, auth_hub_url, app_manifest_data
|
|
2130
|
+
)
|
|
2131
|
+
child_app.add_middleware(middleware_class)
|
|
2132
|
+
logger.debug(f"Added AppContextMiddleware to child app '{slug}'")
|
|
2133
|
+
|
|
1796
2134
|
# Mount child app at path prefix
|
|
1797
2135
|
app.mount(path_prefix, child_app)
|
|
1798
2136
|
mounted_apps.append(
|
|
@@ -1800,27 +2138,91 @@ class MongoDBEngine:
|
|
|
1800
2138
|
"slug": slug,
|
|
1801
2139
|
"path_prefix": path_prefix,
|
|
1802
2140
|
"status": "mounted",
|
|
2141
|
+
"manifest": app_manifest_data,
|
|
1803
2142
|
}
|
|
1804
2143
|
)
|
|
1805
2144
|
logger.info(f"Mounted app '{slug}' at path prefix '{path_prefix}'")
|
|
1806
2145
|
|
|
1807
|
-
except
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
) as e:
|
|
1814
|
-
logger.error(f"Failed to mount app '{slug}': {e}", exc_info=True)
|
|
2146
|
+
except FileNotFoundError as e:
|
|
2147
|
+
error_msg = (
|
|
2148
|
+
f"Failed to mount app '{slug}' at {path_prefix}: "
|
|
2149
|
+
f"manifest.json not found at {manifest_path}"
|
|
2150
|
+
)
|
|
2151
|
+
logger.error(error_msg, exc_info=True)
|
|
1815
2152
|
mounted_apps.append(
|
|
1816
2153
|
{
|
|
1817
2154
|
"slug": slug,
|
|
1818
2155
|
"path_prefix": path_prefix,
|
|
1819
2156
|
"status": "failed",
|
|
1820
|
-
"error":
|
|
2157
|
+
"error": error_msg,
|
|
2158
|
+
"manifest_path": str(manifest_path),
|
|
1821
2159
|
}
|
|
1822
2160
|
)
|
|
1823
|
-
|
|
2161
|
+
if strict:
|
|
2162
|
+
raise ValueError(error_msg) from e
|
|
2163
|
+
continue
|
|
2164
|
+
except json.JSONDecodeError as e:
|
|
2165
|
+
error_msg = (
|
|
2166
|
+
f"Failed to mount app '{slug}' at {path_prefix}: "
|
|
2167
|
+
f"Invalid JSON in manifest.json at {manifest_path}: {e}"
|
|
2168
|
+
)
|
|
2169
|
+
logger.error(error_msg, exc_info=True)
|
|
2170
|
+
mounted_apps.append(
|
|
2171
|
+
{
|
|
2172
|
+
"slug": slug,
|
|
2173
|
+
"path_prefix": path_prefix,
|
|
2174
|
+
"status": "failed",
|
|
2175
|
+
"error": error_msg,
|
|
2176
|
+
"manifest_path": str(manifest_path),
|
|
2177
|
+
}
|
|
2178
|
+
)
|
|
2179
|
+
if strict:
|
|
2180
|
+
raise ValueError(error_msg) from e
|
|
2181
|
+
continue
|
|
2182
|
+
except ValueError as e:
|
|
2183
|
+
error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
|
|
2184
|
+
logger.error(error_msg, exc_info=True)
|
|
2185
|
+
mounted_apps.append(
|
|
2186
|
+
{
|
|
2187
|
+
"slug": slug,
|
|
2188
|
+
"path_prefix": path_prefix,
|
|
2189
|
+
"status": "failed",
|
|
2190
|
+
"error": error_msg,
|
|
2191
|
+
"manifest_path": str(manifest_path),
|
|
2192
|
+
}
|
|
2193
|
+
)
|
|
2194
|
+
if strict:
|
|
2195
|
+
raise ValueError(error_msg) from e
|
|
2196
|
+
continue
|
|
2197
|
+
except (KeyError, RuntimeError) as e:
|
|
2198
|
+
error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
|
|
2199
|
+
logger.error(error_msg, exc_info=True)
|
|
2200
|
+
mounted_apps.append(
|
|
2201
|
+
{
|
|
2202
|
+
"slug": slug,
|
|
2203
|
+
"path_prefix": path_prefix,
|
|
2204
|
+
"status": "failed",
|
|
2205
|
+
"error": error_msg,
|
|
2206
|
+
"manifest_path": str(manifest_path),
|
|
2207
|
+
}
|
|
2208
|
+
)
|
|
2209
|
+
if strict:
|
|
2210
|
+
raise RuntimeError(error_msg) from e
|
|
2211
|
+
continue
|
|
2212
|
+
except (OSError, PermissionError, ImportError, AttributeError, TypeError) as e:
|
|
2213
|
+
error_msg = f"Unexpected error mounting app '{slug}' at {path_prefix}: {e}"
|
|
2214
|
+
logger.error(error_msg, exc_info=True)
|
|
2215
|
+
mounted_apps.append(
|
|
2216
|
+
{
|
|
2217
|
+
"slug": slug,
|
|
2218
|
+
"path_prefix": path_prefix,
|
|
2219
|
+
"status": "failed",
|
|
2220
|
+
"error": error_msg,
|
|
2221
|
+
"manifest_path": str(manifest_path),
|
|
2222
|
+
}
|
|
2223
|
+
)
|
|
2224
|
+
if strict:
|
|
2225
|
+
raise RuntimeError(error_msg) from e
|
|
1824
2226
|
continue
|
|
1825
2227
|
|
|
1826
2228
|
# Expose engine and mounted apps info on parent app state
|
|
@@ -1828,6 +2230,9 @@ class MongoDBEngine:
|
|
|
1828
2230
|
app.state.mounted_apps = mounted_apps
|
|
1829
2231
|
app.state.is_multi_app = True
|
|
1830
2232
|
|
|
2233
|
+
# Store app reference in engine for get_mounted_apps()
|
|
2234
|
+
engine._multi_app_instance = app
|
|
2235
|
+
|
|
1831
2236
|
yield
|
|
1832
2237
|
|
|
1833
2238
|
# Shutdown is handled by parent app
|
|
@@ -1875,45 +2280,323 @@ class MongoDBEngine:
|
|
|
1875
2280
|
@parent_app.get("/health")
|
|
1876
2281
|
async def health_check():
|
|
1877
2282
|
"""Unified health check for all mounted apps."""
|
|
2283
|
+
import time
|
|
2284
|
+
|
|
1878
2285
|
from ..observability import check_engine_health, check_mongodb_health
|
|
1879
2286
|
|
|
1880
2287
|
# Both are async functions
|
|
2288
|
+
start_time = time.time()
|
|
1881
2289
|
engine_health = await check_engine_health(engine)
|
|
1882
2290
|
mongo_health = await check_mongodb_health(engine.mongo_client)
|
|
2291
|
+
engine_response_time = int((time.time() - start_time) * 1000)
|
|
1883
2292
|
|
|
2293
|
+
# Check each mounted app's status
|
|
1884
2294
|
mounted_status = {}
|
|
1885
2295
|
for mounted_app_info in mounted_apps:
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2296
|
+
app_slug = mounted_app_info["slug"]
|
|
2297
|
+
path_prefix = mounted_app_info["path_prefix"]
|
|
2298
|
+
status = mounted_app_info["status"]
|
|
2299
|
+
|
|
2300
|
+
app_status = {
|
|
2301
|
+
"path_prefix": path_prefix,
|
|
2302
|
+
"status": status,
|
|
1889
2303
|
}
|
|
1890
|
-
if "error" in mounted_app_info:
|
|
1891
|
-
mounted_status[mounted_app_info["slug"]]["error"] = mounted_app_info["error"]
|
|
1892
2304
|
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
2305
|
+
if "error" in mounted_app_info:
|
|
2306
|
+
app_status["error"] = mounted_app_info["error"]
|
|
2307
|
+
app_status["status"] = "unhealthy"
|
|
2308
|
+
elif status == "mounted":
|
|
2309
|
+
# App is mounted successfully
|
|
2310
|
+
app_status["status"] = "healthy"
|
|
2311
|
+
# Try to get response time by checking if app has routes
|
|
2312
|
+
try:
|
|
2313
|
+
# Find the mounted app and check its route count
|
|
2314
|
+
for route in parent_app.routes:
|
|
2315
|
+
if hasattr(route, "path") and route.path == path_prefix:
|
|
2316
|
+
if hasattr(route, "app"):
|
|
2317
|
+
mounted_app = route.app
|
|
2318
|
+
route_count = len(mounted_app.routes)
|
|
2319
|
+
app_status["route_count"] = route_count
|
|
2320
|
+
break
|
|
2321
|
+
except (AttributeError, TypeError, KeyError):
|
|
2322
|
+
pass
|
|
2323
|
+
|
|
2324
|
+
mounted_status[app_slug] = app_status
|
|
2325
|
+
|
|
2326
|
+
# Determine overall status
|
|
2327
|
+
all_healthy = (
|
|
2328
|
+
engine_health.status.value == "healthy"
|
|
1896
2329
|
and mongo_health.status.value == "healthy"
|
|
1897
|
-
|
|
2330
|
+
and all(
|
|
2331
|
+
app_info.get("status") in ("healthy", "mounted")
|
|
2332
|
+
for app_info in mounted_status.values()
|
|
2333
|
+
)
|
|
1898
2334
|
)
|
|
1899
2335
|
|
|
2336
|
+
overall_status = "healthy" if all_healthy else "unhealthy"
|
|
2337
|
+
|
|
1900
2338
|
return {
|
|
1901
2339
|
"status": overall_status,
|
|
1902
2340
|
"engine": {
|
|
1903
2341
|
"status": engine_health.status.value,
|
|
1904
2342
|
"message": engine_health.message,
|
|
2343
|
+
"response_time_ms": engine_response_time,
|
|
1905
2344
|
},
|
|
1906
2345
|
"mongodb": {
|
|
1907
2346
|
"status": mongo_health.status.value,
|
|
1908
2347
|
"message": mongo_health.message,
|
|
1909
2348
|
},
|
|
1910
|
-
"
|
|
2349
|
+
"apps": mounted_status,
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
# Add route introspection endpoint
|
|
2353
|
+
@parent_app.get("/_mdb/routes")
|
|
2354
|
+
async def list_routes():
|
|
2355
|
+
"""List all routes from all mounted apps."""
|
|
2356
|
+
routes_info = {
|
|
2357
|
+
"parent_app": {
|
|
2358
|
+
"routes": [],
|
|
2359
|
+
},
|
|
2360
|
+
"mounted_apps": {},
|
|
1911
2361
|
}
|
|
1912
2362
|
|
|
2363
|
+
# Get parent app routes
|
|
2364
|
+
for route in parent_app.routes:
|
|
2365
|
+
route_info = {
|
|
2366
|
+
"path": getattr(route, "path", str(route)),
|
|
2367
|
+
"methods": list(getattr(route, "methods", set())),
|
|
2368
|
+
"name": getattr(route, "name", None),
|
|
2369
|
+
}
|
|
2370
|
+
routes_info["parent_app"]["routes"].append(route_info)
|
|
2371
|
+
|
|
2372
|
+
# Get routes from mounted apps
|
|
2373
|
+
for mounted_app_info in mounted_apps:
|
|
2374
|
+
app_slug = mounted_app_info["slug"]
|
|
2375
|
+
path_prefix = mounted_app_info["path_prefix"]
|
|
2376
|
+
status = mounted_app_info["status"]
|
|
2377
|
+
|
|
2378
|
+
if status != "mounted":
|
|
2379
|
+
routes_info["mounted_apps"][app_slug] = {
|
|
2380
|
+
"path_prefix": path_prefix,
|
|
2381
|
+
"status": status,
|
|
2382
|
+
"routes": [],
|
|
2383
|
+
"error": mounted_app_info.get("error"),
|
|
2384
|
+
}
|
|
2385
|
+
continue
|
|
2386
|
+
|
|
2387
|
+
# Find the mounted app
|
|
2388
|
+
app_routes = []
|
|
2389
|
+
for route in parent_app.routes:
|
|
2390
|
+
# Check if this route belongs to the mounted app
|
|
2391
|
+
# Mounted apps appear as Mount routes
|
|
2392
|
+
if hasattr(route, "path") and route.path == path_prefix:
|
|
2393
|
+
# This is the mount point
|
|
2394
|
+
if hasattr(route, "app"):
|
|
2395
|
+
# Get routes from the mounted app
|
|
2396
|
+
mounted_app = route.app
|
|
2397
|
+
for child_route in mounted_app.routes:
|
|
2398
|
+
route_path = getattr(child_route, "path", str(child_route))
|
|
2399
|
+
# Prepend path prefix
|
|
2400
|
+
full_path = (
|
|
2401
|
+
f"{path_prefix}{route_path}"
|
|
2402
|
+
if route_path != "/"
|
|
2403
|
+
else path_prefix
|
|
2404
|
+
)
|
|
2405
|
+
|
|
2406
|
+
route_info = {
|
|
2407
|
+
"path": full_path,
|
|
2408
|
+
"relative_path": route_path,
|
|
2409
|
+
"methods": list(getattr(child_route, "methods", set())),
|
|
2410
|
+
"name": getattr(child_route, "name", None),
|
|
2411
|
+
}
|
|
2412
|
+
app_routes.append(route_info)
|
|
2413
|
+
|
|
2414
|
+
routes_info["mounted_apps"][app_slug] = {
|
|
2415
|
+
"path_prefix": path_prefix,
|
|
2416
|
+
"status": status,
|
|
2417
|
+
"routes": app_routes,
|
|
2418
|
+
"route_count": len(app_routes),
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
return routes_info
|
|
2422
|
+
|
|
2423
|
+
# Aggregate OpenAPI docs from all mounted apps
|
|
2424
|
+
def custom_openapi():
|
|
2425
|
+
"""Generate aggregated OpenAPI schema from all mounted apps."""
|
|
2426
|
+
from fastapi.openapi.utils import get_openapi
|
|
2427
|
+
|
|
2428
|
+
if parent_app.openapi_schema:
|
|
2429
|
+
return parent_app.openapi_schema
|
|
2430
|
+
|
|
2431
|
+
# Get base schema from parent app
|
|
2432
|
+
openapi_schema = get_openapi(
|
|
2433
|
+
title=title,
|
|
2434
|
+
version=fastapi_kwargs.get("version", "1.0.0"),
|
|
2435
|
+
description=fastapi_kwargs.get("description", ""),
|
|
2436
|
+
routes=parent_app.routes,
|
|
2437
|
+
)
|
|
2438
|
+
|
|
2439
|
+
# Aggregate schemas from mounted apps
|
|
2440
|
+
for mounted_app_info in mounted_apps:
|
|
2441
|
+
if mounted_app_info.get("status") != "mounted":
|
|
2442
|
+
continue
|
|
2443
|
+
|
|
2444
|
+
app_slug = mounted_app_info["slug"]
|
|
2445
|
+
path_prefix = mounted_app_info["path_prefix"]
|
|
2446
|
+
|
|
2447
|
+
# Find the mounted app
|
|
2448
|
+
for route in parent_app.routes:
|
|
2449
|
+
if hasattr(route, "path") and route.path == path_prefix:
|
|
2450
|
+
if hasattr(route, "app"):
|
|
2451
|
+
mounted_app = route.app
|
|
2452
|
+
try:
|
|
2453
|
+
# Get OpenAPI schema from mounted app
|
|
2454
|
+
child_schema = get_openapi(
|
|
2455
|
+
title=getattr(mounted_app, "title", app_slug),
|
|
2456
|
+
version=getattr(mounted_app, "version", "1.0.0"),
|
|
2457
|
+
description=getattr(mounted_app, "description", ""),
|
|
2458
|
+
routes=mounted_app.routes,
|
|
2459
|
+
)
|
|
2460
|
+
|
|
2461
|
+
# Merge paths with prefix
|
|
2462
|
+
if "paths" in child_schema:
|
|
2463
|
+
for path, methods in child_schema["paths"].items():
|
|
2464
|
+
# Prepend path prefix
|
|
2465
|
+
prefixed_path = (
|
|
2466
|
+
f"{path_prefix}{path}" if path != "/" else path_prefix
|
|
2467
|
+
)
|
|
2468
|
+
openapi_schema["paths"][prefixed_path] = methods
|
|
2469
|
+
|
|
2470
|
+
# Merge components/schemas
|
|
2471
|
+
if "components" in child_schema:
|
|
2472
|
+
if "components" not in openapi_schema:
|
|
2473
|
+
openapi_schema["components"] = {}
|
|
2474
|
+
if "schemas" in child_schema["components"]:
|
|
2475
|
+
if "schemas" not in openapi_schema["components"]:
|
|
2476
|
+
openapi_schema["components"]["schemas"] = {}
|
|
2477
|
+
openapi_schema["components"]["schemas"].update(
|
|
2478
|
+
child_schema["components"]["schemas"]
|
|
2479
|
+
)
|
|
2480
|
+
|
|
2481
|
+
logger.debug(f"Aggregated OpenAPI schema from app '{app_slug}'")
|
|
2482
|
+
except (AttributeError, TypeError, KeyError, ValueError) as e:
|
|
2483
|
+
logger.warning(
|
|
2484
|
+
f"Failed to aggregate OpenAPI schema from app '{app_slug}': {e}"
|
|
2485
|
+
)
|
|
2486
|
+
break
|
|
2487
|
+
|
|
2488
|
+
parent_app.openapi_schema = openapi_schema
|
|
2489
|
+
return openapi_schema
|
|
2490
|
+
|
|
2491
|
+
parent_app.openapi = custom_openapi
|
|
2492
|
+
|
|
2493
|
+
# Add per-app docs endpoint
|
|
2494
|
+
@parent_app.get("/docs/{app_slug}")
|
|
2495
|
+
async def app_docs(app_slug: str):
|
|
2496
|
+
"""Get OpenAPI docs for a specific app."""
|
|
2497
|
+
from fastapi.openapi.docs import get_swagger_ui_html
|
|
2498
|
+
|
|
2499
|
+
# Find the app
|
|
2500
|
+
mounted_app = None
|
|
2501
|
+
path_prefix = None
|
|
2502
|
+
for mounted_app_info in mounted_apps:
|
|
2503
|
+
if mounted_app_info["slug"] == app_slug:
|
|
2504
|
+
path_prefix = mounted_app_info["path_prefix"]
|
|
2505
|
+
# Find the mounted app
|
|
2506
|
+
for route in parent_app.routes:
|
|
2507
|
+
if hasattr(route, "path") and route.path == path_prefix:
|
|
2508
|
+
if hasattr(route, "app"):
|
|
2509
|
+
mounted_app = route.app
|
|
2510
|
+
break
|
|
2511
|
+
break
|
|
2512
|
+
|
|
2513
|
+
if not mounted_app:
|
|
2514
|
+
from fastapi import HTTPException
|
|
2515
|
+
|
|
2516
|
+
raise HTTPException(404, f"App '{app_slug}' not found or not mounted")
|
|
2517
|
+
|
|
2518
|
+
# Generate OpenAPI JSON for this app
|
|
2519
|
+
from fastapi.openapi.utils import get_openapi
|
|
2520
|
+
|
|
2521
|
+
openapi_schema = get_openapi(
|
|
2522
|
+
title=getattr(mounted_app, "title", app_slug),
|
|
2523
|
+
version=getattr(mounted_app, "version", "1.0.0"),
|
|
2524
|
+
description=getattr(mounted_app, "description", ""),
|
|
2525
|
+
routes=mounted_app.routes,
|
|
2526
|
+
)
|
|
2527
|
+
|
|
2528
|
+
# Modify paths to include prefix
|
|
2529
|
+
if "paths" in openapi_schema:
|
|
2530
|
+
new_paths = {}
|
|
2531
|
+
for path, methods in openapi_schema["paths"].items():
|
|
2532
|
+
prefixed_path = f"{path_prefix}{path}" if path != "/" else path_prefix
|
|
2533
|
+
new_paths[prefixed_path] = methods
|
|
2534
|
+
openapi_schema["paths"] = new_paths
|
|
2535
|
+
|
|
2536
|
+
# Return Swagger UI HTML
|
|
2537
|
+
openapi_url = f"/_mdb/openapi/{app_slug}.json"
|
|
2538
|
+
|
|
2539
|
+
# Store schema temporarily for the JSON endpoint
|
|
2540
|
+
if not hasattr(parent_app.state, "app_openapi_schemas"):
|
|
2541
|
+
parent_app.state.app_openapi_schemas = {}
|
|
2542
|
+
parent_app.state.app_openapi_schemas[app_slug] = openapi_schema
|
|
2543
|
+
|
|
2544
|
+
return get_swagger_ui_html(
|
|
2545
|
+
openapi_url=openapi_url,
|
|
2546
|
+
title=f"{app_slug} - API Documentation",
|
|
2547
|
+
)
|
|
2548
|
+
|
|
2549
|
+
@parent_app.get("/_mdb/openapi/{app_slug}.json")
|
|
2550
|
+
async def app_openapi_json(app_slug: str):
|
|
2551
|
+
"""Get OpenAPI JSON for a specific app."""
|
|
2552
|
+
from fastapi import HTTPException
|
|
2553
|
+
|
|
2554
|
+
if not hasattr(parent_app.state, "app_openapi_schemas"):
|
|
2555
|
+
raise HTTPException(404, f"OpenAPI schema for '{app_slug}' not found")
|
|
2556
|
+
|
|
2557
|
+
schema = parent_app.state.app_openapi_schemas.get(app_slug)
|
|
2558
|
+
if not schema:
|
|
2559
|
+
raise HTTPException(404, f"OpenAPI schema for '{app_slug}' not found")
|
|
2560
|
+
|
|
2561
|
+
return schema
|
|
2562
|
+
|
|
1913
2563
|
logger.info(f"Multi-app parent created with {len(apps)} app(s) configured")
|
|
1914
2564
|
|
|
1915
2565
|
return parent_app
|
|
1916
2566
|
|
|
2567
|
+
def get_mounted_apps(self, app: Optional["FastAPI"] = None) -> list[dict[str, Any]]:
|
|
2568
|
+
"""
|
|
2569
|
+
Get metadata about all mounted apps.
|
|
2570
|
+
|
|
2571
|
+
Args:
|
|
2572
|
+
app: FastAPI app instance (optional, will use engine's tracked app if available)
|
|
2573
|
+
|
|
2574
|
+
Returns:
|
|
2575
|
+
List of dicts with app metadata:
|
|
2576
|
+
- slug: App slug
|
|
2577
|
+
- path_prefix: Path prefix where app is mounted
|
|
2578
|
+
- status: Mount status ("mounted", "failed", etc.)
|
|
2579
|
+
- manifest: App manifest (if available)
|
|
2580
|
+
- error: Error message (if status is "failed")
|
|
2581
|
+
|
|
2582
|
+
Example:
|
|
2583
|
+
mounted_apps = engine.get_mounted_apps(app)
|
|
2584
|
+
for app_info in mounted_apps:
|
|
2585
|
+
print(f"App {app_info['slug']} at {app_info['path_prefix']}")
|
|
2586
|
+
"""
|
|
2587
|
+
if app is None:
|
|
2588
|
+
# Try to get from engine state if available
|
|
2589
|
+
if hasattr(self, "_multi_app_instance"):
|
|
2590
|
+
app = self._multi_app_instance
|
|
2591
|
+
else:
|
|
2592
|
+
raise ValueError(
|
|
2593
|
+
"App instance required. Pass app parameter or use "
|
|
2594
|
+
"app.state.mounted_apps directly."
|
|
2595
|
+
)
|
|
2596
|
+
|
|
2597
|
+
mounted_apps = getattr(app.state, "mounted_apps", [])
|
|
2598
|
+
return mounted_apps
|
|
2599
|
+
|
|
1917
2600
|
async def _initialize_shared_user_pool(
|
|
1918
2601
|
self,
|
|
1919
2602
|
app: "FastAPI",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
mdb_engine/README.md,sha256=T3EFGcPopY9LslYW3lxgG3hohWkAOmBNbYG0FDMUJiY,3502
|
|
2
|
-
mdb_engine/__init__.py,sha256=
|
|
2
|
+
mdb_engine/__init__.py,sha256=hYVtAlyPN9kRthzuVQ5A_m-5IIh5Q4LJIyzOzUQtGE8,3172
|
|
3
3
|
mdb_engine/config.py,sha256=DTAyxfKB8ogyI0v5QR9Y-SJOgXQr_eDBCKxNBSqEyLc,7269
|
|
4
4
|
mdb_engine/constants.py,sha256=eaotvW57TVOg7rRbLziGrVNoP7adgw_G9iVByHezc_A,7837
|
|
5
5
|
mdb_engine/dependencies.py,sha256=MJuYQhZ9ZGzXlip1ha5zba9Rvn04HDPWahJFJH81Q2s,14107
|
|
@@ -26,7 +26,7 @@ mdb_engine/auth/provider.py,sha256=FOSHn-jp4VYtxvmjnzho5kH6y-xDKWbcKUorYRRl1C4,2
|
|
|
26
26
|
mdb_engine/auth/rate_limiter.py,sha256=l3EYZE1Kz9yVfZwNrKq_1AgdD7GXB1WOLSqqGQVSSgA,15808
|
|
27
27
|
mdb_engine/auth/restrictions.py,sha256=tOyQBO_w0bK9zmTsOPZf9cbvh4oITvpNfSxIXt-XrcU,8824
|
|
28
28
|
mdb_engine/auth/session_manager.py,sha256=ywWJjTarm-obgJ3zO3s-1cdqEYe0XrozlY00q_yMJ8I,15396
|
|
29
|
-
mdb_engine/auth/shared_middleware.py,sha256=
|
|
29
|
+
mdb_engine/auth/shared_middleware.py,sha256=nOiswgK8ptx7zUG70YN7LQNhF8PSwwkM_atAO2CAzo4,32100
|
|
30
30
|
mdb_engine/auth/shared_users.py,sha256=25OBks4VRHaYZW7R61vnplV7wmr7RRpDctSgnej_nxc,26773
|
|
31
31
|
mdb_engine/auth/token_lifecycle.py,sha256=Q9S1X2Y6W7Ckt5PvyYXswBRh2Tg9DGpyRv_3Xve7VYQ,6708
|
|
32
32
|
mdb_engine/auth/token_store.py,sha256=-B8j5RH5YEoKsswF4rnMoI51BaxMe4icke3kuehXmcI,9121
|
|
@@ -46,7 +46,7 @@ mdb_engine/core/app_registration.py,sha256=7szt2a7aBkpSppjmhdkkPPYMKGKo0MkLKZeEe
|
|
|
46
46
|
mdb_engine/core/app_secrets.py,sha256=bo-syg9UUATibNyXEZs-0TTYWG-JaY-2S0yNSGA12n0,10524
|
|
47
47
|
mdb_engine/core/connection.py,sha256=XnwuPG34pJ7kJGJ84T0mhj1UZ6_CLz_9qZf6NRYGIS8,8346
|
|
48
48
|
mdb_engine/core/encryption.py,sha256=RZ5LPF5g28E3ZBn6v1IMw_oas7u9YGFtBcEj8lTi9LM,7515
|
|
49
|
-
mdb_engine/core/engine.py,sha256=
|
|
49
|
+
mdb_engine/core/engine.py,sha256=EH3vPgdJuNsg8JiIki4Auz1slrM8dA1WXwfOjPeuh8Y,118185
|
|
50
50
|
mdb_engine/core/index_management.py,sha256=9-r7MIy3JnjQ35sGqsbj8K_I07vAUWtAVgSWC99lJcE,5555
|
|
51
51
|
mdb_engine/core/manifest.py,sha256=jguhjVPAHMZGxOJcdSGouv9_XiKmxUjDmyjn2yXHCj4,139205
|
|
52
52
|
mdb_engine/core/ray_integration.py,sha256=vexYOzztscvRYje1xTNmXJbi99oJxCaVJAwKfTNTF_E,13610
|
|
@@ -89,9 +89,9 @@ mdb_engine/routing/__init__.py,sha256=reupjHi_RTc2ZBA4AH5XzobAmqy4EQIsfSUcTkFknU
|
|
|
89
89
|
mdb_engine/routing/websockets.py,sha256=3X4OjQv_Nln4UmeifJky0gFhMG8A6alR77I8g1iIOLY,29311
|
|
90
90
|
mdb_engine/utils/__init__.py,sha256=lDxQSGqkV4fVw5TWIk6FA6_eey_ZnEtMY0fir3cpAe8,236
|
|
91
91
|
mdb_engine/utils/mongo.py,sha256=Oqtv4tQdpiiZzrilGLEYQPo8Vmh8WsTQypxQs8Of53s,3369
|
|
92
|
-
mdb_engine-0.
|
|
93
|
-
mdb_engine-0.
|
|
94
|
-
mdb_engine-0.
|
|
95
|
-
mdb_engine-0.
|
|
96
|
-
mdb_engine-0.
|
|
97
|
-
mdb_engine-0.
|
|
92
|
+
mdb_engine-0.4.1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
93
|
+
mdb_engine-0.4.1.dist-info/METADATA,sha256=kyV21MMeAU9NF0MuVLYHWX6k1I-GIG01YZ45iNEZIj4,15810
|
|
94
|
+
mdb_engine-0.4.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
95
|
+
mdb_engine-0.4.1.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
|
|
96
|
+
mdb_engine-0.4.1.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
|
|
97
|
+
mdb_engine-0.4.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|