objects.py
python
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109
docs(mwp-1): mark Phase 5 complete, all acceptance criteria…
Sonnet 4.6
25 days ago
| 1 | """MuseHub object (artifact) route handlers. |
| 2 | |
| 3 | Endpoint summary: |
| 4 | GET /repos/{repo_id}/objects — list artifact metadata |
| 5 | GET /repos/{repo_id}/objects/{object_id}/content — serve raw artifact bytes |
| 6 | GET /repos/{repo_id}/blob/{ref}/{path} — blob metadata + text content for UI |
| 7 | GET /repos/{repo_id}/tree/{ref} — list repo root directory at a ref |
| 8 | GET /repos/{repo_id}/tree/{ref}/{path} — list subdirectory at a ref |
| 9 | |
| 10 | All endpoints require a valid MSign token. |
| 11 | """ |
| 12 | |
| 13 | import logging |
| 14 | import mimetypes |
| 15 | from pathlib import Path |
| 16 | |
| 17 | from fastapi import APIRouter, Depends, HTTPException, status |
| 18 | from fastapi.responses import Response |
| 19 | from sqlalchemy.ext.asyncio import AsyncSession |
| 20 | |
| 21 | from musehub.api.validation import FilePathParam, SlugParam |
| 22 | from musehub.auth.dependencies import TokenClaims, optional_token |
| 23 | from musehub.db import get_db |
| 24 | from musehub.models.musehub import BlobMetaResponse, ObjectMetaListResponse, TreeListResponse |
| 25 | from musehub.types.json_types import StrDict |
| 26 | from musehub.services import musehub_repository |
| 27 | from musehub.storage.backends import get_backend as _get_storage_backend |
| 28 | |
| 29 | logger = logging.getLogger(__name__) |
| 30 | |
| 31 | router = APIRouter() |
| 32 | |
| 33 | # Additional MIME types not always in the system mimetypes database |
| 34 | _EXTRA_MIME: StrDict = { |
| 35 | ".webp": "image/webp", |
| 36 | } |
| 37 | |
| 38 | |
| 39 | def _content_type(path: str) -> str: |
| 40 | """Resolve MIME type from path extension; fall back to octet-stream.""" |
| 41 | ext = Path(path).suffix.lower() |
| 42 | if ext in _EXTRA_MIME: |
| 43 | return _EXTRA_MIME[ext] |
| 44 | guessed, _ = mimetypes.guess_type(path) |
| 45 | return guessed or "application/octet-stream" |
| 46 | |
| 47 | |
| 48 | # Maximum text file size to embed in BlobMetaResponse.content_text (256 KB). |
| 49 | _MAX_TEXT_EMBED_BYTES = 256 * 1024 |
| 50 | |
| 51 | _FILE_TYPE_MAP: StrDict = { |
| 52 | ".webp": "image", |
| 53 | ".png": "image", |
| 54 | ".jpg": "image", |
| 55 | ".jpeg": "image", |
| 56 | ".json": "json", |
| 57 | ".xml": "xml", |
| 58 | } |
| 59 | |
| 60 | |
| 61 | def _detect_file_type(path: str) -> str: |
| 62 | """Return a rendering hint based on file extension. |
| 63 | |
| 64 | Values: 'json' | 'image' | 'xml' | 'other' |
| 65 | """ |
| 66 | ext = Path(path).suffix.lower() |
| 67 | return _FILE_TYPE_MAP.get(ext, "other") |
| 68 | |
| 69 | |
| 70 | @router.get( |
| 71 | "/repos/{repo_id}/blob/{ref}/{path:path}", |
| 72 | response_model=BlobMetaResponse, |
| 73 | operation_id="getBlobMeta", |
| 74 | summary="Get blob metadata and optional text content for the file viewer", |
| 75 | ) |
| 76 | async def get_blob_meta( |
| 77 | repo_id: str, |
| 78 | ref: str, |
| 79 | path: FilePathParam, |
| 80 | db: AsyncSession = Depends(get_db), |
| 81 | claims: TokenClaims | None = Depends(optional_token), |
| 82 | ) -> BlobMetaResponse: |
| 83 | """Return metadata for a single file in the blob viewer. |
| 84 | |
| 85 | The response includes the file's size, SHA, creation timestamp, and |
| 86 | rendering hint (``file_type``). For text-based files (JSON, XML) up to |
| 87 | 256 KB, ``content_text`` is populated so the viewer can render them |
| 88 | inline without a second request. Binary and oversized files omit |
| 89 | ``content_text``; consumers should stream bytes from ``raw_url`` instead. |
| 90 | |
| 91 | The ``ref`` parameter accepts branch names or commit SHAs and is used |
| 92 | for URL construction (raw_url). Object resolution always returns the |
| 93 | most-recently-pushed object at ``path``, consistent with the raw and |
| 94 | tree endpoints at MVP scope. |
| 95 | |
| 96 | Returns 404 if the repo is not found or no object exists at that path. |
| 97 | Returns 401 if the repo is private and no valid token is supplied. |
| 98 | """ |
| 99 | repo = await musehub_repository.get_repo(db, repo_id) |
| 100 | if repo is None: |
| 101 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found") |
| 102 | if repo.visibility != "public" and claims is None: |
| 103 | raise HTTPException( |
| 104 | status_code=status.HTTP_401_UNAUTHORIZED, |
| 105 | detail="Authentication required to access private repos.", |
| 106 | headers={"WWW-Authenticate": 'MSign realm="musehub"'}, |
| 107 | ) |
| 108 | |
| 109 | file_meta = await musehub_repository.get_file_at_ref(db, repo_id, ref, path) |
| 110 | if file_meta is None: |
| 111 | raise HTTPException( |
| 112 | status_code=status.HTTP_404_NOT_FOUND, |
| 113 | detail=f"No object at path '{path}' in ref '{ref}'", |
| 114 | ) |
| 115 | |
| 116 | object_id = str(file_meta["object_id"]) |
| 117 | norm_path = str(file_meta["path"]) |
| 118 | file_type = _detect_file_type(norm_path) |
| 119 | raw_url = f"/repos/{repo_id}/raw/{ref}/{path}" |
| 120 | |
| 121 | obj_row = await musehub_repository.get_object_row(db, repo_id, object_id) |
| 122 | if obj_row is None: |
| 123 | raise HTTPException( |
| 124 | status_code=status.HTTP_404_NOT_FOUND, |
| 125 | detail=f"Object '{object_id}' not found in storage", |
| 126 | ) |
| 127 | |
| 128 | storage = _get_storage_backend() |
| 129 | raw_bytes = await storage.get(object_id) |
| 130 | size_bytes = len(raw_bytes) if raw_bytes is not None else obj_row.size_bytes |
| 131 | |
| 132 | content_text: str | None = None |
| 133 | if file_type in ("json", "xml") and raw_bytes is not None and size_bytes <= _MAX_TEXT_EMBED_BYTES: |
| 134 | try: |
| 135 | content_text = raw_bytes.decode("utf-8", errors="replace") |
| 136 | except Exception: |
| 137 | logger.warning("⚠️ Could not decode text content for blob %s", object_id) |
| 138 | |
| 139 | return BlobMetaResponse( |
| 140 | object_id=object_id, |
| 141 | path=norm_path, |
| 142 | filename=Path(norm_path).name, |
| 143 | size_bytes=size_bytes, |
| 144 | sha=object_id, |
| 145 | created_at=obj_row.created_at, |
| 146 | raw_url=raw_url, |
| 147 | file_type=file_type, |
| 148 | content_text=content_text, |
| 149 | ) |
| 150 | |
| 151 | |
| 152 | @router.get( |
| 153 | "/repos/{repo_id}/objects", |
| 154 | response_model=ObjectMetaListResponse, |
| 155 | operation_id="listObjects", |
| 156 | summary="List artifact metadata for a MuseHub repo", |
| 157 | ) |
| 158 | async def list_objects( |
| 159 | repo_id: str, |
| 160 | db: AsyncSession = Depends(get_db), |
| 161 | claims: TokenClaims | None = Depends(optional_token), |
| 162 | ) -> ObjectMetaListResponse: |
| 163 | """Return metadata (path, size, object_id) for all objects in the repo. |
| 164 | |
| 165 | Binary content is excluded from this response — use the ``/content`` |
| 166 | sub-resource to download individual artifacts. Results are ordered by path. |
| 167 | Returns 404 if the repo does not exist. |
| 168 | """ |
| 169 | repo = await musehub_repository.get_repo(db, repo_id) |
| 170 | if repo is None: |
| 171 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found") |
| 172 | if repo.visibility != "public" and claims is None: |
| 173 | raise HTTPException( |
| 174 | status_code=status.HTTP_401_UNAUTHORIZED, |
| 175 | detail="Authentication required to access private repos.", |
| 176 | headers={"WWW-Authenticate": 'MSign realm="musehub"'}, |
| 177 | ) |
| 178 | objects = await musehub_repository.list_objects(db, repo_id) |
| 179 | return ObjectMetaListResponse(objects=objects) |
| 180 | |
| 181 | |
| 182 | @router.get( |
| 183 | "/repos/{repo_id}/objects/{object_id}/content", |
| 184 | operation_id="getObjectContent", |
| 185 | summary="Download raw artifact bytes", |
| 186 | ) |
| 187 | async def get_object_content( |
| 188 | repo_id: str, |
| 189 | object_id: str, |
| 190 | db: AsyncSession = Depends(get_db), |
| 191 | claims: TokenClaims | None = Depends(optional_token), |
| 192 | ) -> Response: |
| 193 | """Return the raw bytes of a stored artifact from the blob store. |
| 194 | |
| 195 | Content-Type is inferred from the stored ``path`` extension |
| 196 | (.webp → image/webp). |
| 197 | Returns 404 if the repo or object is not found, or 410 if the bytes |
| 198 | are not present in the blob store. |
| 199 | """ |
| 200 | repo = await musehub_repository.get_repo(db, repo_id) |
| 201 | if repo is None: |
| 202 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found") |
| 203 | if repo.visibility != "public" and claims is None: |
| 204 | raise HTTPException( |
| 205 | status_code=status.HTTP_401_UNAUTHORIZED, |
| 206 | detail="Authentication required to access private repos.", |
| 207 | headers={"WWW-Authenticate": 'MSign realm="musehub"'}, |
| 208 | ) |
| 209 | obj = await musehub_repository.get_object_row(db, repo_id, object_id) |
| 210 | if obj is None: |
| 211 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found") |
| 212 | |
| 213 | data = await _get_storage_backend().get(object_id) |
| 214 | if data is None: |
| 215 | logger.warning("⚠️ Object %s exists in DB but not in blob store", object_id) |
| 216 | raise HTTPException( |
| 217 | status_code=status.HTTP_410_GONE, |
| 218 | detail="Object has been removed from storage", |
| 219 | ) |
| 220 | |
| 221 | filename = Path(obj.path).name |
| 222 | media_type = _content_type(obj.path) |
| 223 | return Response( |
| 224 | content=data, |
| 225 | media_type=media_type, |
| 226 | headers={"Content-Disposition": f'attachment; filename="{filename}"'}, |
| 227 | ) |
| 228 | |
| 229 | |
| 230 | |
| 231 | |
| 232 | |
| 233 | |
| 234 | @router.get( |
| 235 | "/repos/{repo_id}/tree/{ref_and_path:path}", |
| 236 | response_model=TreeListResponse, |
| 237 | summary="List a directory of a Muse repo at a given ref and optional sub-path", |
| 238 | ) |
| 239 | async def list_tree( |
| 240 | repo_id: str, |
| 241 | ref_and_path: str, |
| 242 | owner: SlugParam, |
| 243 | repo_slug: SlugParam, |
| 244 | db: AsyncSession = Depends(get_db), |
| 245 | claims: TokenClaims | None = Depends(optional_token), |
| 246 | ) -> TreeListResponse: |
| 247 | """Return a directory listing for the given ref and optional sub-path. |
| 248 | |
| 249 | Branch names may contain slashes (e.g. ``feat/my-thing``). This endpoint |
| 250 | accepts the full tail after ``/tree/`` as ``ref_and_path`` and resolves the |
| 251 | boundary between the ref and the sub-path by fetching all known branch names |
| 252 | and selecting the longest prefix match — the same strategy as the UI route. |
| 253 | |
| 254 | Query params ``owner`` and ``repo_slug`` are passed through for breadcrumb |
| 255 | generation; ``repo_id`` is the authoritative key. |
| 256 | |
| 257 | Returns entries sorted directories-first, then files, both alphabetically. |
| 258 | """ |
| 259 | repo = await musehub_repository.get_repo(db, repo_id) |
| 260 | if repo is None: |
| 261 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found") |
| 262 | if repo.visibility != "public" and claims is None: |
| 263 | raise HTTPException( |
| 264 | status_code=status.HTTP_401_UNAUTHORIZED, |
| 265 | detail="Authentication required to access private repos.", |
| 266 | headers={"WWW-Authenticate": 'MSign realm="musehub"'}, |
| 267 | ) |
| 268 | |
| 269 | # Disambiguate ref vs sub-path using longest branch-name prefix match. |
| 270 | branches = await musehub_repository.list_branches(db, repo_id) |
| 271 | branch_names: list[str] = sorted( |
| 272 | (b.name for b in branches), key=len, reverse=True |
| 273 | ) |
| 274 | ref = ref_and_path |
| 275 | dir_path = "" |
| 276 | for name in branch_names: |
| 277 | if ref_and_path == name: |
| 278 | ref = name |
| 279 | dir_path = "" |
| 280 | break |
| 281 | if ref_and_path.startswith(f"{name}/"): |
| 282 | ref = name |
| 283 | dir_path = ref_and_path[len(name) + 1:] |
| 284 | break |
| 285 | |
| 286 | ref_valid = await musehub_repository.resolve_ref_for_tree(db, repo_id, ref) |
| 287 | if not ref_valid: |
| 288 | raise HTTPException( |
| 289 | status_code=status.HTTP_404_NOT_FOUND, |
| 290 | detail=f"Ref '{ref}' not found in repo", |
| 291 | ) |
| 292 | resolved_ref = ( |
| 293 | await musehub_repository.resolve_head_ref(db, repo_id) if ref == "HEAD" else ref |
| 294 | ) |
| 295 | return await musehub_repository.list_tree(db, repo_id, owner, repo_slug, resolved_ref, dir_path) |
File History
1 commit
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109
docs(mwp-1): mark Phase 5 complete, all acceptance criteria…
Sonnet 4.6
25 days ago