Source code for md2star.api

"""
md2star — FastAPI HTTP surface.

Exposes the md2star Markdown → DOCX / PPTX / PDF bridge as HTTP endpoints so it
can sit behind any reverse proxy and be consumed by other services. Kept
intentionally aligned with the rest of the toolkit's suite (``os_helper.api`` /
``vocal_helper.api`` / …): a liveness probe, a JSON environment diagnostic, and
a multipart **action** that uploads Markdown and streams back the compiled
document.

Exposed surface:

- ``GET  /`` — redirect to the browser bench at ``/gui``.
- ``GET  /gui`` — a minimal single-page conversion bench (drop a ``.md``, pick a
  format, download the result). The self-contained HTML lives in
  :mod:`md2star.gui`; the page POSTs to ``/convert`` and adds no server logic.
  (For the full Overleaf-style editor with a live PDF preview, run
  ``md2star gui`` — that is :mod:`md2star.gui_server`, a separate stdlib server.)
- ``GET  /health`` — liveness probe.
- ``GET  /doctor`` — the same environment diagnostic as ``md2star doctor
  --json`` (which tools are present, per-format feature status).
- ``POST /convert`` — upload a ``.md`` file, pick a target format
  (``docx`` / ``pptx`` / ``pdf``), and stream back the rendered document.
- ``POST /extract`` — the reverse direction: upload a ``.docx`` / ``.pptx`` /
  ``.pdf`` and get its Markdown back as JSON, or pass ``twin=true`` (optionally
  ``diagrams=true``) to receive a zip of ``<stem>.md`` + an ``assets/`` folder —
  a self-contained, re-renderable Markdown twin. Needs the optional ``[ocr]``
  extra (Kreuzberg); returns 503 with an install hint when it is absent.

Install the extra to get the runtime dependencies::

    pip install 'md2star[api]'

Then run the app with any ASGI server::

    uvicorn md2star.api:app --host 0.0.0.0 --port 8000
    # or: md2star-api      (see [project.scripts])

Note that conversion still requires Pandoc on the host (and LibreOffice for PDF);
``GET /doctor`` reports what is available, and ``POST /convert`` returns HTTP 503
when a required system tool is missing.

Usage Example
-------------
>>> # Start the server:
>>> #   uvicorn md2star.api:app --reload
>>> # Convert Markdown to DOCX:
>>> #   curl -F 'file=@notes.md' 'http://localhost:8000/convert?fmt=docx' -o notes.docx
>>> # Environment diagnostic:
>>> #   curl -s http://localhost:8000/doctor
>>> # Full OpenAPI docs at http://localhost:8000/docs

Author
------
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
"""

from __future__ import annotations

import os
import shutil
import zipfile
from pathlib import Path

import os_helper as osh

# FastAPI is an OPTIONAL dependency (the ``[api]`` extra). Importing this
# module without it should fail with an actionable install hint rather than a
# bare ModuleNotFoundError, so we re-raise with the exact pip command.
try:
    from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Query, UploadFile
    from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
except ImportError as exc:  # pragma: no cover
    raise ImportError(
        "The FastAPI HTTP surface requires the [api] extra. "
        "Install with: pip install 'md2star[api]'"
    ) from exc

from . import __version__
from .cli import _convert
from .doctor import run_checks
from .errors import (
    InvalidInputError,
    Md2starError,
    MissingDependencyError,
)

app = FastAPI(
    title="md2star",
    version=__version__,
    description=(
        "Markdown → DOCX / PPTX / PDF via Pandoc, with curated styling. "
        "HTTP surface: health, environment doctor, and a convert action."
    ),
)

# Target formats and the MIME type each compiled document is streamed with.
_FORMAT_MEDIA: dict[str, str] = {
    "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
    "pdf": "application/pdf",
}


@app.get("/", include_in_schema=False)
def index() -> RedirectResponse:
    """Redirect the site root to the browser bench.

    Returns
    -------
    fastapi.responses.RedirectResponse
        A 307 redirect to ``/gui`` so opening the server root lands on the GUI.
    """
    return RedirectResponse(url="/gui")


@app.get("/gui", response_class=HTMLResponse, tags=["meta"])
def gui() -> HTMLResponse:
    """Serve the minimal single-page conversion bench.

    The page (defined in :mod:`md2star.gui`) is a build-step-free, self-contained
    HTML document: drop a Markdown file, pick a format, and it POSTs to the same
    ``/convert`` endpoint as the CLI and MCP surfaces. For the full editor with a
    live PDF preview, run ``md2star gui`` instead.

    Returns
    -------
    fastapi.responses.HTMLResponse
        The conversion-bench page.
    """
    # Imported lazily so the (long) HTML string is only loaded when the route is
    # actually hit, and so importing md2star.api stays cheap.
    from .gui import GUI_HTML

    return HTMLResponse(content=GUI_HTML)


@app.get("/health")
def health() -> dict[str, str]:
    """Liveness probe.

    Returns
    -------
    dict of str
        ``{"status": "ok"}`` when the service is up.
    """
    return {"status": "ok"}


@app.get("/doctor")
def doctor() -> dict:
    """Report the environment md2star runs in (tools present, feature status).

    Returns
    -------
    dict
        The same payload as ``md2star doctor --json``: a list of ``checks``, a
        per-format ``features`` map, and a ``core_failing`` flag.
    """
    # Reuse the exact same check engine as the CLI's ``doctor`` so the HTTP and
    # terminal diagnostics never drift apart; we only reshape it into JSON.
    report = run_checks()
    return {
        # Flatten each Check dataclass into a plain dict for the JSON response.
        "checks": [
            {"name": c.name, "status": c.status, "detail": c.detail, "section": c.section}
            for c in report.checks
        ],
        # Per-format readiness (can we actually produce docx/pptx/pdf/mermaid?).
        "features": {
            fmt: report.feature_status(fmt) for fmt in ("docx", "pptx", "pdf", "mermaid")
        },
        # True when a core tool is missing — a quick top-level red/green flag.
        "core_failing": report.core_failing(),
        # Reverse direction (DOCX/PPTX/PDF → Markdown) needs the optional [ocr]
        # extra; report it so a client can show/hide the /extract action.
        "reverse_available": _reverse_available(),
    }


def _reverse_available() -> bool:
    """Return whether the optional reverse-conversion engine is importable."""
    from .reverse import reverse_available

    return reverse_available()


@app.post("/convert", response_model=None)
async def convert(
    background: BackgroundTasks,
    file: UploadFile = File(..., description="The input Markdown (.md) file."),
    fmt: str = Query("docx", description="Target format: docx, pptx or pdf."),
    author: str | None = Query(None, description="Document author (Pandoc metadata)."),
    lang: str | None = Query(None, description="Document language (BCP 47, e.g. en-US)."),
    date: str | None = Query(None, description="Override the auto-generated date string."),
) -> FileResponse:
    """Convert an uploaded Markdown file into a rendered document and stream it.

    Parameters
    ----------
    background : fastapi.BackgroundTasks
        Used to delete the temp working directory after the response streams.
    file : fastapi.UploadFile
        The input Markdown document.
    fmt : str, optional
        Target format — one of ``docx`` / ``pptx`` / ``pdf``. Defaults to ``docx``.
    author, lang, date : str or None, optional
        Optional Pandoc metadata forwarded to the converter when provided.

    Returns
    -------
    fastapi.responses.FileResponse
        The compiled document, streamed with the format's MIME type.

    Raises
    ------
    fastapi.HTTPException
        400 for an unknown format or invalid Markdown input, 503 when a required
        system tool (Pandoc / LibreOffice) is missing, 500 on any other
        conversion failure.
    """
    # Normalise + validate the target format up front so a typo fails fast with
    # a 400 rather than deep inside the converter.
    fmt = fmt.lower()
    if fmt not in _FORMAT_MEDIA:
        raise HTTPException(
            status_code=400,
            detail=f"fmt must be one of {sorted(_FORMAT_MEDIA)}; got {fmt!r}",
        )

    # One temp dir per request holds the upload + the rendered output. We
    # register its cleanup as a background task so it runs AFTER FileResponse
    # has finished streaming — deleting it earlier would truncate the download.
    work = osh.make_temporary_directory(prefix="md2star_api_")
    background.add_task(shutil.rmtree, work, ignore_errors=True)

    # md2star's converter is path-in / path-out; stage the upload on disk and
    # give it an explicit output path so we know exactly what to stream back.
    # Derive a safe stem from the upload name (falling back to "document" when
    # the client sends no/empty filename) and reuse it for both the staged
    # input and the output, so the download keeps a sensible name.
    stem = Path(file.filename or "document").stem or "document"
    in_path = os.path.join(work, f"{stem}.md")
    out_path = os.path.join(work, f"{stem}.{fmt}")
    with open(in_path, "wb") as f:
        f.write(await file.read())

    # Translate the optional query params into the same CLI flags the converter
    # already understands — only appending a flag when the caller supplied it.
    argv = [in_path, "-o", out_path]
    for flag, value in (("--author", author), ("--lang", lang), ("--date", date)):
        if value:
            argv += [flag, value]

    try:
        _convert(fmt, argv)
    except MissingDependencyError as exc:
        # A required system tool (Pandoc / LibreOffice) is absent — this is an
        # environment problem, not a bad request; 503 tells the caller to retry
        # against a properly provisioned host.
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    except InvalidInputError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except Md2starError as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc

    if not os.path.exists(out_path):  # pragma: no cover — defensive
        raise HTTPException(status_code=500, detail="conversion produced no output file")

    return FileResponse(
        out_path, media_type=_FORMAT_MEDIA[fmt], filename=os.path.basename(out_path)
    )


@app.post("/extract", response_model=None)
async def extract(
    background: BackgroundTasks,
    file: UploadFile = File(..., description="A .docx, .pptx or .pdf document."),
    twin: bool = Form(
        False,
        description="Recover an editable twin (prose + tables + scraped images) "
        "and return a zip of <stem>.md + assets/ instead of plain JSON.",
    ),
    diagrams: bool = Form(
        False,
        description="With twin=true, re-author node-and-edge figures as Mermaid "
        "via the local AI (needs the [ai] stack + Ollama); implies twin.",
    ),
) -> dict[str, str] | FileResponse:
    """Read an uploaded DOCX/PPTX/PDF back into Markdown (the reverse direction).

    Two modes:

    * **text-only** (default) — delegate to :func:`md2star.reverse.to_markdown`
      (Kreuzberg) and return ``{"filename": <stem>.md, "markdown": <text>}``.
    * **twin** (``twin=true``, or ``diagrams=true`` which implies it) — delegate
      to :func:`md2star.reverse.to_markdown_twin`, which writes ``<stem>.md`` plus
      an ``assets/`` folder; the response is a **zip** of both so the caller
      receives a self-contained, re-renderable Markdown source. With
      ``diagrams=true`` and the ``[ai]`` stack present, node-and-edge figures are
      re-authored as Mermaid; otherwise every image degrades to a scraped PNG.

    Parameters
    ----------
    background : fastapi.BackgroundTasks
        Deletes the temp working directory after the request completes.
    file : fastapi.UploadFile
        The document to read back; extension must be docx / pptx / pdf.
    twin : bool, default False
        Return the full editable twin (zip) rather than text-only JSON.
    diagrams : bool, default False
        Reconstruct diagrams as Mermaid during a twin extraction (implies twin).

    Returns
    -------
    dict or fastapi.responses.FileResponse
        Text-only mode: ``{"filename": <stem>.md, "markdown": <text>}``.
        Twin mode: a ``application/zip`` file response (``<stem>.zip``).

    Raises
    ------
    fastapi.HTTPException
        400 for an unsupported extension, 503 when the optional ``[ocr]`` extra
        (Kreuzberg) is not installed, 500 on any extraction failure.
    """
    from .reverse import ReverseUnavailable, is_supported, to_markdown, to_markdown_twin

    stem = Path(file.filename or "document").stem or "document"
    suffix = Path(file.filename or "").suffix.lower()
    # Validate the extension up front so an unsupported upload fails fast with a
    # 400 instead of reaching the engine.
    if not is_supported(file.filename or ""):
        raise HTTPException(
            status_code=400,
            detail=f"unsupported input {suffix!r}; expected .docx, .pptx or .pdf",
        )

    # Stage the upload on disk (Kreuzberg is path-in) and clean up after streaming.
    work = osh.make_temporary_directory(prefix="md2star_api_")
    background.add_task(shutil.rmtree, work, ignore_errors=True)
    in_path = os.path.join(work, f"{stem}{suffix}")
    with open(in_path, "wb") as f:
        f.write(await file.read())

    # diagrams can't happen without keeping the images, so it implies twin.
    want_twin = twin or diagrams

    try:
        if not want_twin:
            markdown = to_markdown(in_path)
            return {"filename": f"{stem}.md", "markdown": markdown}

        # Only build the AI handler when diagrams are requested AND the stack is
        # live; otherwise reconstruction degrades to plain scraped PNGs.
        handler = None
        if diagrams:
            from .reverse_diagrams import diagrams_available, make_diagram_handler

            if diagrams_available(None):
                handler = make_diagram_handler()

        out_dir = os.path.join(work, "twin")
        md_path = to_markdown_twin(in_path, out_dir, image_handler=handler)
    except ReverseUnavailable as exc:
        # Optional feature not installed — an environment problem, so 503 tells
        # the caller to provision `pip install 'md2star[ocr]'` and retry.
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    except (ValueError, FileNotFoundError) as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except RuntimeError as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc

    # Bundle <stem>.md + assets/ into one zip so the twin travels as a unit.
    zip_path = os.path.join(work, f"{stem}.zip")
    assets_dir = Path(out_dir) / "assets"
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
        zf.write(md_path, arcname=md_path.name)
        if assets_dir.is_dir():
            # Preserve the assets/ prefix so the archived links keep resolving.
            for asset in sorted(assets_dir.rglob("*")):
                if asset.is_file():
                    zf.write(asset, arcname=str(Path("assets") / asset.relative_to(assets_dir)))

    return FileResponse(zip_path, media_type="application/zip", filename=f"{stem}.zip")


[docs] def main() -> None: """Entry point for the ``md2star-api`` console script. Boots the FastAPI app with ``uvicorn`` in single-worker mode. Meant for local / container usage; behind a real load balancer, run ``uvicorn`` / ``gunicorn`` directly against :data:`md2star.api.app`. """ import uvicorn # Host/port are env-overridable so the same image runs unchanged across # environments (0.0.0.0 to be reachable from outside a container). host = os.environ.get("MD2STAR_HOST", "0.0.0.0") port = int(os.environ.get("MD2STAR_PORT", "8000")) # Single worker: conversions shell out to Pandoc/LibreOffice, so scale by # running several processes behind a load balancer, not in-process workers. uvicorn.run(app, host=host, port=port, workers=1)
if __name__ == "__main__": # pragma: no cover main()