generated from coulomb/repo-seed
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:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user