Imported from jeffweilee/ragent (
.claude/skills/onboard-api-endpoint/SKILL.md). Install upstream withnpx skills add jeffweilee/ragent --skill onboard-api-endpoint. Copyright stays with the author.
Onboarding a New API Endpoint
All ragent business endpoints follow the shape /<resource>/v<N>[/<rest>] with
the version segment carried by the router prefix, not individual decorators.
Read docs/00_rule.md §API Endpoint Naming & Versioning and
src/ragent/bootstrap/app.py before touching any router file.
Orientation — how a route is registered end-to-end
[ Router factory — src/ragent/routers/<resource>.py ]
def create_<resource>_router(...) -> APIRouter:
router = APIRouter(prefix="/<resource>/v1") # ← version here, once
@router.post("") # ← collection op
@router.get("/{id}") # ← item op
@router.post("/stream") # ← sub-action
return router
[ Composition root — src/ragent/bootstrap/app.py ]
app.include_router(create_<resource>_router(...)) # ← wired here
[ Auth middleware — bootstrap/app.py::_PUBLIC_PATHS ]
# If the endpoint must be reachable WITHOUT X-User-Id (health, metrics),
# add its exact path to _PUBLIC_PATHS. Business endpoints omit this.
[ Test contract ]
tests/unit/test_api_versioning.py # asserts every non-infra route matches
# r"^/[a-z][a-z0-9-]*/v\d+"
Existing routers to read as living references:
| Router | File | Prefix | Routes |
|---|---|---|---|
| Ingest | src/ragent/routers/ingest.py |
/ingest/v1 |
"", /{document_id} (GET+DELETE), "" (GET list), /{document_id}/rerun |
| Chat | src/ragent/routers/chat.py |
/chat/v1 |
"", /stream |
| Retrieve | src/ragent/routers/retrieve.py |
/retrieve/v1 |
"" |
| Feedback | src/ragent/routers/feedback.py |
/feedback/v1 |
"" |
| MCP | src/ragent/routers/mcp.py |
/mcp/v1 |
"" (JSON-RPC 2.0 dispatch) |
| Embedding lifecycle | src/ragent/routers/admin_embedding.py |
/embedding/v1 |
/promote, /cutover, /rollback, /commit, /abort, /backfill, /state, /cutover/preflight |
| Upload (direct route) | src/ragent/routers/admin_ingest.py |
/ingest/v1/upload |
(no APIRouter prefix — full path in decorator) |
| Health | src/ragent/routers/health.py |
(none) | /livez, /readyz, /startupz, /metrics |
Step 1 — Classify the operation
Decide which of these three situations applies before writing a single line.
| Situation | Action |
|---|---|
New route on an existing resource (e.g. POST /ingest/v1/supersede) |
Add a decorator to the existing router factory; no new file |
Brand-new resource (e.g. GET /documents/v1) |
Create src/ragent/routers/<resource>.py with a new factory function; wire it in app.py |
| Breaking change to existing resource (e.g. different request/response shape) | Create create_<resource>_v2_router() with prefix="/<resource>/v2"; mount both in app.py; do NOT modify the v1 router |
Ask the user which case applies when it is not obvious — do not silently pick.
Step 2 — Name the path correctly
Rules from docs/00_rule.md §API Endpoint Naming & Versioning:
- Resource segment: lowercase, hyphen-separated noun (
document-revisions, notDocumentRevisionsordocument_revisions). - Version token:
v+ positive integer, no suffix (v1, neverv1-beta). - Sub-action segment: lowercase verb or noun that reads as an action on the resource (
/stream,/supersede,/tools/rag). - Collection vs. item:
POST /<resource>/v1— create (returns 202/201)GET /<resource>/v1— list (returns 200 + pagination)GET /<resource>/v1/{id}— fetch one (returns 200 or 404)DELETE /<resource>/v1/{id}— delete (returns 204)POST /<resource>/v1/{id}/<action>— item-scoped action (returns 200/202)
If the endpoint is infrastructure-style (health probe, internal ping), it goes in health.py or a dedicated infra router with no version prefix and must be added to _PUBLIC_PATHS.
Step 3 — Spec and plan before code
Before writing tests, update two documents:
-
docs/spec/endpoints.md(linked fromdocs/00_spec.md§4.1) — add a row to the endpoint table with method, path, auth header, request schema ref, response schema ref, and notes. Add a scenario (S-XX) if there is a behaviour contract worth pinning (happy path, error codes, edge cases). -
docs/00_plan.md— append a new task row under the active track and update the track's**Counter: 完成 N / 未完成 N / descope N**line (mandatory perdocs/00_rule.md §docs/00_plan.md):| T-XX.N | Red | • **Achieve:** Pin <endpoint> contract.<br>• **Deliver:** `tests/unit/test_<resource>_router.py` — <what the test covers>.<br>• **Success criteria:** `pytest tests/unit/test_<resource>_router.py` exits non-zero with all new test IDs collected and failing (red). | [ ] | QA | | T-XX.N | Green | • **Achieve:** Implement <endpoint>.<br>• **Deliver:** `src/ragent/routers/<resource>.py::<handler>`.<br>• **Success criteria:** `pytest tests/unit/test_<resource>_router.py` exits 0; `make test-gate` still green. | [ ] | Dev |
Never write production code before the spec and plan entries exist — the
pre-commit gate's /simplify + /review cycle will flag the gap.
Step 4 — TDD sequence
Per CLAUDE.md, every endpoint ships Red → Green → Refactor.
Red
Write the failing test first. Minimum test surface for any new route:
# tests/unit/test_<resource>_router.py
def _make_client(...) -> TestClient:
app = FastAPI()
app.include_router(create_<resource>_router(...))
return TestClient(app, raise_server_exceptions=False)
def test_<endpoint>_happy_path():
client = _make_client(...)
resp = client.<method>("/<resource>/v1[/<rest>]", json={...})
assert resp.status_code == <expected>
# assert response schema fields
def test_<endpoint>_returns_problem_json_on_error():
...
assert resp.headers["content-type"].startswith("application/problem+json")
assert resp.json()["error_code"] == "<ERROR_CODE>"
Run uv run pytest tests/unit/test_<resource>_router.py -x — confirm it
fails with 404 (path not found) or an assertion error, not a Python error.
Green
Implement the minimum handler. Template for a new route on an existing router:
# in create_<resource>_router():
@router.<method>("<relative-path>", status_code=<N>, response_model=<ResponseModel>)
async def <handler>(
body: <RequestModel>,
x_user_id: Annotated[str | None, Depends(get_user_id)] = None, # NOT Header(alias="X-User-Id")
) -> <ResponseModel> | Response:
try:
result = await svc.<operation>(...)
except <DomainError>:
return problem(<status>, HttpErrorCode.<CODE>, "<message>")
return <ResponseModel>(...)
IMPORTANT: Never use
Header(alias="X-User-Id")in route handlers — the domain map R3 prohibition and00_rule.mdban it. Always useDepends(get_user_id)fromragent.auth.deps; it reads fromrequest.scope[SCOPE_USER_ID_KEY]populated by the auth middleware, so it works correctly in all auth modes (open, trust-header, JWT).Header(alias="X-User-Id")bypasses the middleware and breaks in JWT mode.get_user_idreturnsstr | None; the= Nonedefault is required for FastAPI to accept the dependency without a positional arg.
For a brand-new resource also:
- Create
src/ragent/routers/<resource>.pyfollowing the factory function pattern. - Import and wire in
src/ragent/bootstrap/app.py:from ragent.routers.<resource> import create_<resource>_router # inside create_app(): app.include_router(create_<resource>_router(svc=container.<resource>_svc)) - Confirm
tests/unit/test_api_versioning.pystill passes — it asserts every non-infra route carries a version segment.
Refactor
- Extract repeated error-handling into a shared helper if ≥ 2 routes share the same
exceptpattern. - Ensure all
response_model=annotations are present (FastAPI OpenAPI schema completeness). - Run
make check(format + lint + tests).
Step 5 — Error codes
Every non-2xx response needs a stable error_code. Before adding a new one:
- Check
src/ragent/errors/codes.py::HttpErrorCode— reuse an existing code if semantics match. - Add a new
SCREAMING_SNAKEmember only for genuinely distinct failure modes. - Add the new code to the error catalog table in
docs/spec/error_codes.mdwith status, path, and owning task. - Add a test asserting
resp.json()["error_code"] == HttpErrorCode.<CODE>.
Step 6 — Versioning a breaking change (v2)
Only do this when the request or response shape changes in a way that breaks existing clients (field removed, type changed, semantics inverted).
# src/ragent/routers/ingest.py (example)
def create_ingest_v2_router(svc: Any) -> APIRouter:
router = APIRouter(prefix="/ingest/v2", route_class=_IngestRoute)
# new shapes here
return router
# src/ragent/bootstrap/app.py
app.include_router(create_ingest_router(svc=ingest_svc)) # v1 stays live
app.include_router(create_ingest_v2_router(svc=ingest_svc)) # v2 alongside
Document the deprecation timeline for v1 in docs/00_spec.md and a
docs/00_plan.md decommission task before merging the v2 router.
Step 7 — Auth middleware bypass (infra-only)
If the new endpoint must be reachable without X-User-Id (a health
probe, a metrics scrape, an unauthenticated callback), add its path to
_PUBLIC_PATHS in src/ragent/bootstrap/app.py and to the
_SKIP_PATHS list in src/ragent/middleware/logging.py. Business
endpoints (any path under /<resource>/v<N>) must NOT be in this list.
Quick checklist (paste into the PR description)
- Situation classified: new route on existing router / new resource / v2 bump
- Path follows
/<resource>/v<N>[/<rest>]naming convention; version in router prefix only -
docs/spec/endpoints.mdendpoint table row + scenario(s) added -
docs/00_plan.mdRed + Green task rows added - Failing test written first; confirmed to fail with 404 or assertion before any production code
-
response_model=annotation on every new route decorator - Non-2xx paths covered by tests asserting
error_codein response body - New
HttpErrorCodemembers (if any) added tosrc/ragent/errors/codes.pyanddocs/spec/error_codes.md - For new resource:
create_<resource>_router()wired inbootstrap/app.py -
tests/unit/test_api_versioning.pystill passes after wiring - For v2: old version still mounted; deprecation timeline in spec; decommission task in plan
- Infra bypass: only added to
_PUBLIC_PATHSif genuinely infra (not a business endpoint) - Handler uses
Depends(get_user_id)(fromragent.auth.deps), NOTHeader(alias="X-User-Id") -
make checkgreen (format + lint + full test suite) -
[BEHAVIORAL]commit; no structural changes mixed in