documents_api: implement OpenCDE Documents API 1.0

POST /documents/1.0/document-versions resolves document_ids (base64url
encoding of owner/repo/path) to versioned download URLs. Exposes only
git tags + the current HEAD commit — mirrors CDE conventions where only
released versions are visible, while still surfacing the WIP state.

version_number is the tag name for releases and short SHA for HEAD.
Tagged SHAs are deduplicated so HEAD at a tag doesn't create a duplicate.
Download URLs point at Forgejo's existing raw/commit endpoint.

Auth forwarded verbatim. No Go code, no Forgejo recompilation.
Proxy at /documents/ on the Forgejo hostname.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-29 20:27:25 +01:00
parent 8821c70098
commit 63a6b2bfa6
5 changed files with 343 additions and 1 deletions

View file

@ -98,7 +98,7 @@
{"id":"ifcurl-0oj","title":"Phase 4: Bonsai ifc:// OS protocol handler","description":"Register ifc:// as an OS protocol handler in Bonsai on installation (Windows, macOS, Linux). On receiving a URL, resolve the git source, load the model, and apply the view state from the URL parameters (camera, selector, clipping planes, visibility mode). This is the foundation for all other Phase 4 work.","status":"deferred","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:45Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:09Z","defer_until":"2026-12-01T00:00:00Z","dependency_count":0,"dependent_count":3,"comment_count":0} {"id":"ifcurl-0oj","title":"Phase 4: Bonsai ifc:// OS protocol handler","description":"Register ifc:// as an OS protocol handler in Bonsai on installation (Windows, macOS, Linux). On receiving a URL, resolve the git source, load the model, and apply the view state from the URL parameters (camera, selector, clipping planes, visibility mode). This is the foundation for all other Phase 4 work.","status":"deferred","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:45Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:09Z","defer_until":"2026-12-01T00:00:00Z","dependency_count":0,"dependent_count":3,"comment_count":0}
{"id":"ifcurl-cyw","title":"Phase 3c: Verify Forgejo merge-ability check works with ifcmerge","description":"Investigate whether Forgejo's merge-ability check (the 'branches can be merged automatically' indicator) correctly reflects ifcmerge's ability to handle .ifc files. Determine if Forgejo runs a trial git merge or uses a heuristic. If needed, patch Forgejo to run a trial git merge --no-commit to get an accurate result.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:22Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:27:25Z","started_at":"2026-04-24T08:19:58Z","closed_at":"2026-04-24T08:27:25Z","close_reason":"Verified: git merge-tree --write-tree (Forgejo's code path for git ≥2.38) does invoke merge drivers. Bare repos require core.attributesFile in /etc/gitconfig — committed .gitattributes not read. Added gitattributes server-config file and updated deployment docs.","dependencies":[{"issue_id":"ifcurl-cyw","depends_on_id":"ifcurl-c69","type":"blocks","created_at":"2026-04-23T06:48:29Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-cyw","title":"Phase 3c: Verify Forgejo merge-ability check works with ifcmerge","description":"Investigate whether Forgejo's merge-ability check (the 'branches can be merged automatically' indicator) correctly reflects ifcmerge's ability to handle .ifc files. Determine if Forgejo runs a trial git merge or uses a heuristic. If needed, patch Forgejo to run a trial git merge --no-commit to get an accurate result.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:22Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:27:25Z","started_at":"2026-04-24T08:19:58Z","closed_at":"2026-04-24T08:27:25Z","close_reason":"Verified: git merge-tree --write-tree (Forgejo's code path for git ≥2.38) does invoke merge drivers. Bare repos require core.attributesFile in /etc/gitconfig — committed .gitattributes not read. Added gitattributes server-config file and updated deployment docs.","dependencies":[{"issue_id":"ifcurl-cyw","depends_on_id":"ifcurl-c69","type":"blocks","created_at":"2026-04-23T06:48:29Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-c69","title":"Phase 3c: Register ifcmerge as git merge driver on Forgejo server","description":"Configure ifcmerge as the git merge driver for .ifc files on the Forgejo server. Add to /etc/gitconfig or ~forgejo/.gitconfig: [merge \"ifcmerge\"] name = IFC merge driver / driver = ifcmerge %O %A %B %L. Add *.ifc merge=ifcmerge to server-side gitattributes. This allows Forgejo's 'merge automatically' path to invoke ifcmerge via git when merging .ifc files.","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:21Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:15:20Z","closed_at":"2026-04-24T08:15:20Z","close_reason":"Documented ifcmerge driver registration (gitconfig-ifcmerge), gitattributes setup, merge direction constraint, and merge-commit strategy requirement","comments":[{"id":"019dbc54-bf0a-76e2-b64d-7d1681f0c730","issue_id":"ifcurl-c69","author":"Bruno Postle","text":"ifcmerge needs to be installed as a pair of merge tools because the merge process is asymmetrical and we need to be able to prefer one branch over another (because step-ids from one branch get rewritten in the merged file). In practice we prefer origin and/or main/master branches. there is code in the ifcopenshell bonsai ifcgit module for reference.","created_at":"2026-04-23T21:52:44Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} {"id":"ifcurl-c69","title":"Phase 3c: Register ifcmerge as git merge driver on Forgejo server","description":"Configure ifcmerge as the git merge driver for .ifc files on the Forgejo server. Add to /etc/gitconfig or ~forgejo/.gitconfig: [merge \"ifcmerge\"] name = IFC merge driver / driver = ifcmerge %O %A %B %L. Add *.ifc merge=ifcmerge to server-side gitattributes. This allows Forgejo's 'merge automatically' path to invoke ifcmerge via git when merging .ifc files.","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:21Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:15:20Z","closed_at":"2026-04-24T08:15:20Z","close_reason":"Documented ifcmerge driver registration (gitconfig-ifcmerge), gitattributes setup, merge direction constraint, and merge-commit strategy requirement","comments":[{"id":"019dbc54-bf0a-76e2-b64d-7d1681f0c730","issue_id":"ifcurl-c69","author":"Bruno Postle","text":"ifcmerge needs to be installed as a pair of merge tools because the merge process is asymmetrical and we need to be able to prefer one branch over another (because step-ids from one branch get rewritten in the merged file). In practice we prefer origin and/or main/master branches. there is code in the ifcopenshell bonsai ifcgit module for reference.","created_at":"2026-04-23T21:52:44Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1}
{"id":"ifcurl-gxf","title":"Add minimal read-only OpenCDE Documents API","description":"Expose a minimal read-only OpenCDE Documents API that makes IFC files in git repos addressable as OpenCDE documents.\n\n## Architecture\n\nGo routes added to Forgejo alongside the existing patch. Purely additive — new endpoints only. Uses Forgejo's existing auth middleware so per-user repository permissions are enforced correctly with no extra machinery. Direct access to Forgejo's repo and git internals — no round-trips through the REST API.\n\n## Design\n\ndocument_id encodes owner/repo/path/commit-hash as a stable opaque string. version_index is derived from the commit's position in the git log. Implements POST /document-versions returning download URLs pointing to Forgejo's existing raw file endpoint for the resolved commit.\n\nThis validates the ifc:// CDE extension in SPECIFICATION.md §9 (document_id= parameter) and makes Forgejo discoverable as an OpenCDE server to any client that speaks the Documents API.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:30Z","created_by":"Bruno Postle","updated_at":"2026-04-28T23:10:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-gxf","title":"Add minimal read-only OpenCDE Documents API","description":"Expose a minimal read-only OpenCDE Documents API that makes IFC files in git repos addressable as OpenCDE documents.\n\n## Architecture\n\nPython routes in the preview service, proxied at /documents/ on the Forgejo hostname via nginx/Caddy. Same pattern as the BCF API. Auth forwarded verbatim (Authorization: Bearer) — Forgejo enforces per-user repo permissions. No Go code, no Forgejo recompilation.\n\n## Design\n\ndocument_id is a base64url encoding of 'owner/repo/path', making it stable and opaque. version_index is the 1-based position of the commit in the file's git history (oldest=1, newest=N), derived by querying Forgejo's commits API with the file path filter.\n\nPOST /documents/1.0/document-versions returns all versions (up to 50 commits) for each requested document_id. Download URLs point at Forgejo's existing raw file endpoint: {forgejo_host}/{owner}/{repo}/raw/commit/{sha}/{path}.\n\nThe public Forgejo base URL for download links is derived from X-Forwarded-Host/X-Forwarded-Proto headers, same as the Foundation endpoint.\n\nThis validates the ifc:// CDE extension in SPECIFICATION.md §9 (document_id= parameter) and makes Forgejo discoverable as an OpenCDE server to any client that speaks the Documents API.","status":"in_progress","priority":4,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:30Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:48:16Z","started_at":"2026-04-29T18:48:04Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-s1o","title":"viewer: extract inline JS from viewer.html into viewer.js","description":"viewer.html is ~1250 lines, almost entirely JavaScript. This makes it hard to edit with proper tooling (no syntax highlighting, linting, or go-to-definition). Extract the main viewer logic into a separate viewer.js file alongside viewer-url.js. The build.js step would need to copy it to the assets directory.","status":"closed","priority":4,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T23:16:36Z","created_by":"Bruno Postle","updated_at":"2026-04-26T14:49:05Z","started_at":"2026-04-26T13:10:02Z","closed_at":"2026-04-26T14:49:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-s1o","title":"viewer: extract inline JS from viewer.html into viewer.js","description":"viewer.html is ~1250 lines, almost entirely JavaScript. This makes it hard to edit with proper tooling (no syntax highlighting, linting, or go-to-definition). Extract the main viewer logic into a separate viewer.js file alongside viewer-url.js. The build.js step would need to copy it to the assets directory.","status":"closed","priority":4,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T23:16:36Z","created_by":"Bruno Postle","updated_at":"2026-04-26T14:49:05Z","started_at":"2026-04-26T13:10:02Z","closed_at":"2026-04-26T14:49:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-nd1","title":"Submit provisional IANA registration for ifc:// scheme","description":"Once the Phase 1 URL spec is stable, submit a provisional IANA URI scheme registration for ifc://.\\n\\nProcess: email iana@iana.org with scheme name, syntax description, security considerations, and a link to the spec (GitHub URL suffices). First Come First Served — no committee review, typically processed within days to weeks. See RFC 7595 for requirements.\\n\\nThis secures the namespace, establishes priority, and is a prerequisite if the scheme is ever proposed to buildingSMART as an open standard.","status":"open","priority":4,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T11:17:45Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:17:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-nd1","title":"Submit provisional IANA registration for ifc:// scheme","description":"Once the Phase 1 URL spec is stable, submit a provisional IANA URI scheme registration for ifc://.\\n\\nProcess: email iana@iana.org with scheme name, syntax description, security considerations, and a link to the spec (GitHub URL suffices). First Come First Served — no committee review, typically processed within days to weeks. See RFC 7595 for requirements.\\n\\nThis secures the namespace, establishes priority, and is a prerequisite if the scheme is ever proposed to buildingSMART as an open standard.","status":"open","priority":4,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T11:17:45Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:17:45Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-u6r","title":"Forgejo commit page: no IFC thumbnail — git log gives no visual indication of model state","description":"In the Forgejo repository commit list, each commit shows only the commit message. When a commit modifies an IFC file, there is no thumbnail showing what the model looks like. Reviewers browsing commits have no visual cues about which commits changed significant geometry versus metadata-only changes. A Forgejo webhook or CI integration could post a rendered PNG thumbnail as a commit status/check comment when .ifc files change, using the preview service. This would make the git log visually meaningful for BIM projects.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:41Z","created_by":"Bruno Postle","updated_at":"2026-04-25T20:07:21Z","closed_at":"2026-04-25T20:07:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-u6r","title":"Forgejo commit page: no IFC thumbnail — git log gives no visual indication of model state","description":"In the Forgejo repository commit list, each commit shows only the commit message. When a commit modifies an IFC file, there is no thumbnail showing what the model looks like. Reviewers browsing commits have no visual cues about which commits changed significant geometry versus metadata-only changes. A Forgejo webhook or CI integration could post a rendered PNG thumbnail as a commit status/check comment when .ifc files change, using the preview service. This would make the git log visually meaningful for BIM projects.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:41Z","created_by":"Bruno Postle","updated_at":"2026-04-25T20:07:21Z","closed_at":"2026-04-25T20:07:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -137,6 +137,7 @@ ifcurl serve --host 0.0.0.0 --port 9000 --allowed-hosts git.example.com
| `GET /select?url=ifc://…` | Resolve a complex selector server-side; returns JSON list of GlobalIds | | `GET /select?url=ifc://…` | Resolve a complex selector server-side; returns JSON list of GlobalIds |
| `GET /render_diff?base=ifc://…&head=ifc://…` | Render a colour-coded diff PNG (added green, removed red) | | `GET /render_diff?base=ifc://…&head=ifc://…` | Render a colour-coded diff PNG (added green, removed red) |
| `GET /foundation/versions` | OpenCDE Foundation API discovery — lists BCF and Documents API endpoints (proxy at `/foundation/` on the Forgejo hostname) | | `GET /foundation/versions` | OpenCDE Foundation API discovery — lists BCF and Documents API endpoints (proxy at `/foundation/` on the Forgejo hostname) |
| `POST /documents/1.0/document-versions` | OpenCDE Documents API — resolve document_ids to versioned download URLs (proxied at `/documents/`) |
| `GET /bcf/3.0/projects` | BCF 3.0 — list repositories as BCF projects | | `GET /bcf/3.0/projects` | BCF 3.0 — list repositories as BCF projects |
| `GET/POST /bcf/3.0/projects/{owner}/{repo}/topics` | BCF 3.0 — list or create topics (Forgejo issues) | | `GET/POST /bcf/3.0/projects/{owner}/{repo}/topics` | BCF 3.0 — list or create topics (Forgejo issues) |
| `GET/PUT /bcf/3.0/projects/{owner}/{repo}/topics/{guid}` | BCF 3.0 — get or update a topic | | `GET/PUT /bcf/3.0/projects/{owner}/{repo}/topics/{guid}` | BCF 3.0 — get or update a topic |
@ -159,6 +160,7 @@ The BCF API forwards the client's `Authorization: Bearer` header to Forgejo's RE
```nginx ```nginx
location /foundation/ { proxy_pass http://localhost:8000/foundation/; } location /foundation/ { proxy_pass http://localhost:8000/foundation/; }
location /bcf/ { proxy_pass http://localhost:8000/bcf/; } location /bcf/ { proxy_pass http://localhost:8000/bcf/; }
location /documents/ { proxy_pass http://localhost:8000/documents/; }
``` ```
**Caching:** **Caching:**

160
ifcurl/documents_api.py Normal file
View file

@ -0,0 +1,160 @@
# 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, 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.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)

View file

@ -53,6 +53,7 @@ from pydantic import BaseModel
from ifcurl.auth import get_token_for_host from ifcurl.auth import get_token_for_host
from ifcurl.bcf import build_bcf from ifcurl.bcf import build_bcf
from ifcurl.bcf_api import router as bcf_router from ifcurl.bcf_api import router as bcf_router
from ifcurl.documents_api import router as documents_router
from ifcurl.git import diff_text as git_diff_text from ifcurl.git import diff_text as git_diff_text
from ifcurl.git import fetch_ifc from ifcurl.git import fetch_ifc
from ifcurl.render_service import _sandboxed_diff, _sandboxed_pipeline, _sandboxed_select from ifcurl.render_service import _sandboxed_diff, _sandboxed_pipeline, _sandboxed_select
@ -174,6 +175,7 @@ app = FastAPI(
version="0.0.0", version="0.0.0",
) )
app.include_router(bcf_router) app.include_router(bcf_router)
app.include_router(documents_router)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Rate limiting (in-process, per client IP, sliding window) # Rate limiting (in-process, per client IP, sliding window)

178
tests/test_documents_api.py Normal file
View file

@ -0,0 +1,178 @@
"""Tests for ifcurl.documents_api — OpenCDE Documents API routes."""
from __future__ import annotations
from unittest.mock import call, patch
import pytest
from fastapi.testclient import TestClient
from ifcurl.documents_api import decode_document_id, encode_document_id
from ifcurl.service import _rate_hits, app
client = TestClient(app)
OWNER, REPO, PATH = "alice", "myproject", "models/building.ifc"
TAG_V1 = {
"name": "v1.0",
"commit": {"sha": "1111aaaa2222bbbb3333cccc4444dddd5555eeee", "created": "2026-01-01T10:00:00Z"},
}
TAG_V2 = {
"name": "v2.0",
"commit": {"sha": "aaaa1111bbbb2222cccc3333dddd4444eeee5555", "created": "2026-02-01T10:00:00Z"},
}
HEAD_COMMIT = {
"sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"commit": {
"author": {"name": "Alice", "email": "alice@example.com", "date": "2026-03-01T10:00:00Z"},
"committer": {"name": "Alice", "email": "alice@example.com", "date": "2026-03-01T10:00:00Z"},
"message": "WIP changes",
},
}
# HEAD at same SHA as the latest tag — should not add a duplicate version
HEAD_AT_TAG = {
"sha": TAG_V2["commit"]["sha"],
"commit": {
"author": {"name": "Alice", "email": "alice@example.com", "date": "2026-02-01T10:00:00Z"},
"committer": {"name": "Alice", "email": "alice@example.com", "date": "2026-02-01T10:00:00Z"},
"message": "Release v2.0",
},
}
@pytest.fixture(autouse=True)
def reset_rate_limit():
_rate_hits.clear()
yield
_rate_hits.clear()
def _post(doc_ids, tags=None, head_commits=None, headers=None):
if tags is None:
tags = [TAG_V1, TAG_V2]
if head_commits is None:
head_commits = [HEAD_COMMIT]
def mock_fget(path, auth, params=None):
if "/tags" in path:
return tags
if "/commits" in path:
return head_commits
return []
with patch("ifcurl.documents_api._fget", side_effect=mock_fget):
return client.post(
"/documents/1.0/document-versions",
json={"document_ids": doc_ids},
headers=headers or {},
)
# ---------------------------------------------------------------------------
# document_id encoding
# ---------------------------------------------------------------------------
class TestDocumentId:
def test_roundtrip(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
assert decode_document_id(doc_id) == (OWNER, REPO, PATH)
def test_path_with_slashes(self):
doc_id = encode_document_id("alice", "proj", "a/b/c.ifc")
assert decode_document_id(doc_id) == ("alice", "proj", "a/b/c.ifc")
def test_stable(self):
assert encode_document_id(OWNER, REPO, PATH) == encode_document_id(OWNER, REPO, PATH)
def test_different_inputs_differ(self):
assert encode_document_id("alice", "a", "m.ifc") != encode_document_id("alice", "b", "m.ifc")
def test_invalid_raises(self):
with pytest.raises(ValueError):
decode_document_id("!!!not-base64!!!")
# ---------------------------------------------------------------------------
# POST /documents/1.0/document-versions
# ---------------------------------------------------------------------------
class TestDocumentVersions:
def test_returns_200(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id])
assert r.status_code == 200
def test_two_tags_plus_head_give_three_versions(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id])
assert len(r.json()) == 3
def test_tag_names_used_as_version_number(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id])
version_numbers = {v["version_number"] for v in r.json()}
assert "v1.0" in version_numbers
assert "v2.0" in version_numbers
def test_head_version_number_is_short_sha(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id])
versions = sorted(r.json(), key=lambda v: v["version_index"])
assert versions[-1]["version_number"] == HEAD_COMMIT["sha"][:8]
def test_oldest_tag_is_version_index_1(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id])
versions = sorted(r.json(), key=lambda v: v["version_index"])
assert versions[0]["version_number"] == "v1.0"
def test_head_at_same_sha_as_tag_not_duplicated(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id], head_commits=[HEAD_AT_TAG])
assert len(r.json()) == 2 # only the two tags, HEAD deduplicated
def test_no_tags_just_head(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id], tags=[])
versions = r.json()
assert len(versions) == 1
assert versions[0]["version_number"] == HEAD_COMMIT["sha"][:8]
def test_download_url_uses_commit_sha(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id])
for v in r.json():
assert "/raw/commit/" in v["document_version_download"]
def test_download_url_contains_path(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id])
for v in r.json():
assert PATH in v["document_version_download"]
def test_download_url_uses_forwarded_host(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id], headers={"x-forwarded-host": "git.example.com", "x-forwarded-proto": "https"})
for v in r.json():
assert v["document_version_download"].startswith("https://git.example.com/")
def test_document_id_preserved(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id])
for v in r.json():
assert v["document_id"] == doc_id
def test_invalid_document_id_skipped(self):
valid_id = encode_document_id(OWNER, REPO, PATH)
r = _post(["not-valid!!!!", valid_id])
assert all(v["document_id"] == valid_id for v in r.json())
def test_empty_document_ids(self):
r = _post([])
assert r.json() == []
def test_no_commits_and_no_tags_gives_no_versions(self):
doc_id = encode_document_id(OWNER, REPO, PATH)
r = _post([doc_id], tags=[], head_commits=[])
assert r.json() == []