agnes-the-ai-analyst/app/api/metrics.py
Vojtech c552bf8243
feat(api): enforce API design rules via pytest + fix DELETE/status-code violations (#338)
* feat(api): enforce API design rules via pytest + fix DELETE/status-code violations

Adds tests/test_api_design_rules.py with four forward-only design guardrails
that prevent new endpoints from accumulating REST debt:

  Rule 1 — No new verbs in URL paths (existing 28 grandfathered via allowlist)
  Rule 2 — DELETE must declare 204 No Content (zero allowlist entries)
  Rule 3 — Creator POSTs (path has GET counterpart) must declare 201/202
  Rule 4 — All protected /api/* routes must declare 401 and 403

Fixes found by running the rules:

- DELETE /api/admin/metrics/{metric_id}: return 204, drop redundant body
- DELETE /api/memory/{item_id}/dismiss (undismiss): return 204, drop body
- POST /api/memory/admin/contradictions: add status_code=201 (creates a resource)
- app/main.py: _add_auth_error_responses() injected into app.openapi() at startup;
  declares 401/403 on all protected /api/* operations centrally, fixing the 120
  routes that previously omitted these response codes from the spec.

Closes #337

* fix(api): resolve CI failures — extend 204 fixes + complete allowlists

- Fix remaining 6 DELETE endpoints to return 204: store entities,
  store entity install, marketplace curated install, marketplace plugin
  system flag, admin store submission, and observability view
- Update all affected tests to expect 204 (removed body assertions)
- Add 4 missing verb paths to _VERB_PATH_ALLOWLIST in test_api_design_rules.py
- Add 2 upsert endpoints to _CREATOR_POST_ALLOWLIST
- Update admin_marketplaces.html to not call r.json() on 204 DELETE

* fix(tests): align 2 DELETE-asserting tests with 204 contract (post-#339 rebase)

CI's test-shard (1) and (4) failures on this PR were caused by
Vojta's second commit (`fix(api): resolve CI failures — extend 204
fixes`) flipping more DELETE endpoints to status_code=204 than just
the two mentioned in the PR body. Two tests assert status_code==200
on the DELETE response and broke:

- tests/test_admin_store_submissions.py::TestQuarantineGates::test_admin_can_delete_quarantined
  (DELETE /api/store/entities/{entity_id})
- tests/test_store_api.py::TestInstallCycle::test_admin_hard_delete_cascades_installs
  (DELETE /api/store/entities/{entity_id}?hard=true)

Updated both to assert 204 with a comment pointing at
tests/test_api_design_rules.py rule 2 so future reviewers can
trace the contract. Verified via broader scan that no other test
asserts == 200 on a .delete() response directly (4 other sites do
.delete() then check 200 on a subsequent GET — those are fine).

* release: 0.54.26 — API design rules (test_api_design_rules.py) + 8 DELETE endpoints flip to 204

---------

Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-18 15:25:07 +02:00

176 lines
5.5 KiB
Python

"""Metrics API endpoints — CRUD for metric definitions stored in DuckDB."""
from typing import List, Optional
import duckdb
import yaml
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from app.auth.access import require_admin
from app.auth.dependencies import get_current_user, _get_db
from src.repositories.metrics import MetricRepository
router = APIRouter(tags=["metrics"])
class MetricCreate(BaseModel):
id: str
name: str
display_name: str
category: str
sql: str
description: Optional[str] = None
type: str = "sum"
unit: Optional[str] = None
grain: str = "monthly"
table_name: Optional[str] = None
tables: Optional[List[str]] = None
expression: Optional[str] = None
time_column: Optional[str] = None
dimensions: Optional[List[str]] = None
filters: Optional[List[str]] = None
synonyms: Optional[List[str]] = None
notes: Optional[List[str]] = None
sql_variants: Optional[dict] = None
validation: Optional[dict] = None
source: str = "manual"
@router.get("/api/metrics")
async def list_metrics(
category: Optional[str] = None,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""List all metric definitions, optionally filtered by category."""
repo = MetricRepository(conn)
metrics = repo.list(category=category)
return {"metrics": metrics, "count": len(metrics)}
@router.get("/api/metrics/{metric_id:path}")
async def get_metric(
metric_id: str,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Get a single metric definition by ID."""
repo = MetricRepository(conn)
metric = repo.get(metric_id)
if metric is None:
raise HTTPException(status_code=404, detail=f"Metric '{metric_id}' not found")
return metric
@router.post("/api/admin/metrics", status_code=201)
async def create_or_update_metric(
body: MetricCreate,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Create or update a metric definition (admin only)."""
repo = MetricRepository(conn)
metric = repo.create(
id=body.id,
name=body.name,
display_name=body.display_name,
category=body.category,
sql=body.sql,
description=body.description,
type=body.type,
unit=body.unit,
grain=body.grain,
table_name=body.table_name,
tables=body.tables,
expression=body.expression,
time_column=body.time_column,
dimensions=body.dimensions,
filters=body.filters,
synonyms=body.synonyms,
notes=body.notes,
sql_variants=body.sql_variants,
validation=body.validation,
source=body.source,
)
return metric
@router.delete("/api/admin/metrics/{metric_id:path}", status_code=204)
async def delete_metric(
metric_id: str,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Delete a metric definition by ID (admin only)."""
repo = MetricRepository(conn)
deleted = repo.delete(metric_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Metric '{metric_id}' not found")
@router.post("/api/admin/metrics/import", status_code=200)
async def import_metrics(
file: UploadFile = File(...),
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Import metrics from uploaded YAML file."""
content = await file.read()
try:
data = yaml.safe_load(content)
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
if not data:
raise HTTPException(status_code=400, detail="Empty YAML file")
metric_list = data if isinstance(data, list) else [data]
repo = MetricRepository(conn)
count = 0
for metric in metric_list:
if not isinstance(metric, dict):
continue
name = metric.get("name")
category = metric.get("category")
if not name or not category:
raise HTTPException(
status_code=400,
detail="Each metric must have 'name' and 'category' fields",
)
metric_id = f"{category}/{name}"
table_name = metric.pop("table", None) or metric.get("table_name")
# Collect sql_by_* variants
sql_variants = {}
for key in list(metric.keys()):
if key.startswith("sql_by_"):
sql_variants[key[4:]] = metric.pop(key)
repo.create(
id=metric_id,
name=name,
display_name=metric.get("display_name", name),
category=category,
description=metric.get("description"),
type=metric.get("type", "sum"),
unit=metric.get("unit"),
grain=metric.get("grain", "monthly"),
table_name=table_name,
tables=metric.get("tables"),
expression=metric.get("expression"),
time_column=metric.get("time_column"),
dimensions=metric.get("dimensions"),
filters=metric.get("filters"),
synonyms=metric.get("synonyms"),
notes=metric.get("notes"),
sql=metric.get("sql", ""),
sql_variants=sql_variants if sql_variants else None,
validation=metric.get("validation"),
source="yaml_import",
)
count += 1
return {"status": "imported", "count": count}