Coverage for polar/app.py: 82%

112 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 16:17 +0000

1import contextlib 1b

2from collections.abc import AsyncIterator 1b

3from typing import TypedDict 1b

4 

5import structlog 1b

6from fastapi import FastAPI 1b

7from fastapi.routing import APIRoute 1b

8 

9from polar import worker # noqa 1b

10from polar.api import router 1b

11from polar.auth.middlewares import AuthSubjectMiddleware 1b

12from polar.backoffice import app as backoffice_app 1b

13from polar.checkout import ip_geolocation 1b

14from polar.config import settings 1b

15from polar.exception_handlers import add_exception_handlers 1b

16from polar.health.endpoints import router as health_router 1b

17from polar.kit.cors import CORSConfig, CORSMatcherMiddleware, Scope 1b

18from polar.kit.db.postgres import ( 1b

19 AsyncEngine, 

20 AsyncSessionMaker, 

21 Engine, 

22 SyncSessionMaker, 

23 create_async_sessionmaker, 

24 create_sync_sessionmaker, 

25) 

26from polar.logfire import ( 1b

27 configure_logfire, 

28 instrument_fastapi, 

29 instrument_httpx, 

30 instrument_sqlalchemy, 

31) 

32from polar.logging import Logger 1b

33from polar.logging import configure as configure_logging 1b

34from polar.middlewares import ( 1b

35 FlushEnqueuedWorkerJobsMiddleware, 

36 LogCorrelationIdMiddleware, 

37 PathRewriteMiddleware, 

38 SandboxResponseHeaderMiddleware, 

39) 

40from polar.oauth2.endpoints.well_known import router as well_known_router 1b

41from polar.oauth2.exception_handlers import OAuth2Error, oauth2_error_exception_handler 1b

42from polar.openapi import OPENAPI_PARAMETERS, APITag, set_openapi_generator 1b

43from polar.postgres import ( 1b

44 AsyncSessionMiddleware, 

45 create_async_engine, 

46 create_async_read_engine, 

47 create_sync_engine, 

48) 

49from polar.posthog import configure_posthog 1b

50from polar.redis import Redis, create_redis 1b

51from polar.sentry import configure_sentry 1b

52from polar.webhook.webhooks import document_webhooks 1b

53 

54from . import rate_limit 1b

55 

56log: Logger = structlog.get_logger() 1b

57 

58 

59def configure_cors(app: FastAPI) -> None: 1b

60 configs: list[CORSConfig] = [] 

61 

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

64 

65 def polar_frontend_matcher(origin: str, scope: Scope) -> bool: 

66 return origin in settings.CORS_ORIGINS 

67 

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) 

76 

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) 

86 

87 app.add_middleware(CORSMatcherMiddleware, configs=configs) 

88 

89 

90def generate_unique_openapi_id(route: APIRoute) -> str: 1b

91 parts = [str(tag) for tag in route.tags if tag not in APITag] + [route.name] 

92 return ":".join(parts) 

93 

94 

95class State(TypedDict): 1b

96 async_engine: AsyncEngine 1b

97 async_sessionmaker: AsyncSessionMaker 1b

98 async_read_engine: AsyncEngine 1b

99 async_read_sessionmaker: AsyncSessionMaker 1b

100 sync_engine: Engine 1b

101 sync_sessionmaker: SyncSessionMaker 1b

102 

103 redis: Redis 1b

104 ip_geolocation_client: ip_geolocation.IPGeolocationClient | None 1b

105 

106 

107@contextlib.asynccontextmanager 1b

108async def lifespan(app: FastAPI) -> AsyncIterator[State]: 1b

109 log.info("Starting Polar API") 

110 

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] 

116 

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) 

121 

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) 

126 

127 redis = create_redis("app") 

128 

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 

137 

138 log.info("Polar API started") 

139 

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 } 

150 

151 await redis.close(True) 

152 await async_engine.dispose() 

153 if async_read_engine is not async_engine: 

154 await async_read_engine.dispose() 

155 sync_engine.dispose() 

156 if ip_geolocation_client is not None: 

157 ip_geolocation_client.close() 

158 

159 log.info("Polar API stopped") 

160 

161 

162def create_app() -> FastAPI: 1b

163 app = FastAPI( 

164 generate_unique_id_function=generate_unique_openapi_id, 

165 lifespan=lifespan, 

166 **OPENAPI_PARAMETERS, 

167 ) 

168 

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) 

178 

179 configure_cors(app) 

180 

181 add_exception_handlers(app) 

182 app.add_exception_handler(OAuth2Error, oauth2_error_exception_handler) # pyright: ignore 

183 

184 # /.well-known 

185 app.include_router(well_known_router) 

186 

187 # /healthz 

188 app.include_router(health_router) 

189 

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) 

194 

195 app.include_router(router) 

196 document_webhooks(app) 

197 

198 return app 

199 

200 

201configure_sentry() 1b

202configure_logfire("server") 1ba

203configure_logging(logfire=True) 

204configure_posthog() 

205 

206app = create_app() 

207set_openapi_generator(app) 

208instrument_fastapi(app) 

209instrument_httpx()