* 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>
176 lines
5.5 KiB
Python
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}
|