Skip to content

Commit b43c621

Browse files
authored
Merge branch 'main' into test/vrt-perf-and-gallery
2 parents 12c25a8 + 73c1d3d commit b43c621

30 files changed

+9174
-16177
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ artifacts/
4242
.env
4343
.env.*
4444
!.env.example
45+
secrets/*.env
46+
!secrets/*.env.example
4547

4648
# Docker
4749
**/.dockerignore

backend/api/deps.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Shared lightweight dependency helpers."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from typing import Optional, Protocol
7+
8+
from fastapi import Depends
9+
10+
from api.config import Settings, get_settings
11+
12+
try:
13+
from redis import asyncio as redis_asyncio
14+
except Exception: # pragma: no cover - optional dependency
15+
redis_asyncio = None # type: ignore[assignment]
16+
17+
18+
class RedisLike(Protocol):
19+
async def get(self, key: str) -> Optional[str]:
20+
"""
21+
Retrieve a value by key from the in-memory store, respecting expiry.
22+
23+
Returns:
24+
The stored string value for `key`, or `None` if the key is missing or has expired.
25+
"""
26+
...
27+
28+
async def set(self, key: str, value: str, ex: Optional[int] = None) -> None:
29+
"""
30+
Store a string value under the given key, optionally expiring after a number of seconds.
31+
32+
Parameters:
33+
key (str): Key under which to store the value.
34+
value (str): String value to store.
35+
ex (Optional[int]): Expiration time in seconds; if provided, the key will be removed after this many seconds. Existing values for the key are overwritten.
36+
"""
37+
...
38+
39+
40+
class _InMemoryRedis:
41+
"""Minimal in-memory Redis replacement used when redis-py asyncio is unavailable."""
42+
43+
def __init__(self) -> None:
44+
"""
45+
Initialize internal storage for the in-memory Redis replacement.
46+
47+
Creates the `_store` dictionary that maps keys (str) to tuples of `(value, expires_at)`,
48+
where `value` is the stored string and `expires_at` is a Unix timestamp (float) when the
49+
entry expires or `None` if it does not expire.
50+
"""
51+
self._store: dict[str, tuple[str, Optional[float]]] = {}
52+
53+
async def get(self, key: str) -> Optional[str]:
54+
"""
55+
Retrieve the string value stored for the given key if it exists and has not expired.
56+
57+
If the key is missing or its expiry time has passed, the key is removed from the in-memory store and `None` is returned.
58+
59+
Returns:
60+
The stored `str` value for `key`, or `None` if the key does not exist or is expired.
61+
"""
62+
payload = self._store.get(key)
63+
if payload is None:
64+
return None
65+
value, expires_at = payload
66+
if expires_at is not None and expires_at < time.monotonic():
67+
self._store.pop(key, None)
68+
return None
69+
return value
70+
71+
async def set(self, key: str, value: str, ex: Optional[int] = None) -> None:
72+
"""
73+
Store a string value under a key with an optional TTL in seconds.
74+
75+
Parameters:
76+
key (str): The key to store the value under.
77+
value (str): The string value to store.
78+
ex (Optional[int]): Time-to-live in seconds; if provided, the key expires after this many seconds.
79+
"""
80+
expires_at = time.monotonic() + ex if ex is not None else None
81+
self._store[key] = (value, expires_at)
82+
83+
84+
_redis_client: Optional["redis_asyncio.Redis"] = None # type: ignore[misc]
85+
_fallback_client = _InMemoryRedis()
86+
87+
88+
async def get_redis(settings: Settings = Depends(get_settings)) -> RedisLike:
89+
"""
90+
Provide a shared Redis-like client: use the asyncio Redis client when available, otherwise use the in-memory fallback.
91+
92+
Parameters:
93+
settings (Settings): Application settings used to obtain `redis_url` when lazily initializing the asyncio Redis client.
94+
95+
Returns:
96+
RedisLike: The module-level Redis-like client (an initialized asyncio Redis client if available, otherwise the in-memory fallback).
97+
"""
98+
99+
global _redis_client
100+
101+
if redis_asyncio is None:
102+
return _fallback_client
103+
104+
if _redis_client is None:
105+
_redis_client = redis_asyncio.from_url(
106+
settings.redis_url,
107+
encoding="utf-8",
108+
decode_responses=True,
109+
)
110+
return _redis_client
111+
112+
113+
__all__ = ["get_redis", "RedisLike"]

backend/api/graphql.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Strawberry GraphQL façade exposing pricing queries."""
2+
3+
from __future__ import annotations
4+
5+
import strawberry
6+
from strawberry.fastapi import GraphQLRouter
7+
8+
from api.config import get_settings
9+
from api.deps import get_redis
10+
from backend.api.routes_pricing import PriceRequest, PriceResponse, compute_price_quote
11+
12+
13+
@strawberry.type
14+
class PriceBreakdown:
15+
base: int
16+
finish: int
17+
hardware: int
18+
countertop: int
19+
20+
21+
@strawberry.type
22+
class PriceQuote:
23+
total_cents: int
24+
breakdown: PriceBreakdown
25+
26+
27+
def _to_graphql(response: PriceResponse) -> PriceQuote:
28+
"""
29+
Convert a backend PriceResponse into a GraphQL PriceQuote.
30+
31+
Constructs a PriceQuote with total_cents from the response and a nested PriceBreakdown populated from the response.breakdown keys: "base", "finish", "hardware", and "countertop".
32+
33+
Returns:
34+
PriceQuote: GraphQL PriceQuote with mapped `total_cents` and `breakdown`.
35+
"""
36+
breakdown = response.breakdown
37+
return PriceQuote(
38+
total_cents=response.total_cents,
39+
breakdown=PriceBreakdown(
40+
base=breakdown["base"],
41+
finish=breakdown["finish"],
42+
hardware=breakdown["hardware"],
43+
countertop=breakdown["countertop"],
44+
),
45+
)
46+
47+
48+
@strawberry.type
49+
class Query:
50+
@strawberry.field
51+
async def price_quote(
52+
self,
53+
elevation: str,
54+
finish: str,
55+
hardware: str,
56+
countertop: str,
57+
) -> PriceQuote:
58+
"""
59+
Resolve a price quote for a product configuration specified by the provided attributes.
60+
61+
Builds a price request from the provided elevation, finish, hardware, and countertop identifiers, queries the pricing backend, and returns the result as a GraphQL PriceQuote.
62+
63+
Parameters:
64+
elevation (str): Identifier for the product elevation/profile.
65+
finish (str): Identifier for the surface finish option.
66+
hardware (str): Identifier for the hardware option.
67+
countertop (str): Identifier for the countertop option.
68+
69+
Returns:
70+
PriceQuote: GraphQL representation of the computed price, including `total_cents` and a `breakdown` of cost components.
71+
"""
72+
request = PriceRequest(
73+
elevation=elevation,
74+
finish=finish,
75+
hardware=hardware,
76+
countertop=countertop,
77+
)
78+
redis = await get_redis(settings=get_settings())
79+
response = await compute_price_quote(request, redis)
80+
return _to_graphql(response)
81+
82+
83+
schema = strawberry.Schema(query=Query)
84+
85+
_settings = get_settings()
86+
graphql_app = GraphQLRouter(
87+
schema,
88+
graphiql=_settings.environment != "production",
89+
allow_introspection=_settings.environment != "production",
90+
)
91+
92+
93+
__all__ = ["graphql_app", "schema"]

0 commit comments

Comments
 (0)