Coverage for polar/app.py: 88%
104 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1import contextlib
2from collections.abc import AsyncIterator
3from typing import TypedDict
5import structlog
6from fastapi import FastAPI
7from fastapi.routing import APIRoute
9from polar import worker # noqa
10from polar.api import router
11from polar.auth.middlewares import AuthSubjectMiddleware
12from polar.backoffice import app as backoffice_app
13from polar.checkout import ip_geolocation
14from polar.config import settings
15from polar.exception_handlers import add_exception_handlers
16from polar.health.endpoints import router as health_router
17from polar.kit.cors import CORSConfig, CORSMatcherMiddleware, Scope
18from polar.kit.db.postgres import (
19 AsyncEngine,
20 AsyncSessionMaker,
21 Engine,
22 SyncSessionMaker,
23 create_async_sessionmaker,
24 create_sync_sessionmaker,
25)
26from polar.logfire import (
27 configure_logfire,
28 instrument_fastapi,
29 instrument_httpx,
30 instrument_sqlalchemy,
31)
32from polar.logging import Logger
33from polar.logging import configure as configure_logging
34from polar.middlewares import (
35 FlushEnqueuedWorkerJobsMiddleware,
36 LogCorrelationIdMiddleware,
37 PathRewriteMiddleware,
38 SandboxResponseHeaderMiddleware,
39)
40from polar.oauth2.endpoints.well_known import router as well_known_router
41from polar.oauth2.exception_handlers import OAuth2Error, oauth2_error_exception_handler
42from polar.openapi import OPENAPI_PARAMETERS, APITag, set_openapi_generator
43from polar.postgres import (
44 AsyncSessionMiddleware,
45 create_async_engine,
46 create_async_read_engine,
47 create_sync_engine,
48)
49from polar.posthog import configure_posthog
50from polar.redis import Redis, create_redis
51from polar.sentry import configure_sentry
52from polar.webhook.webhooks import document_webhooks
54from . import rate_limit
56log: Logger = structlog.get_logger()
59def configure_cors(app: FastAPI) -> None:
60 configs: list[CORSConfig] = []
62 # Polar frontend CORS configuration
63 if settings.CORS_ORIGINS: 63 ↛ 78line 63 didn't jump to line 78 because the condition on line 63 was always true
65 def polar_frontend_matcher(origin: str, scope: Scope) -> bool:
66 return origin in settings.CORS_ORIGINS
68 polar_frontend_config = CORSConfig(
69 polar_frontend_matcher,
70 allow_origins=[str(origin) for origin in settings.CORS_ORIGINS],
71 allow_credentials=True, # Cookies are allowed, but only there!
72 allow_methods=["*"],
73 allow_headers=["*"],
74 )
75 configs.append(polar_frontend_config)
77 # External API calls CORS configuration
78 api_config = CORSConfig(
79 lambda origin, scope: True,
80 allow_origins=["*"],
81 allow_credentials=False, # No cookies allowed
82 allow_methods=["*"],
83 allow_headers=["Authorization"], # Allow Authorization header to pass tokens
84 )
85 configs.append(api_config)
87 app.add_middleware(CORSMatcherMiddleware, configs=configs)
90def generate_unique_openapi_id(route: APIRoute) -> str:
91 parts = [str(tag) for tag in route.tags if tag not in APITag] + [route.name]
92 return ":".join(parts)
95class State(TypedDict):
96 async_engine: AsyncEngine
97 async_sessionmaker: AsyncSessionMaker
98 async_read_engine: AsyncEngine
99 async_read_sessionmaker: AsyncSessionMaker
100 sync_engine: Engine
101 sync_sessionmaker: SyncSessionMaker
103 redis: Redis
104 ip_geolocation_client: ip_geolocation.IPGeolocationClient | None
107@contextlib.asynccontextmanager
108async def lifespan(app: FastAPI) -> AsyncIterator[State]:
109 log.info("Starting Polar API")
111 async_engine = async_read_engine = create_async_engine("app")
112 async_sessionmaker = async_read_sessionmaker = create_async_sessionmaker(
113 async_engine
114 )
115 instrument_engines = [async_engine.sync_engine]
117 if settings.is_read_replica_configured(): 117 ↛ 122line 117 didn't jump to line 122 because the condition on line 117 was always true
118 async_read_engine = create_async_read_engine("app")
119 async_read_sessionmaker = create_async_sessionmaker(async_read_engine)
120 instrument_engines.append(async_read_engine.sync_engine)
122 sync_engine = create_sync_engine("app")
123 sync_sessionmaker = create_sync_sessionmaker(sync_engine)
124 instrument_engines.append(sync_engine)
125 instrument_sqlalchemy(instrument_engines)
127 redis = create_redis("app")
129 try:
130 ip_geolocation_client = None # Disabled for testing
131 except FileNotFoundError:
132 log.info(
133 "IP geolocation database not found. "
134 "Checkout won't automatically geolocate IPs."
135 )
136 ip_geolocation_client = None
138 log.info("Polar API started")
140 yield {
141 "async_engine": async_engine,
142 "async_sessionmaker": async_sessionmaker,
143 "async_read_engine": async_read_engine,
144 "async_read_sessionmaker": async_read_sessionmaker,
145 "sync_engine": sync_engine,
146 "sync_sessionmaker": sync_sessionmaker,
147 "redis": redis,
148 "ip_geolocation_client": ip_geolocation_client,
149 }
151 await redis.close(True)
152 await async_engine.dispose()
153 if async_read_engine is not async_engine: 153 ↛ 155line 153 didn't jump to line 155 because the condition on line 153 was always true
154 await async_read_engine.dispose()
155 sync_engine.dispose()
156 if ip_geolocation_client is not None: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 ip_geolocation_client.close()
159 log.info("Polar API stopped")
162def create_app() -> FastAPI:
163 app = FastAPI(
164 generate_unique_id_function=generate_unique_openapi_id,
165 lifespan=lifespan,
166 **OPENAPI_PARAMETERS,
167 )
169 if settings.is_sandbox(): 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 app.add_middleware(SandboxResponseHeaderMiddleware)
171 if not settings.is_testing(): 171 ↛ 176line 171 didn't jump to line 176 because the condition on line 171 was always true
172 app.add_middleware(rate_limit.get_middleware)
173 app.add_middleware(AuthSubjectMiddleware)
174 app.add_middleware(FlushEnqueuedWorkerJobsMiddleware)
175 app.add_middleware(AsyncSessionMiddleware)
176 app.add_middleware(PathRewriteMiddleware, pattern=r"^/api/v1", replacement="/v1")
177 app.add_middleware(LogCorrelationIdMiddleware)
179 configure_cors(app)
181 add_exception_handlers(app)
182 app.add_exception_handler(OAuth2Error, oauth2_error_exception_handler) # pyright: ignore
184 # /.well-known
185 app.include_router(well_known_router)
187 # /healthz
188 app.include_router(health_router)
190 if settings.BACKOFFICE_HOST is None: 190 ↛ 193line 190 didn't jump to line 193 because the condition on line 190 was always true
191 app.mount("/backoffice", backoffice_app)
192 else:
193 app.host(settings.BACKOFFICE_HOST, backoffice_app)
195 app.include_router(router)
196 document_webhooks(app)
198 return app
201configure_sentry()
202configure_logfire("server")
203configure_logging(logfire=True)
204configure_posthog()
206app = create_app()
207set_openapi_generator(app)
208instrument_fastapi(app)
209instrument_httpx()