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 409 if generation is active for that variant.
  • Deletes DB record and variant directory.

DELETE /api/projects/{name}

  • Requires write access.
  • Rejects with 409 if 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}/abort is intentionally retained for backward compatibility; new clients should prefer /api/projects/{name}/{provider}/{model}/abort.