ifcurl/ifcurl/documents_api.py
Bruno Postle 9afeae5d44 bcf_api: add topic filtering; documents_api: add GET /document-metadata
BCF list_topics now maps topic_status, assigned_to, label, modified_after,
modified_before query params to Forgejo issue API params.

documents_api adds GET /document-metadata/{document_id} which decodes the
document_id and proxies Forgejo's contents endpoint for file metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 22:24:52 +01:00

178 lines
7.1 KiB
Python

# IFC URL — OpenCDE Documents API routes
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
"""OpenCDE Documents API 1.0 — read-only, minimal implementation.
Routes are mounted at /documents/1.0 in service.py and proxied at
/documents/ on the Forgejo hostname via nginx/Caddy.
document_id is a base64url encoding of "owner/repo/path", making it stable
and opaque to callers.
Versions exposed: one per git tag (sorted chronologically, oldest = index 1),
plus the most recent HEAD commit if it is not already covered by a tag. This
mirrors CDE conventions where only released/tagged versions are visible, while
still surfacing the current work-in-progress state.
version_number is the tag name for tagged versions and the short SHA for HEAD.
Download URLs point at Forgejo's existing raw file endpoint:
{forgejo_host}/{owner}/{repo}/raw/commit/{sha}/{path}
Auth is forwarded verbatim so Forgejo enforces per-user permissions.
"""
from __future__ import annotations
import base64
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from ifcurl.bcf_api import _auth, _fget
router = APIRouter(prefix="/documents/1.0")
# ---------------------------------------------------------------------------
# document_id encoding / decoding
# ---------------------------------------------------------------------------
def encode_document_id(owner: str, repo: str, path: str) -> str:
"""Encode owner/repo/path as a stable base64url document_id."""
return base64.urlsafe_b64encode(f"{owner}/{repo}/{path}".encode()).rstrip(b"=").decode()
def decode_document_id(doc_id: str) -> tuple[str, str, str]:
"""Decode a document_id back to (owner, repo, path). Raises ValueError if malformed."""
padding = "=" * (4 - len(doc_id) % 4) if len(doc_id) % 4 else ""
try:
decoded = base64.urlsafe_b64decode(doc_id + padding).decode()
except Exception as exc:
raise ValueError(f"Invalid document_id: {doc_id!r}") from exc
parts = decoded.split("/", 2)
if len(parts) != 3 or not all(parts):
raise ValueError(f"Invalid document_id: {doc_id!r}")
return parts[0], parts[1], parts[2]
# ---------------------------------------------------------------------------
# Version enumeration
# ---------------------------------------------------------------------------
def _versions_for_file(owner: str, repo: str, path: str, auth: str | None, forgejo_base: str) -> list[dict]:
"""Return a list of BCF version dicts for the file, tagged releases + HEAD."""
# Tagged versions — one entry per tag, sorted oldest-first by commit date.
tags = _fget(f"/api/v1/repos/{owner}/{repo}/tags", auth, params={"limit": 50})
tags = tags if isinstance(tags, list) else []
seen_shas: set[str] = set()
versions: list[dict] = []
for tag in sorted(tags, key=lambda t: t.get("commit", {}).get("created", "")):
sha = (tag.get("commit") or {}).get("sha", "")
if not sha or sha in seen_shas:
continue
seen_shas.add(sha)
versions.append({
"sha": sha,
"date": (tag.get("commit") or {}).get("created", ""),
"version_number": tag.get("name", sha[:8]),
"author_name": "",
"author_email": "",
})
# HEAD — add only if its commit SHA is not already represented by a tag.
head_commits = _fget(
f"/api/v1/repos/{owner}/{repo}/commits",
auth,
params={"path": path, "limit": 1, "sha": "HEAD"},
)
if isinstance(head_commits, list) and head_commits:
head = head_commits[0]
sha = head.get("sha", "")
if sha and sha not in seen_shas:
commit_meta = head.get("commit") or {}
author = commit_meta.get("author") or {}
committer = commit_meta.get("committer") or {}
versions.append({
"sha": sha,
"date": committer.get("date") or author.get("date", ""),
"version_number": sha[:8],
"author_name": author.get("name", ""),
"author_email": author.get("email", ""),
})
return [
{
"version_index": i,
"version_number": v["version_number"],
"creation_date": v["date"],
"created_by": {"id": v["author_email"], "name": v["author_name"]},
"document_version_download": f"{forgejo_base}/{owner}/{repo}/raw/commit/{v['sha']}/{path}",
"document_version_upload": None,
}
for i, v in enumerate(versions, start=1)
]
# ---------------------------------------------------------------------------
# Route
# ---------------------------------------------------------------------------
@router.get("/document-metadata/{document_id}")
def get_document_metadata(document_id: str, request: Request) -> JSONResponse:
"""Return metadata for a single document_id."""
auth = _auth(request)
try:
owner, repo, path = decode_document_id(document_id)
except ValueError:
raise HTTPException(status_code=404, detail="Invalid document_id")
info = _fget(f"/api/v1/repos/{owner}/{repo}/contents/{path}", auth)
if not isinstance(info, dict):
raise HTTPException(status_code=404, detail="File not found")
return JSONResponse({
"document_id": document_id,
"name": info.get("name", path.rsplit("/", 1)[-1]),
"description": f"{owner}/{repo}/{path}",
})
@router.post("/document-versions")
def document_versions(request: Request, body: dict = Body(default={})) -> JSONResponse:
"""Return tagged releases + HEAD for each requested document_id.
Implements the OpenCDE Documents API resolution step (SPECIFICATION.md §9
step 3). Tagged versions are indexed oldest=1, newest=N; HEAD is appended
as N+1 when it is not already tagged. version_number is the tag name for
releases and the short SHA for HEAD.
"""
auth = _auth(request)
host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost")
proto = request.headers.get("x-forwarded-proto", "https")
forgejo_base = f"{proto}://{host}"
result = []
for doc_id in body.get("document_ids", []):
try:
owner, repo, path = decode_document_id(doc_id)
except ValueError:
continue
for v in _versions_for_file(owner, repo, path, auth, forgejo_base):
result.append({"document_id": doc_id, **v})
return JSONResponse(result)