Skip to content

Commit 4499bfb

Browse files
feat(api): add azure blob storage adapter and provider factory
Implement AzureBlobStorageProvider against the StorageProvider contract (container/blob mapping, upload/download/delete, SAS signed URLs). Auth resolves connection string > shared key > managed identity; azure-identity and the SDK ship behind the optional storage-azure extra. Add a cached get_storage_provider factory selecting the backend from STORAGE_BACKEND, a Storage FastAPI dependency, and lifespan cleanup via aclose_storage_provider. Cover the adapter and factory with unit tests. Co-Authored-By: First Fluke <our.first.fluke@gmail.com>
1 parent 11683ec commit 4499bfb

11 files changed

Lines changed: 1072 additions & 2 deletions

File tree

apps/api/pyproject.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ dependencies = [
3535
[project.optional-dependencies]
3636
genai-openai = ["opentelemetry-instrumentation-openai-v2>=2.4b0"]
3737
genai-google = ["opentelemetry-instrumentation-google-genai>=0.7b1"]
38+
# Object storage backend (STORAGE_BACKEND="azure"). azure-identity enables
39+
# managed/workload identity; connection-string and shared-key auth need only
40+
# azure-storage-blob. Install with `uv sync --extra storage-azure`.
41+
storage-azure = [
42+
"azure-storage-blob>=12.24.0",
43+
"azure-identity>=1.19.0",
44+
"aiohttp>=3.10.0",
45+
]
3846

3947
[dependency-groups]
4048
dev = [
@@ -108,3 +116,8 @@ module = [
108116
"opentelemetry.instrumentation.google_genai.*",
109117
]
110118
ignore_missing_imports = true
119+
120+
# Optional object storage SDKs (installed via the storage-azure extra only).
121+
[[tool.mypy.overrides]]
122+
module = ["azure.*"]
123+
ignore_missing_imports = true

apps/api/src/lib/config.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,22 @@ class Settings(BaseSettings):
6060
OPENAI_API_KEY: str | None = None
6161

6262
# Storage (optional)
63-
STORAGE_BACKEND: Literal["gcs", "s3", "minio"] = "minio"
63+
STORAGE_BACKEND: Literal["gcs", "s3", "minio", "azure"] = "minio"
6464
GCS_BUCKET_NAME: str | None = None
6565
MINIO_ENDPOINT: str = "localhost:9000"
6666
MINIO_ACCESS_KEY: str = "minioadmin"
6767
MINIO_SECRET_KEY: str = "minioadmin" # noqa: S105
6868

69+
# Azure Blob Storage (used when STORAGE_BACKEND="azure").
70+
# Auth resolves in order: connection string > account name + key (shared
71+
# key) > account name + managed/workload identity (DefaultAzureCredential).
72+
AZURE_STORAGE_CONNECTION_STRING: str | None = None
73+
AZURE_STORAGE_ACCOUNT_NAME: str | None = None
74+
AZURE_STORAGE_ACCOUNT_KEY: str | None = None
75+
AZURE_STORAGE_CONTAINER: str | None = None
76+
# Override for sovereign clouds (e.g. core.chinacloudapi.cn).
77+
AZURE_STORAGE_ENDPOINT_SUFFIX: str = "core.windows.net"
78+
6979

7080
@lru_cache
7181
def get_settings() -> Settings:

apps/api/src/lib/dependencies.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,21 @@
1111
get_optional_user,
1212
)
1313
from src.lib.database import get_db
14+
from src.lib.storage import StorageProvider, get_storage_provider
1415

1516
# Type alias for database session dependency
1617
DBSession = Annotated[AsyncSession, Depends(get_db)]
1718

19+
# Type alias for object storage dependency
20+
Storage = Annotated[StorageProvider, Depends(get_storage_provider)]
21+
1822
# Re-export auth dependencies for convenience
1923
__all__ = [
2024
"CurrentUser",
2125
"CurrentUserInfo",
2226
"DBSession",
2327
"OptionalUser",
28+
"Storage",
2429
"get_current_user",
2530
"get_optional_user",
2631
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Object storage provider abstraction.
2+
3+
Concrete adapters live in submodules and may require optional extras (e.g.
4+
``AzureBlobStorageProvider`` needs the ``storage-azure`` extra). They are
5+
imported from their submodule rather than re-exported here so that the base
6+
contract has no third-party SDK dependency::
7+
8+
from src.lib.storage.azure import AzureBlobStorageProvider
9+
"""
10+
11+
from src.lib.storage.base import StorageProvider
12+
from src.lib.storage.factory import aclose_storage_provider, get_storage_provider
13+
14+
__all__ = [
15+
"StorageProvider",
16+
"aclose_storage_provider",
17+
"get_storage_provider",
18+
]

apps/api/src/lib/storage/azure.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Azure Blob Storage adapter implementing the StorageProvider contract.
2+
3+
The StorageProvider interface is bucket/key oriented; in Azure terms the
4+
``bucket`` argument maps to a *container* and ``key`` maps to a *blob name*.
5+
6+
Authentication resolves in this order (first match wins):
7+
8+
1. ``AZURE_STORAGE_CONNECTION_STRING``
9+
2. ``AZURE_STORAGE_ACCOUNT_NAME`` + ``AZURE_STORAGE_ACCOUNT_KEY`` (shared key)
10+
3. ``AZURE_STORAGE_ACCOUNT_NAME`` + ``DefaultAzureCredential``
11+
(managed identity / workload identity — recommended in production)
12+
13+
Signed URLs are issued as SAS tokens. Shared-key auth signs with the account
14+
key directly; identity-based auth requests a short-lived *user delegation key*
15+
from Azure AD and signs with that instead.
16+
17+
This adapter requires the optional ``storage-azure`` extra::
18+
19+
uv sync --extra storage-azure
20+
"""
21+
22+
from datetime import UTC, datetime, timedelta
23+
24+
from azure.core.exceptions import ResourceNotFoundError
25+
from azure.storage.blob import (
26+
BlobSasPermissions,
27+
ContentSettings,
28+
generate_blob_sas,
29+
)
30+
from azure.storage.blob.aio import BlobServiceClient
31+
32+
from src.lib.config import Settings, settings
33+
from src.lib.storage.base import StorageProvider
34+
35+
36+
class StorageConfigurationError(RuntimeError):
37+
"""Raised when Azure Blob Storage credentials are not configured."""
38+
39+
40+
def _parse_connection_string(connection_string: str) -> dict[str, str]:
41+
"""Parse a ``key=value;...`` Azure connection string into a dict."""
42+
parts: dict[str, str] = {}
43+
for segment in connection_string.split(";"):
44+
segment = segment.strip()
45+
if not segment:
46+
continue
47+
key, sep, value = segment.partition("=")
48+
if sep:
49+
parts[key.strip()] = value.strip()
50+
return parts
51+
52+
53+
class AzureBlobStorageProvider(StorageProvider):
54+
"""StorageProvider backed by Azure Blob Storage (async SDK)."""
55+
56+
def __init__(
57+
self,
58+
client: BlobServiceClient,
59+
account_name: str,
60+
account_key: str | None = None,
61+
) -> None:
62+
"""Wrap an already-configured async ``BlobServiceClient``.
63+
64+
Args:
65+
client: Async Azure ``BlobServiceClient``.
66+
account_name: Storage account name (used for SAS generation).
67+
account_key: Shared key. When ``None``, signed URLs are issued via
68+
a user delegation key (managed/workload identity).
69+
"""
70+
self._client = client
71+
self._account_name = account_name
72+
self._account_key = account_key
73+
74+
@classmethod
75+
def from_settings(
76+
cls, config: Settings | None = None
77+
) -> "AzureBlobStorageProvider":
78+
"""Build an adapter from application settings.
79+
80+
Raises:
81+
StorageConfigurationError: When no usable credentials are found.
82+
"""
83+
config = config or settings
84+
85+
if config.AZURE_STORAGE_CONNECTION_STRING:
86+
parsed = _parse_connection_string(config.AZURE_STORAGE_CONNECTION_STRING)
87+
account_name = parsed.get("AccountName")
88+
if not account_name:
89+
msg = "AZURE_STORAGE_CONNECTION_STRING is missing AccountName"
90+
raise StorageConfigurationError(msg)
91+
client = BlobServiceClient.from_connection_string(
92+
config.AZURE_STORAGE_CONNECTION_STRING
93+
)
94+
return cls(client, account_name, account_key=parsed.get("AccountKey"))
95+
96+
account_name = config.AZURE_STORAGE_ACCOUNT_NAME
97+
if not account_name:
98+
msg = (
99+
"Azure storage requires AZURE_STORAGE_CONNECTION_STRING or "
100+
"AZURE_STORAGE_ACCOUNT_NAME"
101+
)
102+
raise StorageConfigurationError(msg)
103+
104+
account_url = (
105+
f"https://{account_name}.blob.{config.AZURE_STORAGE_ENDPOINT_SUFFIX}"
106+
)
107+
108+
if config.AZURE_STORAGE_ACCOUNT_KEY:
109+
client = BlobServiceClient(
110+
account_url, credential=config.AZURE_STORAGE_ACCOUNT_KEY
111+
)
112+
return cls(
113+
client, account_name, account_key=config.AZURE_STORAGE_ACCOUNT_KEY
114+
)
115+
116+
# Fall back to managed/workload identity. azure-identity is imported
117+
# lazily so connection-string / shared-key deployments don't need it.
118+
try:
119+
from azure.identity.aio import DefaultAzureCredential
120+
except ImportError as exc: # pragma: no cover - optional dependency
121+
msg = (
122+
"Managed-identity Azure auth requires azure-identity; install "
123+
"the storage-azure extra or set AZURE_STORAGE_ACCOUNT_KEY"
124+
)
125+
raise StorageConfigurationError(msg) from exc
126+
127+
client = BlobServiceClient(account_url, credential=DefaultAzureCredential())
128+
return cls(client, account_name, account_key=None)
129+
130+
async def upload(
131+
self,
132+
bucket: str,
133+
key: str,
134+
data: bytes,
135+
content_type: str | None = None,
136+
) -> str:
137+
blob_client = self._client.get_blob_client(container=bucket, blob=key)
138+
content_settings = (
139+
ContentSettings(content_type=content_type) if content_type else None
140+
)
141+
await blob_client.upload_blob(
142+
data, overwrite=True, content_settings=content_settings
143+
)
144+
return blob_client.url
145+
146+
async def download(self, bucket: str, key: str) -> bytes:
147+
blob_client = self._client.get_blob_client(container=bucket, blob=key)
148+
try:
149+
stream = await blob_client.download_blob()
150+
except ResourceNotFoundError as exc:
151+
raise FileNotFoundError(f"{bucket}/{key}") from exc
152+
return await stream.readall()
153+
154+
async def delete(self, bucket: str, key: str) -> None:
155+
blob_client = self._client.get_blob_client(container=bucket, blob=key)
156+
try:
157+
await blob_client.delete_blob()
158+
except ResourceNotFoundError:
159+
# Idempotent: deleting a missing blob is a no-op.
160+
return
161+
162+
async def get_signed_url(
163+
self, bucket: str, key: str, expires_in: int = 3600
164+
) -> str:
165+
start = datetime.now(UTC)
166+
expiry = start + timedelta(seconds=expires_in)
167+
permission = BlobSasPermissions(read=True)
168+
169+
if self._account_key:
170+
sas_token = generate_blob_sas(
171+
account_name=self._account_name,
172+
container_name=bucket,
173+
blob_name=key,
174+
account_key=self._account_key,
175+
permission=permission,
176+
expiry=expiry,
177+
start=start,
178+
)
179+
else:
180+
user_delegation_key = await self._client.get_user_delegation_key(
181+
key_start_time=start, key_expiry_time=expiry
182+
)
183+
sas_token = generate_blob_sas(
184+
account_name=self._account_name,
185+
container_name=bucket,
186+
blob_name=key,
187+
user_delegation_key=user_delegation_key,
188+
permission=permission,
189+
expiry=expiry,
190+
start=start,
191+
)
192+
193+
blob_client = self._client.get_blob_client(container=bucket, blob=key)
194+
return f"{blob_client.url}?{sas_token}"
195+
196+
async def aclose(self) -> None:
197+
"""Close the underlying client and its transport."""
198+
await self._client.close()
199+
200+
async def __aenter__(self) -> "AzureBlobStorageProvider":
201+
return self
202+
203+
async def __aexit__(self, *_exc: object) -> None:
204+
await self.aclose()

apps/api/src/lib/storage/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ async def get_signed_url(
2525
self, bucket: str, key: str, expires_in: int = 3600
2626
) -> str:
2727
pass
28+
29+
async def aclose(self) -> None:
30+
"""Release underlying resources. Default no-op; override if needed."""
31+
return
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Storage provider factory.
2+
3+
Resolves the concrete ``StorageProvider`` from ``settings.STORAGE_BACKEND``.
4+
The provider is cached as a singleton so the underlying SDK client (and its
5+
connection pool) is shared across requests. Backend-specific adapters are
6+
imported lazily so optional extras (e.g. ``storage-azure``) are only required
7+
when that backend is actually selected.
8+
"""
9+
10+
from functools import lru_cache
11+
12+
from src.lib.config import settings
13+
from src.lib.storage.base import StorageProvider
14+
15+
16+
@lru_cache
17+
def get_storage_provider() -> StorageProvider:
18+
"""Return the configured storage provider (cached singleton).
19+
20+
Raises:
21+
NotImplementedError: When the configured backend has no adapter yet.
22+
"""
23+
backend = settings.STORAGE_BACKEND
24+
25+
if backend == "azure":
26+
from src.lib.storage.azure import AzureBlobStorageProvider
27+
28+
return AzureBlobStorageProvider.from_settings(settings)
29+
30+
raise NotImplementedError(f"Storage backend {backend!r} is not implemented yet")
31+
32+
33+
async def aclose_storage_provider() -> None:
34+
"""Close the cached provider (if any) and reset the cache.
35+
36+
Safe to call when no provider has been created; it is then a no-op.
37+
Intended for the application shutdown hook.
38+
"""
39+
if get_storage_provider.cache_info().currsize == 0:
40+
return
41+
provider = get_storage_provider()
42+
await provider.aclose()
43+
get_storage_provider.cache_clear()

apps/api/src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from src.lib.config import settings
1616
from src.lib.database import async_session_factory
1717
from src.lib.logging import configure_logging, get_logger
18+
from src.lib.storage import aclose_storage_provider
1819
from src.lib.telemetry import configure_telemetry, instrument_app
1920

2021
# Configure logging first
@@ -32,6 +33,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
3233
yield
3334
# Shutdown
3435
logger.info("Shutting down application")
36+
await aclose_storage_provider()
3537

3638

3739
app = FastAPI(

0 commit comments

Comments
 (0)