Clean up
This commit is contained in:
parent
3930982bd8
commit
ad7968dadd
53 changed files with 564 additions and 1013 deletions
|
|
@ -11,13 +11,6 @@ dependencies = [
|
|||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"""APIRouter with all Life Towers endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -12,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||
from .auth import get_current_user
|
||||
from .db import db_connection
|
||||
from .limits import limiter
|
||||
from .logging import token_log_id
|
||||
from .models import (
|
||||
BlockOut,
|
||||
DataIn,
|
||||
|
|
@ -53,7 +52,7 @@ async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
|
|||
(now, token),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("user_registered", user_id=token, new=existing is None)
|
||||
logger.info("user_registered", user_id=token_log_id(token), new=existing is None)
|
||||
return RegisterResponse(user_id=token)
|
||||
|
||||
|
||||
|
|
@ -239,8 +238,14 @@ async def put_data(
|
|||
)
|
||||
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Submitted IDs conflict with existing data",
|
||||
) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))
|
||||
logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages))
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ def get_current_user(request: Request) -> str:
|
|||
u = uuid.UUID(token)
|
||||
if u.version != 4:
|
||||
raise ValueError("Not v4")
|
||||
token = str(u)
|
||||
except (ValueError, AttributeError):
|
||||
raise _unauthorized()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import time
|
||||
import uuid as _uuid_mod
|
||||
from hashlib import sha256
|
||||
|
||||
import structlog
|
||||
from fastapi import Request, Response
|
||||
|
|
@ -26,8 +27,12 @@ def configure_logging() -> None:
|
|||
)
|
||||
|
||||
|
||||
def token_log_id(token: str) -> str:
|
||||
return sha256(token.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
async def request_logging_middleware(request: Request, call_next) -> Response:
|
||||
"""Log each request with method, path, status, duration_ms, user_id, request_id."""
|
||||
"""Log each request without writing bearer credentials to the log stream."""
|
||||
request_id = str(_uuid_mod.uuid4())
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(request_id=request_id)
|
||||
|
|
@ -38,7 +43,7 @@ async def request_logging_middleware(request: Request, call_next) -> Response:
|
|||
if auth:
|
||||
parts = auth.split()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
user_id = parts[1]
|
||||
user_id = token_log_id(parts[1])
|
||||
|
||||
start = time.monotonic()
|
||||
response = await call_next(request)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
"""ASGI app, lifespan, static files mount, route registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from html import escape
|
||||
|
|
@ -37,7 +34,6 @@ STATUS_CODE_MAP: dict[int, str] = {
|
|||
413: "payload_too_large",
|
||||
422: "bad_request",
|
||||
429: "rate_limited",
|
||||
507: "quota_exceeded",
|
||||
500: "server_error",
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +90,12 @@ def create_app() -> FastAPI:
|
|||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
fields = sorted(
|
||||
{".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()}
|
||||
field
|
||||
for field in {
|
||||
".".join(str(loc) for loc in e.get("loc", ()) if loc != "body")
|
||||
for e in exc.errors()
|
||||
}
|
||||
if field
|
||||
)
|
||||
if fields:
|
||||
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
|||
import uuid as _uuid_mod
|
||||
|
||||
|
||||
def _is_uuidv4(value: str) -> bool:
|
||||
def _canonical_uuidv4(value: str) -> str:
|
||||
try:
|
||||
u = _uuid_mod.UUID(value)
|
||||
return u.version == 4
|
||||
if u.version == 4:
|
||||
return str(u)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
pass
|
||||
raise ValueError("must be a UUIDv4")
|
||||
|
||||
|
||||
class HslColor(BaseModel):
|
||||
|
|
@ -33,9 +35,7 @@ class BlockIn(BaseModel):
|
|||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class BlockOut(BaseModel):
|
||||
|
|
@ -56,9 +56,7 @@ class TowerIn(BaseModel):
|
|||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class TowerOut(BaseModel):
|
||||
|
|
@ -80,9 +78,7 @@ class PageIn(BaseModel):
|
|||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class PageOut(BaseModel):
|
||||
|
|
@ -139,9 +135,7 @@ class RegisterRequest(BaseModel):
|
|||
@field_validator("token")
|
||||
@classmethod
|
||||
def validate_token(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError("token must be a UUIDv4")
|
||||
return v
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -166,6 +166,21 @@ async def test_register_non_uuidv4_token(client: AsyncClient) -> None:
|
|||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uuid_inputs_are_canonicalized(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
upper_token = token.upper()
|
||||
register_resp = await client.post("/api/v1/register", json={"token": upper_token})
|
||||
assert register_resp.status_code == 200
|
||||
assert register_resp.json() == {"user_id": token}
|
||||
|
||||
data_resp = await client.get(
|
||||
"/api/v1/data",
|
||||
headers={"Authorization": f"Bearer {upper_token}"},
|
||||
)
|
||||
assert data_resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth / GET /data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -322,6 +337,34 @@ async def test_put_duplicate_page_id(client: AsyncClient) -> None:
|
|||
|
||||
resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
|
||||
assert resp.json() == {"error": "bad_request", "detail": "Validation failed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_cross_user_id_conflict_returns_409(client: AsyncClient) -> None:
|
||||
first_token = make_uuidv4()
|
||||
second_token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": first_token})
|
||||
await client.post("/api/v1/register", json={"token": second_token})
|
||||
|
||||
tree = _make_tree()
|
||||
first_resp = await client.put(
|
||||
"/api/v1/data",
|
||||
json=tree,
|
||||
headers={"Authorization": f"Bearer {first_token}"},
|
||||
)
|
||||
assert first_resp.status_code == 204
|
||||
|
||||
second_resp = await client.put(
|
||||
"/api/v1/data",
|
||||
json=tree,
|
||||
headers={"Authorization": f"Bearer {second_token}"},
|
||||
)
|
||||
assert second_resp.status_code == 409
|
||||
assert second_resp.json() == {
|
||||
"error": "conflict",
|
||||
"detail": "Submitted IDs conflict with existing data",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
11
backend/uv.lock
generated
11
backend/uv.lock
generated
|
|
@ -201,13 +201,6 @@ dependencies = [
|
|||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
|
|
@ -218,15 +211,11 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.111.0" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "slowapi", specifier = ">=0.1.9" },
|
||||
{ name = "structlog", specifier = ">=24.1.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue