Skip to content

Commit b430347

Browse files
📝 Add docstrings to paform
Docstrings generation was requested by @shayancoin. * #7 (comment) The following files were modified: * `backend/alembic/versions/cfe1d8e4e001_add_sync_events.py` * `backend/api/db.py` * `backend/api/main.py` * `backend/api/routes_sync.py` * `backend/api/security.py` * `backend/services/hygraph_service.py` * `backend/tests/conftest.py` * `backend/tests/test_error_envelopes.py` * `backend/tests/test_manifest_coverage_backend.py` * `backend/tests/test_sync_routes_metrics.py` * `scripts/gen_glb_manifest.py` * `scripts/generate_reference_glbs.py` * `scripts/glb_validate.py` * `scripts/update_glb_metadata.py` * `tests/perf/k6-quote-cnc.js`
1 parent cba636e commit b430347

15 files changed

+562
-39
lines changed

backend/alembic/versions/cfe1d8e4e001_add_sync_events.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@
99

1010

1111
def upgrade() -> None:
12+
"""
13+
Create the `sync_events` table to record metadata about incoming synchronization events.
14+
15+
The table schema:
16+
- id: integer primary key, autoincrement.
17+
- event_id: varchar(255), nullable.
18+
- source: varchar(100), not nullable.
19+
- body_sha256: varchar(64), not nullable.
20+
- received_at: timestamp with timezone, not nullable, defaults to CURRENT_TIMESTAMP.
21+
22+
Unique constraints:
23+
- uq_sync_events_src_event on (source, event_id)
24+
- uq_sync_events_src_body on (source, body_sha256)
25+
- uq_sync_events_body on (body_sha256)
26+
"""
1227
op.create_table(
1328
"sync_events",
1429
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
@@ -28,4 +43,9 @@ def upgrade() -> None:
2843

2944

3045
def downgrade() -> None:
46+
"""
47+
Drop the "sync_events" table from the database.
48+
49+
This operation permanently removes the table and all its data.
50+
"""
3151
op.drop_table("sync_events")

backend/api/db.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@ def _normalize_postgres_url(url: str) -> str:
2828

2929

3030
def get_engine_url() -> str:
31-
"""Resolve the database URL from env or settings with sane defaults."""
31+
"""
32+
Resolve the database URL, preferring the DATABASE_URL environment variable over the configured settings.
33+
34+
If the URL scheme is "sqlite" it is returned unchanged; otherwise PostgreSQL URLs are normalized to use the psycopg driver.
35+
36+
Returns:
37+
engine_url (str): The resolved, normalized database URL.
38+
"""
3239
settings = Settings()
3340
url = os.getenv("DATABASE_URL", settings.database_url)
3441
if url.startswith("sqlite"):
@@ -51,7 +58,12 @@ def get_engine_url() -> str:
5158

5259

5360
def get_db() -> Generator:
54-
"""FastAPI dependency to provide a session per request."""
61+
"""
62+
FastAPI dependency that yields a per-request SQLAlchemy session.
63+
64+
Yields:
65+
db (Session): A SQLAlchemy Session instance for the request. The session is closed when the generator completes.
66+
"""
5567
db = SessionLocal()
5668
try:
5769
yield db

backend/api/main.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
if _Settings().database_url.startswith("sqlite"):
4141
@app.on_event("startup")
4242
async def _ensure_sqlite_tables() -> None:
43+
"""
44+
Create all database tables declared on the ORM Base using the configured SQLite engine.
45+
46+
This ensures any tables defined on `Base` exist in the SQLite database by invoking the ORM metadata creation routine.
47+
"""
4348
Base.metadata.create_all(bind=engine)
4449

4550
# Add CORS middleware
@@ -63,6 +68,24 @@ async def _ensure_sqlite_tables() -> None:
6368
@app.exception_handler(RequestValidationError)
6469
async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
6570
# Unified error envelope for malformed JSON / validation errors
71+
"""
72+
Handle request validation errors and return a unified JSON error envelope.
73+
74+
Parameters:
75+
request (Request): The incoming HTTP request that failed validation.
76+
exc (RequestValidationError): The validation error containing detailed error items.
77+
78+
Returns:
79+
JSONResponse: HTTP 422 response with body:
80+
{
81+
"ok": False,
82+
"error": {
83+
"code": "BAD_REQUEST",
84+
"message": "invalid request",
85+
"details": [<validation error dicts>]
86+
}
87+
}
88+
"""
6689
return JSONResponse(
6790
status_code=422,
6891
content={
@@ -78,12 +101,11 @@ async def handle_validation_error(request: Request, exc: RequestValidationError)
78101

79102
@app.get("/")
80103
async def root() -> Dict[str, str]:
81-
"""Root endpoint of the API.
82-
83-
Returns
84-
-------
85-
Dict[str, str]
86-
A welcome message for the API.
104+
"""
105+
Provide the root endpoint response containing a welcome message.
106+
107+
Returns:
108+
dict: A mapping with a single key "message" whose value is the welcome string.
87109
"""
88110
return {"message": "Welcome to the MVP API"}
89111

@@ -104,4 +126,10 @@ async def healthcheck() -> Dict[str, str]:
104126
@app.get("/metrics")
105127
async def metrics() -> Response:
106128
# Expose Prometheus metrics including default process/python collectors
129+
"""
130+
Serve Prometheus metrics including default process and Python collectors.
131+
132+
Returns:
133+
Response: An HTTP response whose body is the latest Prometheus metrics (bytes) and whose Content-Type is the Prometheus exposition format.
134+
"""
107135
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)

backend/api/routes_sync.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@
3434

3535

3636
def _error_envelope(code: str, message: str, details: Optional[dict] = None) -> Dict[str, Any]:
37+
"""
38+
Constructs a standardized error response payload for API responses.
39+
40+
Parameters:
41+
code (str): Machine-readable error code.
42+
message (str): Human-readable error message.
43+
details (dict, optional): Additional contextual information; defaults to an empty dict.
44+
45+
Returns:
46+
Dict[str, Any]: A dictionary with `"ok": False` and an `"error"` object containing `code`, `message`, and `details`.
47+
"""
3748
return {"ok": False, "error": {"code": code, "message": message, "details": details or {}}}
3849

3950

@@ -48,12 +59,15 @@ async def hygraph_webhook(
4859
db: Session = Depends(get_db),
4960
) -> Dict[str, Any]:
5061
"""
51-
Webhook receiver:
52-
- HMAC validated (dependency)
53-
- Single size guard (2MB) already enforced by dependency; body/raw set on request.state
54-
- DB dedup via SyncEvent(event_id, body_sha256 unique)
55-
- 202 fast-ack with background processing (pull_all)
56-
- Structured JSON log line and Prometheus counters
62+
Handle Hygraph webhook requests, deduplicate events, acknowledge quickly, and enqueue a background full pull.
63+
64+
Validates HMAC and request size via dependencies, records a SyncEvent to prevent duplicate processing, and returns a fast acknowledgement. If the incoming request body is not valid JSON, raises a 400 with a structured BAD_REQUEST envelope. On new events, schedules a background task that invokes HygraphService.pull_all and updates Prometheus metrics and logs. Duplicate events return immediately with a dedup response.
65+
66+
Returns:
67+
JSONResponse: 202 response with {"ok": True, "accepted": True} when the event is accepted and background processing is scheduled, or 200 with {"ok": True, "dedup": True} when the event was already processed.
68+
69+
Raises:
70+
HTTPException: 400 with a BAD_REQUEST error envelope if the request body contains invalid JSON.
5771
"""
5872
start = time.perf_counter()
5973
raw = getattr(request.state, "raw_body", b"")
@@ -88,6 +102,15 @@ async def hygraph_webhook(
88102
raise HTTPException(status_code=400, detail=_error_envelope("BAD_REQUEST", "Invalid JSON payload"))
89103

90104
async def _process(event_id_local: Optional[str], body_sha_local: str) -> None:
105+
"""
106+
Execute a full Hygraph pull, update Prometheus metrics for the results, and log the outcome.
107+
108+
Calls the service to pull all Hygraph data, increments per-type upsert counters and a success counter on success, or increments a failure counter and logs the exception on error.
109+
110+
Parameters:
111+
event_id_local (Optional[str]): Optional Hygraph delivery event ID to include in log context.
112+
body_sha_local (str): SHA-256 hex of the request body to include in log/processing context.
113+
"""
91114
t0 = time.perf_counter()
92115
try:
93116
counts = await HygraphService.pull_all(db)
@@ -126,10 +149,19 @@ async def hygraph_pull(
126149
db: Session = Depends(get_db),
127150
) -> Dict[str, Any]:
128151
"""
129-
Admin pull:
130-
- Auth via Bearer token (constant-time compare)
131-
- Accepts "type" or "sync_type" + optional "page_size"
132-
- Validates positive page_size and caps inside service (≤200)
152+
Trigger an administrative Hygraph sync for a specified resource type and return the resulting counts.
153+
154+
Parameters:
155+
body (Dict[str, Any]): Request payload containing either "type" or "sync_type" (one of "materials", "modules", "systems", or "all")
156+
and an optional "page_size" (positive integer) to request a specific page size.
157+
db (Session): Database session (provided by dependency injection).
158+
159+
Returns:
160+
Dict[str, Any]: A dictionary with "ok": True and "data": counts returned by the HygraphService call.
161+
162+
Raises:
163+
HTTPException: 400 if "page_size" is not a positive integer or if an unsupported type is provided.
164+
HTTPException: 500 if an internal sync error occurs (includes the attempted type in the error details).
133165
"""
134166
sync_type = str((body.get("type") or body.get("sync_type") or "")).lower().strip()
135167
page_size_raw = body.get("page_size")

backend/api/security.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,35 @@
1818

1919
def get_settings() -> Settings:
2020
# Instantiate settings outside of Pydantic request validation to avoid DI issues.
21+
"""
22+
Create and return a new Settings instance.
23+
24+
Returns:
25+
Settings: A fresh Settings object initialized from environment variables and defaults.
26+
"""
2127
return Settings()
2228

2329

2430
async def require_write_token(
2531
authorization: str | None = Security(api_key_header),
2632
settings: Settings = Depends(get_settings),
2733
) -> bool:
34+
"""
35+
Authenticate requests using an admin write token provided via the Bearer Authorization header.
36+
37+
Validates that the Authorization header contains a Bearer token and that the token matches the configured write token (Settings.api_write_token or the API_WRITE_TOKEN environment variable).
38+
39+
Parameters:
40+
authorization (str | None): The raw Authorization header value (expected to start with "Bearer ").
41+
settings (Settings): Configuration used to obtain the expected write token.
42+
43+
Returns:
44+
bool: `True` if the provided Bearer token matches the configured write token.
45+
46+
Raises:
47+
HTTPException: With status 401 if the Authorization header is missing or not a Bearer token.
48+
HTTPException: With status 403 if the token does not match the configured write token.
49+
"""
2850
if not authorization or not authorization.lower().startswith("bearer "):
2951
raise HTTPException(
3052
status_code=HTTP_401_UNAUTHORIZED, detail="Invalid or missing Authorization header."
@@ -41,11 +63,16 @@ async def require_write_token(
4163

4264

4365
def _parse_kv_signature(header: str) -> dict[str, str]:
44-
"""Parse a Hygraph signature header.
45-
46-
Supports:
47-
- "sha256=<hex>" (Hygraph native)
48-
- "sign=<hex>, t=<epoch_ms>" (extended with timestamp for replay window)
66+
"""
67+
Parse a Hygraph signature header into key/value components.
68+
69+
Accepts either the simple `sha256=<hex>` form or comma-separated `key=value` pairs such as `sign=<hex>, t=<epoch_ms>`. An empty or falsy `header` yields an empty dict.
70+
71+
Parameters:
72+
header (str): Raw value of the Hygraph signature header.
73+
74+
Returns:
75+
dict[str, str]: Mapping of parsed component names to their string values (for example `{'sha256': '...'}` or `{'sign': '...', 't': '...'}`).
4976
"""
5077
header = (header or "").strip()
5178
out: dict[str, str] = {}
@@ -64,6 +91,20 @@ def _parse_kv_signature(header: str) -> dict[str, str]:
6491
def verify_hygraph_signature(
6592
body: bytes, signature_header: str, secret: str, max_skew_ms: int = 5 * 60 * 1000
6693
) -> bool:
94+
"""
95+
Validate a Hygraph webhook payload using the provided shared secret.
96+
97+
Checks the signature_header for either a simple "sha256=<hex>" token or an extended "sign=<hex>,t=<ms>" pair. For the simple form, the function compares the provided hex to the HMAC-SHA256 of the raw body using the secret. For the extended form, the function enforces that the timestamp `t` is within max_skew_ms of the current time and compares the provided `sign` to the HMAC-SHA256 of the body concatenated with the timestamp. Any missing secret, malformed header, clock skew violation, mismatch, or error results in a False result.
98+
99+
Parameters:
100+
body (bytes): Raw request body used to compute the HMAC.
101+
signature_header (str): Hygraph signature header value; expected formats: "sha256=<hex>" or "sign=<hex>,t=<ms>".
102+
secret (str): Shared secret used to compute HMAC-SHA256.
103+
max_skew_ms (int): Maximum allowed clock skew in milliseconds for timestamped signatures (default: 5 minutes).
104+
105+
Returns:
106+
bool: `true` if the signature is valid and within allowed skew, `false` otherwise.
107+
"""
67108
if not secret:
68109
return False
69110
try:
@@ -92,6 +133,21 @@ async def validate_hygraph_request(
92133
request: Request,
93134
settings: Settings = Depends(get_settings),
94135
) -> bool:
136+
"""
137+
Validate a Hygraph webhook request, enforce size limits, verify its signature, and attach the raw payload and its SHA-256 to request.state.
138+
139+
Reads the signature from the `x-hygraph-signature` header, enforces a maximum body size (from settings.max_webhook_body_bytes or the MAX_WEBHOOK_BODY_BYTES environment variable; defaults to 2 MB), and validates the body against the configured Hygraph webhook secret. On successful validation this function sets `request.state.raw_body` to the raw bytes and `request.state.body_sha256` to the hex SHA-256 digest.
140+
141+
Parameters:
142+
request (Request): Incoming FastAPI request; its body is consumed and on success `request.state.raw_body` and `request.state.body_sha256` are populated.
143+
settings (Settings): Configuration providing `max_webhook_body_bytes` and `hygraph_webhook_secret` (if absent, corresponding environment variables are used).
144+
145+
Returns:
146+
bool: `True` if the request body was accepted and the signature validated.
147+
148+
Raises:
149+
HTTPException: 413 if the payload exceeds the configured limit; 401 if the signature is missing or invalid.
150+
"""
95151
signature = request.headers.get(HYGRAPH_SIGNATURE_HEADER)
96152
body = await request.body()
97153
limit = getattr(settings, "max_webhook_body_bytes", None) or int(os.getenv("MAX_WEBHOOK_BODY_BYTES", str(2 * 1024 * 1024)))

0 commit comments

Comments
 (0)