From 768a8ba9c7962deb050849daf6894360a7396fcf Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 26 Apr 2026 15:13:01 +0200 Subject: [PATCH] =?UTF-8?q?fix(api):=20normalize=20trailing=20slashes=20?= =?UTF-8?q?=E2=80=94=20no=20slash=20on=20param=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule: trailing slash only on collection roots (/). Any route containing a path parameter {…} uses no trailing slash. Applies across all routers, scripts, Makefile, and tests. Fixes 307-redirect fragility on POST/PATCH from naive clients (curl, Codex HTTP calls). Also adds POST /repos/{slug}/sync — runs ADR-001 consistency check with --fix via HTTP, so non-MCP agents (Codex) can self-service DB sync without operator intervention. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 2 +- api/routers/contributions.py | 4 +- api/routers/domains.py | 2 +- api/routers/repos.py | 59 +++++++++++++++++++++++++++-- api/routers/sbom.py | 4 +- api/routers/technical_debt.py | 4 +- scripts/capture_sbom_tools.py | 2 +- scripts/check_doi.py | 2 +- scripts/consistency_check.py | 4 +- scripts/ingest_sbom.py | 2 +- scripts/install_hooks.sh | 2 +- scripts/register_project.sh | 4 +- tests/test_routers_td_ep_contrib.py | 4 +- 13 files changed, 74 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index 2950c1f..bf0ccd6 100644 --- a/Makefile +++ b/Makefile @@ -127,7 +127,7 @@ rename-domain: register-path: @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO= PATH="; exit 1) @test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO= PATH="; exit 1) - curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths/" \ + curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \ -H "Content-Type: application/json" \ -d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool diff --git a/api/routers/contributions.py b/api/routers/contributions.py index 1fb530d..3616abe 100644 --- a/api/routers/contributions.py +++ b/api/routers/contributions.py @@ -78,7 +78,7 @@ async def create_contribution( return contrib -@router.get("/{contribution_id}/", response_model=ContributionRead) +@router.get("/{contribution_id}", response_model=ContributionRead) async def get_contribution( contribution_id: uuid.UUID, session: AsyncSession = Depends(get_session), @@ -118,7 +118,7 @@ async def patch_contribution_status( return contrib -@router.delete("/{contribution_id}/", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT) async def withdraw_contribution( contribution_id: uuid.UUID, session: AsyncSession = Depends(get_session), diff --git a/api/routers/domains.py b/api/routers/domains.py index d7d376b..2b1ed94 100644 --- a/api/routers/domains.py +++ b/api/routers/domains.py @@ -45,7 +45,7 @@ async def create_domain( return domain -@router.get("/{slug}/", response_model=DomainDetail) +@router.get("/{slug}", response_model=DomainDetail) async def get_domain( slug: str, session: AsyncSession = Depends(get_session), diff --git a/api/routers/repos.py b/api/routers/repos.py index b6f5db0..e33b2e6 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -1,11 +1,17 @@ import asyncio +import json +import socket +import subprocess +import sys import uuid from datetime import datetime, timezone +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession +from api.config import settings from api.database import get_session from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate from api.models.doi_cache import DOICache @@ -337,7 +343,7 @@ async def get_repo_by_id( return repo -@router.get("/{slug}/", response_model=RepoRead) +@router.get("/{slug}", response_model=RepoRead) async def get_repo( slug: str, session: AsyncSession = Depends(get_session), @@ -345,7 +351,7 @@ async def get_repo( return await _get_repo_by_slug(slug, session) -@router.patch("/{slug}/", response_model=RepoRead) +@router.patch("/{slug}", response_model=RepoRead) async def update_repo( slug: str, body: RepoUpdate, @@ -359,7 +365,7 @@ async def update_repo( return repo -@router.post("/{slug}/paths/", response_model=RepoRead) +@router.post("/{slug}/paths", response_model=RepoRead) async def register_host_path( slug: str, body: RepoPathRegister, @@ -471,6 +477,53 @@ async def get_repo_dispatch( ) +@router.post("/{slug}/sync") +async def sync_repo_consistency( + slug: str, + fix: bool = True, + session: AsyncSession = Depends(get_session), +) -> dict: + """Run ADR-001 consistency check (and optional --fix) for a repo via HTTP. + + Intended for non-Claude-Code agents (e.g. Codex) that cannot use MCP tools + but need to sync workplan file state to the state-hub DB after making changes. + + Returns the raw JSON output from consistency_check.py. + Query param ?fix=false to run check-only without writing. + """ + repo = await _get_repo_by_slug(slug, session) + + hostname = socket.gethostname() + host_paths = repo.host_paths or {} + repo_path = host_paths.get(hostname) + if not repo_path or not Path(repo_path).exists(): + raise HTTPException( + status_code=503, + detail=( + f"No accessible path for repo '{slug}' on host '{hostname}'. " + f"Register with: POST /repos/{slug}/paths/" + ), + ) + + script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py" + cmd = [sys.executable, str(script), "--repo", slug, "--json", + "--api-base", settings.api_base] + if fix: + cmd.append("--fix") + + result = await asyncio.to_thread( + subprocess.run, cmd, capture_output=True, text=True + ) + + try: + return json.loads(result.stdout) + except Exception: + raise HTTPException( + status_code=500, + detail=f"Consistency check failed: {result.stderr or result.stdout or '(no output)'}", + ) + + async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) repo = result.scalar_one_or_none() diff --git a/api/routers/sbom.py b/api/routers/sbom.py index 79e4a64..ee9bf1e 100644 --- a/api/routers/sbom.py +++ b/api/routers/sbom.py @@ -112,7 +112,7 @@ async def list_snapshots( return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()] -@router.get("/snapshots/{snapshot_id}/", response_model=SBOMSnapshotDetail) +@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail) async def get_snapshot( snapshot_id: uuid.UUID, session: AsyncSession = Depends(get_session), @@ -209,7 +209,7 @@ async def licence_report( return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count) -@router.get("/{repo_slug}/", response_model=SBOMRepoView) +@router.get("/{repo_slug}", response_model=SBOMRepoView) async def get_repo_sbom( repo_slug: str, session: AsyncSession = Depends(get_session), diff --git a/api/routers/technical_debt.py b/api/routers/technical_debt.py index 902d68c..b8f9e77 100644 --- a/api/routers/technical_debt.py +++ b/api/routers/technical_debt.py @@ -110,7 +110,7 @@ async def defer_td( # ── Notes ───────────────────────────────────────────────────────────────────── -@router.get("/{td_id}/notes/", response_model=list[TDNoteRead]) +@router.get("/{td_id}/notes", response_model=list[TDNoteRead]) async def list_notes( td_id: uuid.UUID, session: AsyncSession = Depends(get_session), @@ -124,7 +124,7 @@ async def list_notes( return list(result.scalars().all()) -@router.post("/{td_id}/notes/", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED) +@router.post("/{td_id}/notes", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED) async def add_note( td_id: uuid.UUID, body: TDNoteCreate, diff --git a/scripts/capture_sbom_tools.py b/scripts/capture_sbom_tools.py index a42ed6f..ad9805f 100644 --- a/scripts/capture_sbom_tools.py +++ b/scripts/capture_sbom_tools.py @@ -37,7 +37,7 @@ PROMPT_FILE = SCRIPT_DIR.parent / "prompts" / "sbom-capture-agent.md" def resolve_repo_path(repo_slug: str) -> Path | None: """Look up the registered path for a repo slug via the state-hub API.""" - url = f"{API_BASE}/repos/{repo_slug}/" + url = f"{API_BASE}/repos/{repo_slug}" try: with urllib.request.urlopen(url, timeout=10) as resp: data = json.loads(resp.read()) diff --git a/scripts/check_doi.py b/scripts/check_doi.py index 0113377..7d0e14e 100644 --- a/scripts/check_doi.py +++ b/scripts/check_doi.py @@ -69,7 +69,7 @@ def _print_report(report, use_color: bool = True) -> None: async def check_repo(slug: str, as_json: bool) -> bool: try: - repo = _get(f"/repos/{slug}/") + repo = _get(f"/repos/{slug}") except Exception as e: print(f"✗ Could not fetch repo '{slug}': {e}", file=sys.stderr) return False diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 4ea45e8..58dcfe4 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -1083,7 +1083,7 @@ def fix_repo( hostname = socket.gethostname() if (repo_record.get("host_paths") or {}).get(hostname) != repo_path: result = _api_post( - api_base, f"/repos/{repo_slug}/paths/", + api_base, f"/repos/{repo_slug}/paths", {"host": hostname, "path": repo_path}, ) if result and "_error" not in result: @@ -1333,7 +1333,7 @@ def fix_repo( from datetime import timezone as _tz import datetime as _dt now_iso = _dt.datetime.now(_tz.utc).isoformat() - _api_patch(api_base, f"/repos/{repo_slug}/", {"last_state_synced_at": now_iso}) + _api_patch(api_base, f"/repos/{repo_slug}", {"last_state_synced_at": now_iso}) # Write the worker orientation brief (.custodian-brief.md) if repo_path: diff --git a/scripts/ingest_sbom.py b/scripts/ingest_sbom.py index 4e8851a..e3a596b 100644 --- a/scripts/ingest_sbom.py +++ b/scripts/ingest_sbom.py @@ -509,7 +509,7 @@ def post_ingest(api_base: str, repo_slug: str, entries: list[dict]) -> dict: def _resolve_repo_path_from_hub(api_base: str, repo_slug: str) -> Path | None: """Query the hub for this host's registered path for repo_slug.""" try: - url = f"{api_base}/repos/{repo_slug}/" + url = f"{api_base}/repos/{repo_slug}" with urllib.request.urlopen(url) as resp: data = json.loads(resp.read()) hostname = socket.gethostname() diff --git a/scripts/install_hooks.sh b/scripts/install_hooks.sh index 4159dbc..881ee72 100755 --- a/scripts/install_hooks.sh +++ b/scripts/install_hooks.sh @@ -45,7 +45,7 @@ resolve_path() { local slug="$1" # Try the registered local_path first local api_path - api_path=$(curl -sf "${API_BASE}/repos/${slug}/" | python3 -c \ + api_path=$(curl -sf "${API_BASE}/repos/${slug}" | python3 -c \ "import json,sys; d=json.load(sys.stdin); print(d.get('local_path') or '')" 2>/dev/null || true) if [[ -n "$api_path" && -d "$api_path" ]]; then echo "$api_path" diff --git a/scripts/register_project.sh b/scripts/register_project.sh index 7b98172..cd163b2 100755 --- a/scripts/register_project.sh +++ b/scripts/register_project.sh @@ -73,7 +73,7 @@ echo " API OK" # ── Step 2: Verify domain exists ─────────────────────────────────────────────── echo "==> Verifying domain '$DOMAIN' ..." -DOMAIN_JSON="$(curl -sf "$API_BASE/domains/$DOMAIN/" 2>/dev/null || echo 'NOT_FOUND')" +DOMAIN_JSON="$(curl -sf "$API_BASE/domains/$DOMAIN" 2>/dev/null || echo 'NOT_FOUND')" if [[ "$DOMAIN_JSON" == "NOT_FOUND" ]] || ! echo "$DOMAIN_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('slug') else 1)" 2>/dev/null; then echo "ERROR: Domain '$DOMAIN' not found in the State Hub." echo " To create: make add-domain DOMAIN=$DOMAIN NAME=\"\"" @@ -252,7 +252,7 @@ fi # ── Step 7: Register this machine's local path ──────────────────────────────── echo "==> Registering host path for $(hostname) ..." -curl -sf -X POST "$API_BASE/repos/$REPO_SLUG/paths/" \ +curl -sf -X POST "$API_BASE/repos/$REPO_SLUG/paths" \ -H "Content-Type: application/json" \ -d "{\"host\": \"$(hostname)\", \"path\": \"$PROJECT_PATH\"}" > /dev/null \ && echo " host_paths[$(hostname)] = $PROJECT_PATH" diff --git a/tests/test_routers_td_ep_contrib.py b/tests/test_routers_td_ep_contrib.py index 023d015..97c1f90 100644 --- a/tests/test_routers_td_ep_contrib.py +++ b/tests/test_routers_td_ep_contrib.py @@ -68,13 +68,13 @@ class TestTechnicalDebt: }) td_id = r.json()["id"] - r2 = await client.post(f"/technical-debt/{td_id}/notes/", json={ + r2 = await client.post(f"/technical-debt/{td_id}/notes", json={ "step": "analysis", "content": "Root cause identified.", }) assert r2.status_code == 201 - r3 = await client.get(f"/technical-debt/{td_id}/notes/") + r3 = await client.get(f"/technical-debt/{td_id}/notes") assert r3.status_code == 200 assert len(r3.json()) == 1