|
12 | 12 | from functools import lru_cache |
13 | 13 | from typing import Literal |
14 | 14 |
|
15 | | -from pydantic import BaseModel |
| 15 | +from pydantic import BaseModel, model_validator |
16 | 16 | from pydantic_settings import BaseSettings, SettingsConfigDict |
17 | 17 |
|
18 | 18 |
|
@@ -102,6 +102,9 @@ class Settings(BaseSettings): |
102 | 102 | # Neon: use the *pooled* connection string on serverless. |
103 | 103 | # Must be the asyncpg driver: postgresql+asyncpg://... |
104 | 104 | database_url: str = "postgresql+asyncpg://documind:documind@localhost:5432/documind" |
| 105 | + # Set from the URL's libpq params by the validator below; asyncpg takes SSL |
| 106 | + # as a connect argument, not a query string. |
| 107 | + database_ssl: str | None = None |
105 | 108 |
|
106 | 109 | # --- OpenAI ------------------------------------------------------------- |
107 | 110 | openai_api_key: str = "" |
@@ -162,6 +165,80 @@ class Settings(BaseSettings): |
162 | 165 | cors_origins: str = "*" |
163 | 166 | api_key: str | None = None # optional shared-secret gate for write endpoints |
164 | 167 |
|
| 168 | + @model_validator(mode="after") |
| 169 | + def _normalise_database_url(self) -> Settings: |
| 170 | + """Accept a connection string copied verbatim from a provider console. |
| 171 | +
|
| 172 | + Neon, Supabase and Render all hand you a libpq URL: |
| 173 | +
|
| 174 | + postgresql://user:pw@host/db?sslmode=require&channel_binding=require |
| 175 | +
|
| 176 | + Two things make that unusable here. The scheme selects psycopg, not |
| 177 | + asyncpg. And `sslmode`/`channel_binding` are libpq parameters — asyncpg |
| 178 | + does not accept them and raises `TypeError: connect() got an unexpected |
| 179 | + keyword argument 'sslmode'`, which reads like a code bug rather than a |
| 180 | + config one. |
| 181 | +
|
| 182 | + Rather than document a hand-edit that everyone will get wrong once, the |
| 183 | + URL is normalised here: driver corrected, libpq-only parameters lifted |
| 184 | + out, and SSL carried across as an asyncpg connect argument. |
| 185 | + """ |
| 186 | + from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit |
| 187 | + |
| 188 | + url = self.database_url.strip() |
| 189 | + if not url: |
| 190 | + return self |
| 191 | + |
| 192 | + parts = urlsplit(url) |
| 193 | + |
| 194 | + scheme = parts.scheme |
| 195 | + if scheme in ("postgres", "postgresql"): |
| 196 | + scheme = "postgresql+asyncpg" |
| 197 | + elif scheme.startswith("postgresql+") and scheme != "postgresql+asyncpg": |
| 198 | + # A sync driver here would fail much later, inside the engine. |
| 199 | + raise ValueError( |
| 200 | + f"DATABASE_URL must use the asyncpg driver, got {parts.scheme!r}. " |
| 201 | + "Use postgresql+asyncpg://…" |
| 202 | + ) |
| 203 | + |
| 204 | + # libpq spellings asyncpg cannot take as query parameters. |
| 205 | + ssl_mode = self.database_ssl |
| 206 | + kept: list[tuple[str, str]] = [] |
| 207 | + for key, value in parse_qsl(parts.query, keep_blank_values=True): |
| 208 | + lowered = key.lower() |
| 209 | + if lowered in ("sslmode", "ssl"): |
| 210 | + ssl_mode = value or ssl_mode |
| 211 | + elif lowered == "channel_binding": |
| 212 | + # asyncpg negotiates channel binding itself; the parameter has |
| 213 | + # no equivalent and is safe to drop. |
| 214 | + continue |
| 215 | + else: |
| 216 | + kept.append((key, value)) |
| 217 | + |
| 218 | + object.__setattr__( |
| 219 | + self, |
| 220 | + "database_url", |
| 221 | + urlunsplit((scheme, parts.netloc, parts.path, urlencode(kept), parts.fragment)), |
| 222 | + ) |
| 223 | + # Managed Postgres is TLS-only; default to requiring it for any remote |
| 224 | + # host so a stripped `sslmode` cannot silently downgrade the connection. |
| 225 | + if ssl_mode is None and parts.hostname not in (None, "localhost", "127.0.0.1", "db"): |
| 226 | + ssl_mode = "require" |
| 227 | + object.__setattr__(self, "database_ssl", ssl_mode) |
| 228 | + return self |
| 229 | + |
| 230 | + def asyncpg_connect_args(self) -> dict: |
| 231 | + """Driver arguments shared by the app engine and Alembic.""" |
| 232 | + args: dict = { |
| 233 | + # Neon's pooler runs in transaction mode, which is incompatible |
| 234 | + # with asyncpg's prepared-statement cache. |
| 235 | + "statement_cache_size": 0, |
| 236 | + "prepared_statement_cache_size": 0, |
| 237 | + } |
| 238 | + if self.database_ssl and self.database_ssl != "disable": |
| 239 | + args["ssl"] = self.database_ssl |
| 240 | + return args |
| 241 | + |
165 | 242 | @property |
166 | 243 | def is_serverless(self) -> bool: |
167 | 244 | return self.environment == "vercel" |
|
0 commit comments