fix(api): normalize trailing slashes — no slash on param routes

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 15:13:01 +02:00
parent cadeb4a3b5
commit 768a8ba9c7
13 changed files with 74 additions and 21 deletions

View File

@@ -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),

View File

@@ -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),

View File

@@ -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()

View File

@@ -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),

View File

@@ -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,