Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/api/public_runtime_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from fastapi import APIRouter

router = APIRouter()


@router.get("/health", include_in_schema=False)
def public_health() -> dict[str, str]:
return {"status": "ok"}


@router.get("/ready", include_in_schema=False)
def public_ready() -> dict[str, str]:
return {"status": "ready"}
129 changes: 112 additions & 17 deletions app/bootstrap/application.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,128 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.bootstrap.dependencies import (
ApplicationDependencies,
_fake_auth_is_requested,
create_application_dependencies,
)
from app.bootstrap.public_profile import (
validate_public_profile_dependencies,
validate_public_profile_static,
)
from app.config import RuntimeProfile, Settings
from app.core.errors import install_error_handlers
from app.core.request_context import RequestContextMiddleware
from app.core.security_headers import SecurityHeadersMiddleware
from app.modules.identity.router import router as identity_router
from app.modules.sessions.middleware import (
CookieCsrfMiddleware,
SessionWorkspaceContextMiddleware,
)


def create_app(*, dependencies: ApplicationDependencies | None = None) -> FastAPI:
"""Create the DirectPilot ASGI application with stable public metadata."""
app = FastAPI(
title="DirectPilot Beta API",
version="0.2.1",
description="Standalone API-first beta app for safe Yandex Direct automation.",
)
app.state.dependencies = (
dependencies if dependencies is not None else create_application_dependencies()
class _ApplicationRuntimeCloser:
def __init__(self, dependencies: ApplicationDependencies) -> None:
self._runtime = dependencies.database_runtime
self._closed = False

def close(self) -> None:
if self._closed:
return
self._closed = True
if self._runtime is not None:
self._runtime.close()


@asynccontextmanager
async def _application_lifespan(app: FastAPI) -> AsyncIterator[None]:
try:
yield
finally:
app.state.runtime_closer.close()


def _validate_explicit_dependencies(
*,
settings: Settings,
dependencies: ApplicationDependencies,
include_legacy_router: bool,
) -> None:
validate_public_profile_static(
settings,
include_legacy_router=include_legacy_router,
fake_auth_enabled=dependencies.fake_auth_enabled or _fake_auth_is_requested(),
database_configured=dependencies.database_runtime is not None,
)
app.add_middleware(SessionWorkspaceContextMiddleware)
app.add_middleware(RequestContextMiddleware)
app.add_middleware(CookieCsrfMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.include_router(identity_router)
install_error_handlers(app)
return app
validate_public_profile_dependencies(settings, dependencies)


def create_app(
*,
settings: Settings | None = None,
dependencies: ApplicationDependencies | None = None,
include_legacy_router: bool = False,
) -> FastAPI:
"""Create the DirectPilot ASGI application for the selected runtime profile."""

settings = settings if settings is not None else Settings()
explicit_dependencies = dependencies is not None
if dependencies is None:
dependencies = create_application_dependencies(
settings,
include_legacy_router=include_legacy_router,
)

closer = _ApplicationRuntimeCloser(dependencies)
try:
if explicit_dependencies:
_validate_explicit_dependencies(
settings=settings,
dependencies=dependencies,
include_legacy_router=include_legacy_router,
)

is_public = settings.runtime_profile is RuntimeProfile.PUBLIC
app = FastAPI(
title="DirectPilot Beta API",
version="0.2.1",
description="Standalone API-first beta app for safe Yandex Direct automation.",
docs_url=None if is_public else "/docs",
redoc_url=None if is_public else "/redoc",
openapi_url=None if is_public else "/openapi.json",
lifespan=_application_lifespan,
)
app.state.dependencies = dependencies
app.state.runtime_closer = closer
app.add_middleware(SessionWorkspaceContextMiddleware)
app.add_middleware(RequestContextMiddleware)
app.add_middleware(CookieCsrfMiddleware)
app.add_middleware(SecurityHeadersMiddleware)

if is_public:
from app.api.public_runtime_router import router as public_runtime_router
from app.modules.integrations.yandex.router import (
router as yandex_oauth_router,
)

app.include_router(public_runtime_router)
app.include_router(yandex_oauth_router)
else:
from app.modules.identity.router import router as identity_router
from app.modules.integrations.yandex.router import (
router as yandex_oauth_router,
)

app.include_router(identity_router)
app.include_router(yandex_oauth_router)
if include_legacy_router:
from app.api.legacy_router import router as legacy_router

app.include_router(legacy_router)

install_error_handlers(app)
return app
except Exception:
closer.close()
raise
171 changes: 152 additions & 19 deletions app/bootstrap/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@

from fastapi import Depends, Request

from app.config import Settings, get_settings
from app.bootstrap.public_profile import (
PublicProfileConfigurationError,
validate_public_profile_dependencies,
validate_public_profile_static,
)
from app.config import RuntimeProfile, Settings, get_settings
from app.db.engine import (
DATABASE_URL_ENV,
DatabaseRuntime,
Expand All @@ -15,6 +20,29 @@
)
from app.db.schema import check_schema_compatibility
from app.modules.identity.repository import PostgresIdentityRepository
from app.modules.integrations.yandex.client_factory import (
ConnectionScopedDirectClientFactory,
PostgresConnectionScopedDirectClientFactory,
)
from app.modules.integrations.yandex.credentials import (
CredentialConfigurationError,
CredentialKeyRing,
CredentialVault,
)
from app.modules.integrations.yandex.oauth import (
YandexOAuthConfiguration,
YandexOAuthIntegration,
)
from app.modules.integrations.yandex.provider import HttpxYandexOAuthProvider
from app.modules.integrations.yandex.refresh import (
YandexConnectionLifecycle,
YandexConnectionLifecycleService,
)
from app.modules.integrations.yandex.repository import (
PostgresExternalIdentityRepository,
PostgresOAuthTransactionRepository,
PostgresYandexProviderConnectionRepository,
)
from app.modules.sessions.service import PostgresSessionService
from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer
from app.providers.protocols import (
Expand All @@ -26,6 +54,7 @@
DefaultDirectClientFactory,
DefaultMetrikaClientFactory,
DefaultWordstatClientFactory,
PublicDirectClientFactory,
)
from app.repositories.context import RequestRepositoryProxy
from app.repositories.mock_store import MockStoreRepositoryAdapter
Expand All @@ -38,6 +67,7 @@

legacy_store_adapter = MockStoreRepositoryAdapter(mock_store)
legacy_store = RequestRepositoryProxy(legacy_store_adapter)
_CACHED_SETTINGS_RESOLVER = get_settings


@dataclass(frozen=True, slots=True)
Expand All @@ -46,49 +76,152 @@ class ApplicationDependencies:
direct_client_factory: DirectClientFactory
metrika_client_factory: MetrikaClientFactory
wordstat_client_factory: WordstatClientFactory
connection_scoped_direct_client_factory: ConnectionScopedDirectClientFactory | None = None
database_runtime: DatabaseRuntime | None = None
identity_repository: PostgresIdentityRepository | None = None
session_service: PostgresSessionService | None = None
workspace_authorizer: PostgresWorkspaceAuthorizer | None = None
yandex_oauth: YandexOAuthIntegration | None = None
yandex_connection_lifecycle: YandexConnectionLifecycle | None = None
fake_auth_enabled: bool = False
credential_vault: CredentialVault | None = None


def _fake_auth_is_enabled(app_env: str) -> bool:
return app_env in {"local", "test"} and os.environ.get(
"DIRECTPILOT_ENABLE_FAKE_AUTH", ""
).lower() == "1"
return app_env in {"local", "test"} and _fake_auth_is_requested()


def _fake_auth_is_requested() -> bool:
return os.environ.get("DIRECTPILOT_ENABLE_FAKE_AUTH", "").lower() == "1"


def _configured_yandex_oauth(settings: Settings) -> YandexOAuthConfiguration | None:
if settings.yandex_client_id is None or settings.yandex_client_secret is None:
return None
return YandexOAuthConfiguration(
client_id=settings.yandex_client_id,
client_secret=settings.yandex_client_secret,
redirect_uri=settings.yandex_oauth_redirect_uri,
)


def _configured_credential_vault(settings: Settings) -> CredentialVault:
secret_file = settings.credential_keyring_secret_file
if not secret_file:
raise CredentialConfigurationError("Credential key-ring secret file is not configured")
return CredentialVault(CredentialKeyRing.from_json_secret_file(secret_file))

def create_application_dependencies() -> ApplicationDependencies:

def _direct_client_factory_for(settings: Settings) -> DirectClientFactory:
if settings.runtime_profile is RuntimeProfile.PUBLIC:
return PublicDirectClientFactory()
return DefaultDirectClientFactory()


def _resolve_application_settings(settings: Settings | None) -> Settings:
if settings is not None:
return settings
if get_settings is not _CACHED_SETTINGS_RESOLVER:
return get_settings()
return Settings()


def create_application_dependencies(
settings: Settings | None = None,
*,
include_legacy_router: bool = False,
) -> ApplicationDependencies:
"""Create dependency wiring from one resolved Settings object."""

settings = _resolve_application_settings(settings)
database_url = os.environ.get(DATABASE_URL_ENV)
app_env = os.environ.get("DIRECTPILOT_APP_ENV", "local").lower()
fake_auth_enabled = _fake_auth_is_enabled(settings.app_env.lower())
validate_public_profile_static(
settings,
include_legacy_router=include_legacy_router,
fake_auth_enabled=fake_auth_enabled or _fake_auth_is_requested(),
database_configured=bool(database_url),
)

preloaded_credential_vault: CredentialVault | None = None
if settings.runtime_profile is RuntimeProfile.PUBLIC:
try:
preloaded_credential_vault = _configured_credential_vault(settings)
except CredentialConfigurationError:
raise PublicProfileConfigurationError(
"public_credential_keyring_unavailable"
) from None

if database_url:
oauth_config = _configured_yandex_oauth(settings)
runtime = create_database_runtime(
DatabaseSettings.from_mapping({DATABASE_URL_ENV: database_url})
)
try:
credential_persister = None
yandex_provider = None
connection_lifecycle = None
credential_vault = preloaded_credential_vault
if credential_vault is None and settings.credential_keyring_secret_file:
credential_vault = _configured_credential_vault(settings)
workspace_authorizer = PostgresWorkspaceAuthorizer(runtime.sessions)
if oauth_config is not None:
connection_repository = PostgresYandexProviderConnectionRepository(
runtime.sessions,
vault=credential_vault or _configured_credential_vault(settings),
)
credential_persister = connection_repository
yandex_provider = HttpxYandexOAuthProvider(
config=oauth_config,
)
connection_lifecycle = YandexConnectionLifecycleService(
repository=connection_repository,
provider=yandex_provider,
)
check_schema_compatibility(runtime)
dependencies = ApplicationDependencies(
repository=PostgresLegacyStoreRepository(runtime.sessions),
direct_client_factory=_direct_client_factory_for(settings),
metrika_client_factory=DefaultMetrikaClientFactory(),
wordstat_client_factory=DefaultWordstatClientFactory(),
connection_scoped_direct_client_factory=(
None
if credential_vault is None
else PostgresConnectionScopedDirectClientFactory(
sessions=runtime.sessions,
vault=credential_vault,
workspace_authorizer=workspace_authorizer,
settings=settings,
)
),
database_runtime=runtime,
identity_repository=PostgresIdentityRepository(runtime.sessions),
session_service=PostgresSessionService(runtime.sessions),
workspace_authorizer=workspace_authorizer,
yandex_oauth=YandexOAuthIntegration(
transactions=PostgresOAuthTransactionRepository(runtime.sessions),
identities=PostgresExternalIdentityRepository(runtime.sessions),
config=oauth_config,
provider=yandex_provider,
credential_persister=credential_persister,
),
yandex_connection_lifecycle=connection_lifecycle,
fake_auth_enabled=fake_auth_enabled,
credential_vault=credential_vault,
)
validate_public_profile_dependencies(settings, dependencies)
return dependencies
except Exception:
runtime.close()
raise
return ApplicationDependencies(
repository=PostgresLegacyStoreRepository(runtime.sessions),
direct_client_factory=DefaultDirectClientFactory(),
metrika_client_factory=DefaultMetrikaClientFactory(),
wordstat_client_factory=DefaultWordstatClientFactory(),
database_runtime=runtime,
identity_repository=PostgresIdentityRepository(runtime.sessions),
session_service=PostgresSessionService(runtime.sessions),
workspace_authorizer=PostgresWorkspaceAuthorizer(runtime.sessions),
fake_auth_enabled=_fake_auth_is_enabled(app_env),
)
if app_env in {"production", "staging"}:
if settings.app_env.lower() in {"production", "staging"}:
DatabaseSettings.from_mapping({})
return ApplicationDependencies(
repository=legacy_store_adapter,
direct_client_factory=DefaultDirectClientFactory(),
direct_client_factory=_direct_client_factory_for(settings),
metrika_client_factory=DefaultMetrikaClientFactory(),
wordstat_client_factory=DefaultWordstatClientFactory(),
fake_auth_enabled=fake_auth_enabled,
)


Expand Down
Loading
Loading