2026-02-09 15:49:50 +05:30
|
|
|
"""API routes for searching and fetching souls-directory markdown entries."""
|
|
|
|
|
|
2026-02-08 00:46:10 +05:30
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
|
|
|
|
2026-03-03 21:41:56 -07:00
|
|
|
from app.api.deps import ActorContext, require_user_or_agent
|
2026-02-08 00:46:10 +05:30
|
|
|
from app.schemas.souls_directory import (
|
|
|
|
|
SoulsDirectoryMarkdownResponse,
|
|
|
|
|
SoulsDirectorySearchResponse,
|
|
|
|
|
SoulsDirectorySoulRef,
|
|
|
|
|
)
|
|
|
|
|
from app.services import souls_directory
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/souls-directory", tags=["souls-directory"])
|
2026-03-03 21:41:56 -07:00
|
|
|
USER_OR_AGENT_DEP = Depends(require_user_or_agent)
|
2026-02-08 00:46:10 +05:30
|
|
|
|
|
|
|
|
_SAFE_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
|
|
|
_SAFE_SLUG_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_segment(value: str, *, field: str) -> str:
|
|
|
|
|
cleaned = value.strip().strip("/")
|
|
|
|
|
if not cleaned:
|
|
|
|
|
raise HTTPException(
|
2026-02-15 16:06:06 +05:30
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
2026-02-08 00:46:10 +05:30
|
|
|
detail=f"{field} is required",
|
|
|
|
|
)
|
|
|
|
|
if field == "handle":
|
|
|
|
|
ok = bool(_SAFE_SEGMENT_RE.match(cleaned))
|
|
|
|
|
else:
|
|
|
|
|
ok = bool(_SAFE_SLUG_RE.match(cleaned))
|
|
|
|
|
if not ok:
|
|
|
|
|
raise HTTPException(
|
2026-02-15 16:06:06 +05:30
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
2026-02-08 00:46:10 +05:30
|
|
|
detail=f"{field} contains unsupported characters",
|
|
|
|
|
)
|
|
|
|
|
return cleaned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/search", response_model=SoulsDirectorySearchResponse)
|
|
|
|
|
async def search(
|
|
|
|
|
q: str = Query(default="", min_length=0),
|
|
|
|
|
limit: int = Query(default=20, ge=1, le=100),
|
2026-03-03 21:41:56 -07:00
|
|
|
_actor: ActorContext = USER_OR_AGENT_DEP,
|
2026-02-08 00:46:10 +05:30
|
|
|
) -> SoulsDirectorySearchResponse:
|
2026-02-09 15:49:50 +05:30
|
|
|
"""Search souls-directory entries by handle/slug query text."""
|
2026-02-08 00:46:10 +05:30
|
|
|
refs = await souls_directory.list_souls_directory_refs()
|
|
|
|
|
matches = souls_directory.search_souls(refs, query=q, limit=limit)
|
|
|
|
|
items = [
|
|
|
|
|
SoulsDirectorySoulRef(
|
|
|
|
|
handle=ref.handle,
|
|
|
|
|
slug=ref.slug,
|
|
|
|
|
page_url=ref.page_url,
|
|
|
|
|
raw_md_url=ref.raw_md_url,
|
|
|
|
|
)
|
|
|
|
|
for ref in matches
|
|
|
|
|
]
|
|
|
|
|
return SoulsDirectorySearchResponse(items=items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{handle}/{slug}.md", response_model=SoulsDirectoryMarkdownResponse)
|
|
|
|
|
@router.get("/{handle}/{slug}", response_model=SoulsDirectoryMarkdownResponse)
|
|
|
|
|
async def get_markdown(
|
|
|
|
|
handle: str,
|
|
|
|
|
slug: str,
|
2026-03-03 21:41:56 -07:00
|
|
|
_actor: ActorContext = USER_OR_AGENT_DEP,
|
2026-02-08 00:46:10 +05:30
|
|
|
) -> SoulsDirectoryMarkdownResponse:
|
2026-02-09 15:49:50 +05:30
|
|
|
"""Fetch markdown content for a validated souls-directory handle and slug."""
|
2026-02-08 00:46:10 +05:30
|
|
|
safe_handle = _validate_segment(handle, field="handle")
|
|
|
|
|
safe_slug = _validate_segment(slug.removesuffix(".md"), field="slug")
|
|
|
|
|
try:
|
2026-02-09 15:49:50 +05:30
|
|
|
content = await souls_directory.fetch_soul_markdown(
|
|
|
|
|
handle=safe_handle,
|
|
|
|
|
slug=safe_slug,
|
|
|
|
|
)
|
2026-02-08 00:46:10 +05:30
|
|
|
except Exception as exc:
|
2026-02-09 15:49:50 +05:30
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
|
|
|
detail=str(exc),
|
|
|
|
|
) from exc
|
|
|
|
|
return SoulsDirectoryMarkdownResponse(
|
|
|
|
|
handle=safe_handle,
|
|
|
|
|
slug=safe_slug,
|
|
|
|
|
content=content,
|
|
|
|
|
)
|