Project and Variant Endpoints
docsfy exposes both project-level and variant-level endpoints:
- Project-level endpoints use
/{name}and either list, delete, abort, or download across variants. - Variant-level endpoints use
/{name}/{provider}/{model}and target one exact variant.
Tip: Prefer variant-level endpoints in automation; project-level endpoints can select by owner/time and may be ambiguous in multi-owner setups.
Authentication and Access
All endpoints below are protected except /login and /health. API requests without auth return 401, and write endpoints require admin or user role (viewers are read-only).
```105:155:src/docsfy/main.py class AuthMiddleware(BaseHTTPMiddleware): """Authenticate every request via Bearer token or session cookie."""
# Paths that do not require authentication
_PUBLIC_PATHS = frozenset({"/login", "/login/", "/health"})
... if not user and not is_admin: # Not authenticated if request.url.path.startswith("/api/"): return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) return RedirectResponse(url="/login", status_code=302)
```185:191:src/docsfy/main.py
def _require_write_access(request: Request) -> None:
"""Raise 403 if user is a viewer (read-only)."""
if request.state.role not in ("admin", "user"):
raise HTTPException(
status_code=403,
detail="Write access required.",
)
Endpoint Matrix
| Operation | Project Endpoint | Variant Endpoint | Method |
|---|---|---|---|
| Status list | /api/status |
— | GET |
| Status page (HTML) | — | /status/{name}/{provider}/{model} |
GET |
| Details | /api/projects/{name} |
/api/projects/{name}/{provider}/{model} |
GET |
| Delete | /api/projects/{name} |
/api/projects/{name}/{provider}/{model} |
DELETE |
| Abort | /api/projects/{name}/abort (legacy) |
/api/projects/{name}/{provider}/{model}/abort |
POST |
| Download | /api/projects/{name}/download |
/api/projects/{name}/{provider}/{model}/download |
GET |
Variant Data Shape and Status Values
Variant payloads map directly to the projects table columns.
```57:73:src/docsfy/storage.py CREATE TABLE IF NOT EXISTS projects ( name TEXT NOT NULL, ai_provider TEXT NOT NULL DEFAULT '', ai_model TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', repo_url TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'generating', current_stage TEXT, last_commit_sha TEXT, last_generated TEXT, page_count INTEGER DEFAULT 0, error_message TEXT, plan_json TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (name, ai_provider, ai_model, owner) )
```17:17:src/docsfy/storage.py
VALID_STATUSES = frozenset({"generating", "ready", "error", "aborted"})
Status Endpoints
GET /api/status
Returns:
- projects: accessible variants
- known_models: map of provider -> known ready models
For non-admin users, this includes owned variants plus granted-access variants.
```409:419:src/docsfy/main.py @app.get("/api/status") async def status(request: Request) -> dict[str, Any]: if request.state.is_admin: projects = await list_projects() else: accessible = await get_user_accessible_projects(request.state.username) projects = await list_projects( owner=request.state.username, accessible=accessible ) known_models = await get_known_models() return {"projects": projects, "known_models": known_models}
### `GET /status/{name}/{provider}/{model}` (HTML)
Variant status UI page used by the dashboard/status flow.
```369:401:src/docsfy/main.py
@app.get("/status/{name}/{provider}/{model}", response_class=HTMLResponse)
async def project_status_page(
request: Request, name: str, provider: str, model: str
) -> HTMLResponse:
name = _validate_project_name(name)
project = await _resolve_project(
request, name, ai_provider=provider, ai_model=model
)
...
template = _jinja_env.get_template("status.html")
html = template.render(
project=project,
plan_json=plan_json,
total_pages=total_pages,
known_models=known_models,
default_provider=settings.ai_provider,
default_model=settings.ai_model,
)
return HTMLResponse(content=html)
Details Endpoints
GET /api/projects/{name}
Returns { "name": "...", "variants": [...] }.
- Admin: all owners’ variants for that name.
- Non-admin: only variants owned by
request.state.username.
GET /api/projects/{name}/{provider}/{model}
Returns one resolved variant object.
```1019:1124:src/docsfy/main.py @app.get("/api/projects/{name}/{provider}/{model}") async def get_variant_details( request: Request, name: str, provider: str, model: str, ) -> dict[str, str | int | None]: name = _validate_project_name(name) project = await _resolve_project( request, name, ai_provider=provider, ai_model=model )
return project
... @app.get("/api/projects/{name}") async def get_project_details(request: Request, name: str) -> dict[str, Any]: name = _validate_project_name(name) if request.state.is_admin: variants = await list_variants(name) else: variants = await list_variants(name, owner=request.state.username) if not variants: raise HTTPException(status_code=404, detail=f"Project '{name}' not found") return {"name": name, "variants": variants}
> **Warning:** Variant resolution for admin can return `409` when the same `{name}/{provider}/{model}` exists under multiple owners.
```231:246:src/docsfy/main.py
# 2. For admin, disambiguate by owner
if request.state.is_admin:
all_variants = await list_variants(name)
matching = [
v
for v in all_variants
if v.get("ai_provider") == ai_provider and v.get("ai_model") == ai_model
]
if not matching:
raise HTTPException(status_code=404, detail="Not found")
distinct_owners = {str(v.get("owner", "")) for v in matching}
if len(distinct_owners) > 1:
raise HTTPException(
status_code=409,
detail="Multiple owners found for this variant, please specify owner",
)
Deletion Endpoints
DELETE /api/projects/{name}/{provider}/{model}
- Requires write access.
- Rejects deletion with
409if generation is active for that variant. - Deletes DB record and variant directory.
DELETE /api/projects/{name}
- Requires write access.
- Rejects with
409if any variant with that project name is still generating. - Admin deletes all variants for that name (across owners); non-admin deletes only own variants.
```1034:1071:src/docsfy/main.py @app.delete("/api/projects/{name}/{provider}/{model}") async def delete_variant( request: Request, name: str, provider: str, model: str, ) -> dict[str, str]: _require_write_access(request) name = _validate_project_name(name)
# Check for active generation (scan all keys)
for key in _generating:
... raise HTTPException( status_code=409, detail=f"Cannot delete '{name}/{provider}/{model}' while generation is in progress. Abort first.", ) ... return {"deleted": f"{name}/{provider}/{model}"}
```1127:1155:src/docsfy/main.py
@app.delete("/api/projects/{name}")
async def delete_project_endpoint(request: Request, name: str) -> dict[str, str]:
_require_write_access(request)
name = _validate_project_name(name)
...
if request.state.is_admin:
variants = await list_variants(name)
else:
variants = await list_variants(name, owner=request.state.username)
...
return {"deleted": name}
Abort Endpoints
POST /api/projects/{name}/abort (legacy)
Backwards-compatible endpoint that aborts the first active generation matching project name.
POST /api/projects/{name}/{provider}/{model}/abort
Variant-specific abort endpoint.
Both endpoints:
- Require write access.
- Return 404 if no active generation.
- Can return 409 if cancellation is still in progress.
- Update status to aborted with error_message="Generation aborted by user".
```569:639:src/docsfy/main.py @app.post("/api/projects/{name}/abort") async def abort_generation(request: Request, name: str) -> dict[str, str]: """Abort generation for any variant of the given project name. ... _require_write_access(request) ... if not task or not matching_key: raise HTTPException( status_code=404, detail=f"No active generation for '{name}'" ) ... await update_project_status( name, ai_provider, ai_model, status="aborted", owner=key_owner, error_message="Generation aborted by user", current_stage=None, ) ... return {"aborted": name}
```642:717:src/docsfy/main.py
@app.post("/api/projects/{name}/{provider}/{model}/abort")
async def abort_variant(
request: Request, name: str, provider: str, model: str
) -> dict[str, str]:
_require_write_access(request)
...
if not task:
...
if not task:
raise HTTPException(
status_code=404,
detail="No active generation for this variant",
)
...
return {"aborted": f"{name}/{provider}/{model}"}
UI integration example (URL-encoding each path segment):
```2162:2176:src/docsfy/templates/dashboard.html document.addEventListener('click', async function(e) { var abortBtn = e.target.closest('[data-abort-variant]'); if (!abortBtn) return; var composite = abortBtn.getAttribute('data-abort-variant'); // composite is "name/provider/model" var parts = composite.split('/'); var name = parts[0]; var provider = parts[1]; var model = parts.slice(2).join('/'); ... fetch('/api/projects/' + encodeURIComponent(name) + '/' + encodeURIComponent(provider) + '/' + encodeURIComponent(model) + '/abort', { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
## Download Endpoints
### `GET /api/projects/{name}/{provider}/{model}/download`
- Requires variant to be `ready`, else `400 "Variant not ready"`.
- Streams `application/gzip`.
- Filename: `{name}-{provider}-{model}-docs.tar.gz`.
### `GET /api/projects/{name}/download`
- Selects latest ready variant (`last_generated DESC`).
- Streams `application/gzip`.
- Filename: `{name}-docs.tar.gz`.
```1074:1112:src/docsfy/main.py
@app.get("/api/projects/{name}/{provider}/{model}/download")
async def download_variant(
request: Request,
name: str,
provider: str,
model: str,
) -> StreamingResponse:
...
if project["status"] != "ready":
raise HTTPException(status_code=400, detail="Variant not ready")
...
return StreamingResponse(
_stream_and_cleanup(),
media_type="application/gzip",
headers={
"Content-Disposition": f'attachment; filename="{name}-{provider}-{model}-docs.tar.gz"'
},
)
```1158:1194:src/docsfy/main.py @app.get("/api/projects/{name}/download") async def download_project(request: Request, name: str) -> StreamingResponse: ... if request.state.is_admin: latest = await get_latest_variant(name) else: latest = await get_latest_variant(name, owner=request.state.username) if not latest: raise HTTPException(status_code=404, detail=f"No ready variant for '{name}'") ... return StreamingResponse( _stream_and_cleanup(), media_type="application/gzip", headers={"Content-Disposition": f'attachment; filename="{name}-docs.tar.gz"'}, )
Integration test coverage confirms both download routes return gzip content:
```138:146:tests/test_integration.py
# Download via variant-specific route
response = await client.get("/api/projects/test-repo/claude/opus/download")
assert response.status_code == 200
assert response.headers["content-type"] == "application/gzip"
# Download via latest-variant route
response = await client.get("/api/projects/test-repo/download")
assert response.status_code == 200
assert response.headers["content-type"] == "application/gzip"
Validation and Common Error Cases
Project name is validated before project/variant operations:
```73:77:src/docsfy/main.py def validate_project_name(name: str) -> str: """Validate project name to prevent path traversal.""" if not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.-]*$", name): raise HTTPException(status_code=400, detail=f"Invalid project name: '{name}'") return name
Common errors:
- `400`: invalid project name; variant download attempted before ready.
- `401`: missing/invalid API auth for `/api/*`.
- `403`: write action by `viewer`.
- `404`: not found/not accessible/no active generation.
- `409`: delete while generating; admin owner ambiguity; abort still cancelling.
## Relevant Configuration Snippets
Auth/runtime settings affecting these endpoints:
```1:8:.env.example
# REQUIRED - Admin key for user management (minimum 16 characters)
ADMIN_KEY=your-secure-admin-key-here-min-16-chars
# AI Configuration
AI_PROVIDER=claude
# [1m] = 1 million token context window, this is a valid model identifier
AI_MODEL=claude-opus-4-6[1m]
AI_CLI_TIMEOUT=60
```27:28:.env.example
Set to false for local HTTP development
SECURE_COOKIES=false
Operational health check (separate from project status API):
```9:13:docker-compose.yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
Test runner config used for endpoint coverage:
```1:7:tox.toml skipsdist = true
envlist = ["unittests"]
[env.unittests] deps = ["uv"] commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]] ```
Note:
/api/projects/{name}/abortis intentionally retained for backward compatibility; new clients should prefer/api/projects/{name}/{provider}/{model}/abort.