850 lines
34 KiB
Python
850 lines
34 KiB
Python
"""Generate and post-process the omnigent OpenAPI 3.2 document.
|
|
|
|
The omnigent server runs on FastAPI 0.135.x, which emits OpenAPI
|
|
3.1. OpenAPI 3.2 (released September 2025) introduced first-class
|
|
support for sequential media types — specifically, the
|
|
``itemSchema`` keyword for describing each item in a streaming
|
|
response on a ``text/event-stream`` content entry. We need 3.2's
|
|
``itemSchema`` so the SSE routes describe their per-event shape
|
|
correctly to consuming SDK / docs tooling.
|
|
|
|
This script:
|
|
|
|
1. Imports :func:`omnigent.server.app.create_app` and instantiates
|
|
it against in-memory store stubs (no DB needed).
|
|
2. Calls ``app.openapi()`` to get the FastAPI-generated 3.1 dict.
|
|
3. Bumps the top-level ``openapi`` field to ``"3.2.0"``.
|
|
4. Materializes the :data:`ServerStreamEvent` discriminated union as
|
|
a top-level entry under ``components.schemas`` so SSE responses
|
|
can ``$ref`` it.
|
|
5. Rewrites the ``text/event-stream`` content entries on the SSE
|
|
routes to use the OAS 3.2 ``itemSchema`` keyword in place of
|
|
FastAPI's 3.1 ``schema`` keyword.
|
|
6. Writes the result to ``openapi.json`` at the repo root.
|
|
|
|
Run with no arguments to (re)generate the file. Pass ``--check``
|
|
in CI to verify the on-disk file is up to date — non-zero exit
|
|
means a developer changed the spec without regenerating.
|
|
|
|
Usage::
|
|
|
|
python scripts/dump_openapi.py # write openapi.json
|
|
python scripts/dump_openapi.py --check # exit 1 if drifted
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# DBOS's ``compute_app_version`` calls ``hashlib.md5()`` without
|
|
# ``usedforsecurity=False`` for a non-security content hash, which
|
|
# raises ``ValueError`` on FIPS-enabled hosts. Patch md5 here BEFORE
|
|
# any DBOS import so both this script and the drift test
|
|
# (``tests/server/test_openapi_drift.py``, which imports
|
|
# ``generate_spec`` from this module) work on FIPS hosts. The flag is
|
|
# the standard Python 3.9+ way to opt non-security md5 calls out of
|
|
# the FIPS gate; on non-FIPS hosts it's a harmless no-op.
|
|
_orig_md5 = hashlib.md5
|
|
|
|
|
|
def _fips_safe_md5(*args: Any, **kwargs: Any) -> Any:
|
|
kwargs.setdefault("usedforsecurity", False)
|
|
return _orig_md5(*args, **kwargs)
|
|
|
|
|
|
hashlib.md5 = _fips_safe_md5
|
|
|
|
from pydantic import TypeAdapter # noqa: E402 — must follow md5 patch
|
|
|
|
# ── Module-level constants (rule 34) ──────────────────────────────
|
|
|
|
# Output path. The spec lives at the repo root so external tooling
|
|
# (Stoplight, openapi-generator, redocly, …) can pick it up via a
|
|
# stable URL relative to the project. Pinned absolute via
|
|
# ``Path(__file__).resolve().parent.parent`` so the script works
|
|
# regardless of CWD.
|
|
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
|
_OPENAPI_OUT: Path = _REPO_ROOT / "openapi.json"
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
# OpenAPI 3.2.0 release: 2025-09-23. We pin the patch version so
|
|
# the post-processed doc declares its target spec unambiguously.
|
|
_OPENAPI_VERSION: str = "3.2.0"
|
|
|
|
# Routes that emit Server-Sent Events. Each tuple is
|
|
# ``(path, method)`` keyed exactly as the OpenAPI ``paths`` map
|
|
# stores them. If the route inventory grows (e.g. a new SSE
|
|
# endpoint), add the entry here so post-processing rewrites it.
|
|
_SSE_ROUTES: list[tuple[str, str]] = [
|
|
("/v1/responses", "post"),
|
|
("/v1/sessions/{session_id}/stream", "get"),
|
|
]
|
|
|
|
# ── Document-level enrichment ─────────────────────────────────────
|
|
#
|
|
# FastAPI emits accurate per-operation schemas but none of the
|
|
# document-level metadata an integrator needs: no ``servers``, no auth
|
|
# description, no ``info.description``, and only bare snake_case tags.
|
|
# We inject that connective tissue here so the published reference
|
|
# (rendered by Scalar on the omnigent website) is usable for building
|
|
# an integration. Keeping it in this script — rather than scattering it
|
|
# across the route decorators — confines presentation concerns to the
|
|
# spec-generation layer, and the drift test
|
|
# (``tests/server/test_openapi_drift.py``) guards the result.
|
|
|
|
# Self-hosted base URL. ``omnigent server`` binds 127.0.0.1:6767 by
|
|
# default (see ``_DEFAULT_LOCAL_PORT`` in
|
|
# ``omnigent/host/local_server.py``).
|
|
_SERVERS: list[dict[str, str]] = [
|
|
{
|
|
"url": "http://127.0.0.1:6767",
|
|
"description": "Self-hosted Omnigent server (default local port).",
|
|
},
|
|
]
|
|
|
|
# Markdown prose shown at the top of the rendered reference. Covers
|
|
# what the API is, the self-hosted base URL, and the deployment-driven
|
|
# auth model (there is no bearer/API-key scheme — see
|
|
# ``omnigent/server/auth.py``).
|
|
_INFO_DESCRIPTION: str = """\
|
|
Omnigent is an open-source meta-harness for building and running AI \
|
|
agents. This is the REST API exposed by the Omnigent server: use it to \
|
|
create and drive **sessions**, manage **agents**, **hosts**, and \
|
|
**runners**, attach **contextual policies**, post **comments**, and work \
|
|
with session **resources** — files, terminals, and sandboxed \
|
|
environments.
|
|
|
|
## Base URL
|
|
|
|
Omnigent is self-hosted. The server binds `http://127.0.0.1:6767` by \
|
|
default (`omnigent server`); point the base URL at your own deployment.
|
|
|
|
## Authentication
|
|
|
|
There is no API-key or bearer-token scheme. Identity is supplied by the \
|
|
deployment's configured auth provider (`OMNIGENT_AUTH_PROVIDER`):
|
|
|
|
- **Trusted proxy header** (default) — an upstream proxy injects an \
|
|
identity header (`X-Forwarded-Email`, configurable). Single-user local \
|
|
runtimes fall back to a reserved `local` user.
|
|
- **Session cookie** — a signed session cookie minted after an \
|
|
interactive OIDC or accounts login. It is named `ap_session` over HTTP \
|
|
(the advertised local default) and `__Host-ap_session` under HTTPS, where \
|
|
the `__Host-` prefix guards against subdomain cookie-tossing.
|
|
|
|
Auth is configured server-side; clients send the cookie or proxy header \
|
|
according to your deployment.
|
|
|
|
## Streaming
|
|
|
|
`GET /v1/sessions/{session_id}/stream` streams Server-Sent Events \
|
|
(`text/event-stream`). Each event conforms to the `ServerStreamEvent` \
|
|
schema documented below.
|
|
"""
|
|
|
|
# Auth representations. Omnigent has no bearer/API-key scheme — identity
|
|
# arrives via a trusted-proxy header or a signed session cookie,
|
|
# selected by ``OMNIGENT_AUTH_PROVIDER``. We model both as OpenAPI
|
|
# ``apiKey`` schemes so SDK generators and the reference can surface
|
|
# them. We deliberately do NOT assert a top-level ``security``
|
|
# requirement: the active scheme is deployment-specific, and public
|
|
# endpoints (``/health``, ``/api/version``) require none — the prose in
|
|
# :data:`_INFO_DESCRIPTION` carries the human-facing explanation.
|
|
_SECURITY_SCHEMES: dict[str, dict[str, str]] = {
|
|
"proxyHeaderAuth": {
|
|
"type": "apiKey",
|
|
"in": "header",
|
|
"name": "X-Forwarded-Email",
|
|
"description": (
|
|
"Trusted-proxy identity header (header-auth mode, the "
|
|
"default). The header name is configurable via "
|
|
"``OMNIGENT_AUTH_HEADER``."
|
|
),
|
|
},
|
|
"sessionCookieAuth": {
|
|
"type": "apiKey",
|
|
"in": "cookie",
|
|
# Named to match the advertised HTTP server. The ``__Host-``
|
|
# prefix requires HTTPS (browsers drop it on plain HTTP), so the
|
|
# cookie is ``ap_session`` for the default local deployment and
|
|
# ``__Host-ap_session`` only under HTTPS — see ``secure_cookies``
|
|
# in ``accounts_config.py`` / ``oidc.py``.
|
|
"name": "ap_session",
|
|
"description": (
|
|
"Signed session cookie minted after an interactive OIDC or "
|
|
"accounts login (oidc / accounts auth modes). Named "
|
|
"``ap_session`` over HTTP (the advertised local default); "
|
|
"under HTTPS the secure ``__Host-ap_session`` prefixed form "
|
|
"is used instead."
|
|
),
|
|
},
|
|
}
|
|
|
|
# Tag display metadata: human descriptions + sidebar order. Each
|
|
# ``name`` MUST match the tag FastAPI puts on operations (the route
|
|
# decorators use these snake_case values). ``x-displayName`` gives docs
|
|
# tooling a readable label in place of the raw tag. Order here is the
|
|
# order tags render in the reference sidebar.
|
|
#
|
|
# This intentionally covers only the stub-build surface that
|
|
# ``generate_spec()`` emits: the ``terminals`` router is WebSocket-only
|
|
# (no HTTP operations in the spec) and the ``auth`` router is mounted
|
|
# only when an auth provider with a ``login_url`` is configured (absent
|
|
# in the stub build). If either ever surfaces HTTP operations here, add
|
|
# its tag below so the operation doesn't render without a description.
|
|
_TAGS: list[dict[str, str]] = [
|
|
{
|
|
"name": "sessions",
|
|
"x-displayName": "Sessions",
|
|
"description": (
|
|
"Create, inspect, fork, and drive agent sessions — the core "
|
|
"unit of work. Covers session items and events, agent "
|
|
"binding, permissions, labels, and child sessions. The "
|
|
"files, terminals, and sandboxed environments attached to a "
|
|
"session live under Session Resources."
|
|
),
|
|
},
|
|
{
|
|
"name": "session_resources",
|
|
"x-displayName": "Session Resources",
|
|
"description": (
|
|
"Files, terminals, and sandboxed environments attached to a "
|
|
"session: upload and read files, create and manage "
|
|
"terminals, and read, write, edit, and search the "
|
|
"environment filesystem."
|
|
),
|
|
},
|
|
{
|
|
"name": "session_mcp_servers",
|
|
"x-displayName": "Session MCP Servers",
|
|
"description": (
|
|
"Manage the MCP server declarations on a session's bound "
|
|
"agent: list the configured servers and create, update, and "
|
|
"remove them on session-scoped agents."
|
|
),
|
|
},
|
|
{
|
|
"name": "agents",
|
|
"x-displayName": "Agents",
|
|
"description": "Discover the built-in agents available to bind to a session.",
|
|
},
|
|
{
|
|
"name": "hosts",
|
|
"x-displayName": "Hosts",
|
|
"description": (
|
|
"Hosts that can launch runners. Browse the host filesystem and create directories."
|
|
),
|
|
},
|
|
{
|
|
"name": "runners",
|
|
"x-displayName": "Runners",
|
|
"description": "Launch runners on a host and check their status.",
|
|
},
|
|
{
|
|
"name": "session_policies",
|
|
"x-displayName": "Session Policies",
|
|
"description": (
|
|
"Contextual policies scoped to a single session — list, create, update, and remove."
|
|
),
|
|
},
|
|
{
|
|
"name": "default_policies",
|
|
"x-displayName": "Default Policies",
|
|
"description": "Server-level default policies applied to new sessions.",
|
|
},
|
|
{
|
|
"name": "policy_registry",
|
|
"x-displayName": "Policy Registry",
|
|
"description": "The catalog of policy types available to instantiate.",
|
|
},
|
|
{
|
|
"name": "comments",
|
|
"x-displayName": "Comments",
|
|
"description": (
|
|
"Threaded comments on a session, including sending a comment to the agent."
|
|
),
|
|
},
|
|
{
|
|
"name": "system",
|
|
"x-displayName": "System",
|
|
"description": "Health, version, and identity endpoints for the running server.",
|
|
},
|
|
]
|
|
|
|
# Utility endpoints FastAPI leaves untagged. We assign them a synthetic
|
|
# ``system`` tag so they group cleanly in the reference instead of
|
|
# floating in an unlabeled "default" bucket. Keyed ``(path, method)``
|
|
# like :data:`_SSE_ROUTES`; keep accurate if the route inventory grows.
|
|
_SYSTEM_ROUTES: list[tuple[str, str]] = [
|
|
("/health", "get"),
|
|
("/api/version", "get"),
|
|
("/v1/info", "get"),
|
|
("/v1/me", "get"),
|
|
]
|
|
|
|
# HTTP methods that denote an operation object inside a path item
|
|
# (everything else under a path — ``parameters``, ``servers``, … — is
|
|
# not an operation and must be skipped when retagging).
|
|
_HTTP_METHODS: frozenset[str] = frozenset(
|
|
{"get", "put", "post", "delete", "patch", "options", "head", "trace"},
|
|
)
|
|
|
|
# Path prefix whose operations form the dedicated "Session Resources"
|
|
# group. The sessions router is mounted with ``tags=["sessions"]`` in
|
|
# app.py, so every session route — including the resource subtree —
|
|
# inherits that single tag. We split this subtree (files, terminals,
|
|
# sandboxed environments) into its own section in the published
|
|
# reference rather than fracturing the router.
|
|
_SESSION_RESOURCES_PREFIX: str = "/v1/sessions/{session_id}/resources"
|
|
|
|
|
|
def _build_app_with_stub_stores() -> Any:
|
|
"""
|
|
Build a FastAPI app with stub stores sufficient for OpenAPI generation.
|
|
|
|
``app.openapi()`` walks the route table and Pydantic models — it
|
|
does not call any store methods. We use the SQLite-backed
|
|
implementations against an on-disk temporary database. The temp
|
|
file is best-effort cleaned up by the caller's filesystem.
|
|
|
|
:returns: A configured :class:`fastapi.FastAPI` app.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.server.app import create_app
|
|
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
|
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
|
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
|
from omnigent.stores.host_store import HostStore
|
|
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
|
|
|
|
# On-disk SQLite (mkdtemp ensures uniqueness so concurrent
|
|
# invocations don't collide).
|
|
workdir = Path(tempfile.mkdtemp(prefix="oa-openapi-"))
|
|
db_path = workdir / "spec.sqlite"
|
|
db_uri = f"sqlite:///{db_path}"
|
|
artifact_store = LocalArtifactStore(str(workdir / "artifacts"))
|
|
return create_app(
|
|
agent_store=SqlAlchemyAgentStore(db_uri),
|
|
file_store=SqlAlchemyFileStore(db_uri),
|
|
conversation_store=SqlAlchemyConversationStore(db_uri),
|
|
comment_store=SqlAlchemyCommentStore(db_uri),
|
|
artifact_store=artifact_store,
|
|
agent_cache=AgentCache(
|
|
artifact_store=artifact_store,
|
|
cache_dir=workdir / "cache",
|
|
),
|
|
# Pass stores so conditionally-mounted routes stay in the spec.
|
|
host_store=HostStore(db_uri),
|
|
policy_store=SqlAlchemyPolicyStore(db_uri),
|
|
)
|
|
|
|
|
|
def _server_stream_event_schema() -> dict[str, Any]:
|
|
"""
|
|
Return the JSON-Schema dict for the ``ServerStreamEvent`` union.
|
|
|
|
Pydantic's ``TypeAdapter.json_schema(ref_template=...)`` emits a
|
|
schema with internal ``$ref`` pointers in OpenAPI's expected
|
|
``#/components/schemas/<name>`` form. We then split out the
|
|
union-root schema and inline the variant definitions into the
|
|
components map so each per-event class appears as a top-level
|
|
component schema.
|
|
|
|
:returns: A dict with two keys:
|
|
|
|
* ``"root"`` — the discriminated-union schema (the value
|
|
assigned to ``components.schemas.ServerStreamEvent``).
|
|
* ``"definitions"`` — the per-variant component schemas
|
|
(merged into ``components.schemas``).
|
|
"""
|
|
from omnigent.server.schemas import ServerStreamEvent
|
|
|
|
adapter: TypeAdapter[ServerStreamEvent] = TypeAdapter(ServerStreamEvent)
|
|
schema = adapter.json_schema(ref_template="#/components/schemas/{model}")
|
|
# Pydantic returns ``{"oneOf": [...], "discriminator": {...},
|
|
# "$defs": {...}}``. We hoist ``$defs`` to top-level component
|
|
# schemas and keep the rest as the union root.
|
|
definitions = schema.pop("$defs", {})
|
|
return {"root": schema, "definitions": definitions}
|
|
|
|
|
|
def _rewrite_sse_route(
|
|
paths: dict[str, Any],
|
|
path: str,
|
|
method: str,
|
|
) -> None:
|
|
"""
|
|
Rewrite one SSE route's ``text/event-stream`` content for OAS 3.2.
|
|
|
|
FastAPI emits ``content: {text/event-stream: {schema: <ref>}}``;
|
|
OAS 3.2 uses ``itemSchema`` for sequential media types so each
|
|
event in the stream is described as one item. We rename the key.
|
|
|
|
No-op if the route doesn't exist (e.g. a renamed endpoint that
|
|
fell off the inventory) — the caller's job is to keep
|
|
:data:`_SSE_ROUTES` accurate.
|
|
|
|
:param paths: The OpenAPI ``paths`` map; mutated in place.
|
|
:param path: Route path, e.g. ``"/v1/responses"``.
|
|
:param method: HTTP method (lowercase), e.g. ``"post"``.
|
|
"""
|
|
op = paths.get(path, {}).get(method)
|
|
if op is None:
|
|
return
|
|
ok_response = op.get("responses", {}).get("200", {})
|
|
content = ok_response.get("content", {})
|
|
sse_entry = content.get("text/event-stream")
|
|
if sse_entry is None:
|
|
return
|
|
# Rename ``schema`` → ``itemSchema``. The value (a ``$ref``) is
|
|
# untouched because the union schema applies to each event
|
|
# equally — itemSchema is "validate this against every item
|
|
# in the stream" per the 3.2 spec.
|
|
if "schema" in sse_entry:
|
|
sse_entry["itemSchema"] = sse_entry.pop("schema")
|
|
|
|
|
|
def _tag_system_routes(paths: dict[str, Any]) -> None:
|
|
"""
|
|
Assign the synthetic ``system`` tag to untagged utility routes.
|
|
|
|
FastAPI leaves ``/health``, ``/api/version``, ``/v1/info``, and
|
|
``/v1/me`` untagged. Without a tag they render in an unlabeled
|
|
"default" bucket in the reference; tagging them groups the lot
|
|
under "System". Only fills in a tag where none exists — never
|
|
overrides one FastAPI already set.
|
|
|
|
No-op for any ``(path, method)`` not present, so
|
|
:data:`_SYSTEM_ROUTES` stays resilient to inventory changes.
|
|
|
|
:param paths: The OpenAPI ``paths`` map; mutated in place.
|
|
"""
|
|
for path, method in _SYSTEM_ROUTES:
|
|
op = paths.get(path, {}).get(method)
|
|
if op is None:
|
|
continue
|
|
if not op.get("tags"):
|
|
op["tags"] = ["system"]
|
|
|
|
|
|
def _retag_session_resources(paths: dict[str, Any]) -> None:
|
|
"""
|
|
Move the session-resource subtree into its own ``session_resources`` tag.
|
|
|
|
Every operation whose path starts with
|
|
:data:`_SESSION_RESOURCES_PREFIX` has its tag list *replaced* (not
|
|
appended) with ``["session_resources"]`` so it renders as a
|
|
dedicated section instead of inheriting the broad ``sessions`` tag.
|
|
Prefix-based so newly added resource endpoints group automatically.
|
|
|
|
:param paths: The OpenAPI ``paths`` map; mutated in place.
|
|
"""
|
|
for path, methods in paths.items():
|
|
if not path.startswith(_SESSION_RESOURCES_PREFIX):
|
|
continue
|
|
for method, op in methods.items():
|
|
if method in _HTTP_METHODS and isinstance(op, dict):
|
|
op["tags"] = ["session_resources"]
|
|
|
|
|
|
# ── reStructuredText docstring → Markdown ─────────────────────────
|
|
#
|
|
# FastAPI uses each route handler's docstring verbatim as the OpenAPI
|
|
# operation ``description``. Our docstrings are Sphinx/reST: ``:param
|
|
# name:`` / ``:returns:`` / ``:raises Exc:`` field lists and inline
|
|
# ``:class:`Foo``` cross-reference roles. Docs renderers (Scalar) treat
|
|
# the description as Markdown, so reST field lists collapse into one
|
|
# unreadable run of literal text. We convert that markup to Markdown:
|
|
#
|
|
# * each ``:param name:`` whose name matches a real query/path
|
|
# parameter is moved onto that parameter's ``description`` (so it
|
|
# renders inline in the parameter table, not in the prose blob);
|
|
# * request-body / form ``:param`` entries that have no matching
|
|
# parameter become a Markdown ``**Parameters**`` bullet list;
|
|
# * ``:returns:`` becomes a ``**Returns:**`` line, ``:raises:`` a
|
|
# ``**Raises**`` bullet list;
|
|
# * framework-internal params (``request``/``response``/…) are dropped;
|
|
# * inline ``:role:`X``` roles collapse to `` `X` `` and reST double
|
|
# backticks (`` ``X`` ``) normalize to Markdown single backticks.
|
|
|
|
# Field-list line markers (matched at column 0; continuation lines are
|
|
# indented and accumulate onto the field opened above them).
|
|
_RST_PARAM = re.compile(r"^:(?:param|parameter|arg|argument|keyword|kwarg)\s+(\S+)\s*:\s*(.*)$")
|
|
_RST_RETURNS = re.compile(r"^:returns?\s*:\s*(.*)$")
|
|
_RST_RAISES = re.compile(r"^:raises?\s+([^:]+?)\s*:\s*(.*)$")
|
|
# Any other reST field marker (``:rtype:``, ``:type x:``, …) — dropped.
|
|
_RST_OTHER_FIELD = re.compile(r"^:[a-zA-Z][\w ]*:")
|
|
# Inline cross-reference role, e.g. ``:class:`Foo``` → `` `Foo` ``.
|
|
_RST_ROLE = re.compile(r":[a-zA-Z]+:`([^`]+)`")
|
|
# reST inline literal (double backtick) → Markdown code span (single).
|
|
# Non-greedy + DOTALL so a literal may span lines and contain nested
|
|
# single backticks (e.g. a role left inside it); the replacement flattens
|
|
# those so the resulting code span is valid.
|
|
_RST_DOUBLE_BACKTICK = re.compile(r"``(.+?)``", re.DOTALL)
|
|
|
|
# Handler parameters that are FastAPI plumbing, not API inputs.
|
|
_INTERNAL_PARAMS = frozenset(
|
|
{"request", "response", "websocket", "ws", "background_tasks", "bg", "_", "args", "kwargs"},
|
|
)
|
|
|
|
|
|
def _rst_double_backtick_to_code(match: re.Match[str]) -> str:
|
|
"""Flatten a reST ``literal`` into a single-line Markdown code span."""
|
|
inner = re.sub(r"\s+", " ", match.group(1).replace("`", "")).strip()
|
|
return f"`{inner}`"
|
|
|
|
|
|
def _rst_inline_to_md(text: str) -> str:
|
|
"""Convert inline reST roles / literals in *text* to Markdown."""
|
|
text = _RST_ROLE.sub(r"`\1`", text)
|
|
return _RST_DOUBLE_BACKTICK.sub(_rst_double_backtick_to_code, text)
|
|
|
|
|
|
def _rst_field_text(lines: list[str]) -> str:
|
|
"""Join a field's (possibly multi-line) body into one Markdown string."""
|
|
joined = re.sub(r"\s+", " ", " ".join(lines)).strip()
|
|
return _rst_inline_to_md(joined)
|
|
|
|
|
|
def _parse_rst_doc(desc: str) -> tuple[str, list[tuple[str, str | None, str]]]:
|
|
"""
|
|
Split a reST docstring into Markdown prose and parsed fields.
|
|
|
|
Lines before the first reST field marker are prose; ``:param:`` /
|
|
``:returns:`` / ``:raises:`` open a field that subsequent indented
|
|
continuation lines accumulate onto. Unknown field markers (e.g.
|
|
``:rtype:``) are discarded.
|
|
|
|
:param desc: The raw (reST) description text.
|
|
:returns: ``(prose_markdown, fields)`` where ``fields`` is a list of
|
|
``(kind, name, text)`` triples (``kind`` in ``param`` /
|
|
``returns`` / ``raises``) with ``text`` already Markdown.
|
|
"""
|
|
prose: list[str] = []
|
|
fields: list[tuple[str, str | None, list[str]]] = []
|
|
cur: tuple[str, str | None, list[str]] | None = None
|
|
in_fields = False
|
|
for line in desc.split("\n"):
|
|
param_m = _RST_PARAM.match(line)
|
|
if param_m:
|
|
in_fields = True
|
|
cur = ("param", param_m.group(1).strip().lstrip("*"), [param_m.group(2)])
|
|
fields.append(cur)
|
|
continue
|
|
returns_m = _RST_RETURNS.match(line)
|
|
if returns_m:
|
|
in_fields = True
|
|
cur = ("returns", None, [returns_m.group(1)])
|
|
fields.append(cur)
|
|
continue
|
|
raises_m = _RST_RAISES.match(line)
|
|
if raises_m:
|
|
in_fields = True
|
|
cur = ("raises", raises_m.group(1).strip(), [raises_m.group(2)])
|
|
fields.append(cur)
|
|
continue
|
|
if in_fields and _RST_OTHER_FIELD.match(line):
|
|
cur = ("drop", None, []) # unknown field (e.g. :rtype:) — discard
|
|
fields.append(cur)
|
|
continue
|
|
if in_fields:
|
|
if cur is not None:
|
|
cur[2].append(line)
|
|
else:
|
|
prose.append(line)
|
|
|
|
prose_md = _rst_inline_to_md("\n".join(prose).strip())
|
|
parsed = [(kind, name, _rst_field_text(body)) for kind, name, body in fields if kind != "drop"]
|
|
return prose_md, parsed
|
|
|
|
|
|
def _reformat_doc(
|
|
desc: str | None,
|
|
targets: dict[str, Any],
|
|
internal: frozenset[str] | None = None,
|
|
) -> str | None:
|
|
"""
|
|
Convert one reST ``description`` to Markdown.
|
|
|
|
Each ``:param name:`` whose ``name`` is a key in *targets* (a
|
|
parameter or property object) is moved onto that object's own
|
|
``description``; entries with no matching target become a Markdown
|
|
``**Parameters**`` list. ``:returns:`` / ``:raises:`` become
|
|
``**Returns:**`` / ``**Raises**`` sections. Names in *internal*
|
|
(FastAPI plumbing) are dropped.
|
|
|
|
:param desc: The raw description, or ``None``.
|
|
:param targets: Map of name -> object that may receive a moved
|
|
``description`` (empty when there are no field targets).
|
|
:param internal: Parameter names to drop entirely (``None`` = drop
|
|
none, used for schema fields).
|
|
:returns: The rebuilt Markdown description, or the original falsy
|
|
value when *desc* is empty.
|
|
"""
|
|
if not desc:
|
|
return desc
|
|
skip = internal or frozenset()
|
|
prose_md, fields = _parse_rst_doc(desc)
|
|
body_params: list[tuple[str, str]] = []
|
|
raises: list[tuple[str, str]] = []
|
|
returns: str | None = None
|
|
for kind, name, text in fields:
|
|
if kind == "param":
|
|
if not text or name in skip:
|
|
continue
|
|
target = targets.get(name) if name else None
|
|
if isinstance(target, dict):
|
|
# Move onto the matching field; don't clobber an explicit
|
|
# Field/Query description if one already exists.
|
|
if not target.get("description"):
|
|
target["description"] = text
|
|
else:
|
|
body_params.append((name or "", text))
|
|
elif kind == "raises" and text:
|
|
raises.append((name or "", text))
|
|
elif kind == "returns" and text:
|
|
returns = text
|
|
|
|
sections: list[str] = []
|
|
if prose_md:
|
|
sections.append(prose_md)
|
|
if body_params:
|
|
sections.append("**Parameters**\n\n" + "\n".join(f"- `{n}` — {t}" for n, t in body_params))
|
|
if returns:
|
|
sections.append(f"**Returns:** {returns}")
|
|
if raises:
|
|
sections.append("**Raises**\n\n" + "\n".join(f"- `{e}` — {t}" for e, t in raises))
|
|
return "\n\n".join(sections)
|
|
|
|
|
|
def _reformat_operation_doc(op: dict[str, Any]) -> None:
|
|
"""
|
|
Rewrite an operation's (and its responses') reST docs as Markdown.
|
|
|
|
Matched ``:param:`` entries move onto ``op['parameters']``; response
|
|
descriptions are reformatted with no field targets.
|
|
|
|
:param op: An OpenAPI operation object; mutated in place.
|
|
"""
|
|
if op.get("description"):
|
|
targets = {p.get("name"): p for p in op.get("parameters", []) if isinstance(p, dict)}
|
|
op["description"] = _reformat_doc(op["description"], targets, _INTERNAL_PARAMS)
|
|
for resp in (op.get("responses") or {}).values():
|
|
if isinstance(resp, dict) and resp.get("description"):
|
|
resp["description"] = _reformat_doc(resp["description"], {})
|
|
|
|
|
|
def _reformat_schema_node(node: Any) -> None:
|
|
"""
|
|
Rewrite a JSON-Schema node's reST ``description`` as Markdown.
|
|
|
|
A model's docstring becomes its schema ``description`` with
|
|
``:param name:`` entries describing its fields; each moves onto the
|
|
matching ``properties[name]`` description. Recurses into nested
|
|
schema positions so inline sub-objects are handled too.
|
|
|
|
:param node: A JSON-Schema object (non-dicts are ignored); mutated
|
|
in place.
|
|
"""
|
|
if not isinstance(node, dict):
|
|
return
|
|
if node.get("description"):
|
|
props = node.get("properties")
|
|
node["description"] = _reformat_doc(
|
|
node["description"],
|
|
props if isinstance(props, dict) else {},
|
|
)
|
|
properties = node.get("properties")
|
|
if isinstance(properties, dict):
|
|
for sub in properties.values():
|
|
_reformat_schema_node(sub)
|
|
for defs_key in ("$defs", "definitions"):
|
|
defs = node.get(defs_key)
|
|
if isinstance(defs, dict):
|
|
for sub in defs.values():
|
|
_reformat_schema_node(sub)
|
|
for child_key in ("items", "additionalProperties"):
|
|
_reformat_schema_node(node.get(child_key))
|
|
for combinator in ("allOf", "anyOf", "oneOf", "prefixItems"):
|
|
members = node.get(combinator)
|
|
if isinstance(members, list):
|
|
for sub in members:
|
|
_reformat_schema_node(sub)
|
|
|
|
|
|
def _reformat_descriptions(paths: dict[str, Any]) -> None:
|
|
"""Convert every operation's reST description to Markdown in place."""
|
|
for methods in paths.values():
|
|
for method, op in methods.items():
|
|
if method in _HTTP_METHODS and isinstance(op, dict):
|
|
_reformat_operation_doc(op)
|
|
|
|
|
|
def _reformat_component_schemas(components: dict[str, Any]) -> None:
|
|
"""Convert every component schema's reST description to Markdown."""
|
|
schemas = components.get("schemas")
|
|
if isinstance(schemas, dict):
|
|
for schema in schemas.values():
|
|
_reformat_schema_node(schema)
|
|
|
|
|
|
def _normalize_inline_descriptions(node: Any) -> None:
|
|
"""
|
|
Final safety net: normalize inline reST in any remaining description.
|
|
|
|
Walks the whole document and converts inline ``:role:`X``` roles and
|
|
reST double-backtick literals to Markdown `` `X` `` in every
|
|
``description`` string — covering responses, ``info``, tags and
|
|
security schemes that the structured passes don't rewrite.
|
|
|
|
:param node: Any spec fragment; mutated in place.
|
|
"""
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
if key == "description" and isinstance(value, str):
|
|
node[key] = _rst_inline_to_md(value)
|
|
else:
|
|
_normalize_inline_descriptions(value)
|
|
elif isinstance(node, list):
|
|
for value in node:
|
|
_normalize_inline_descriptions(value)
|
|
|
|
|
|
def _enrich_spec(spec: dict[str, Any]) -> None:
|
|
"""
|
|
Inject document-level metadata for docs / SDK tooling.
|
|
|
|
Adds ``info.description``, ``servers``, top-level ``tags`` with
|
|
human-readable descriptions, and ``components.securitySchemes`` —
|
|
none of which FastAPI emits — tags the untagged utility routes, and
|
|
rewrites reST docstrings (operations, parameters, and component
|
|
schemas) as Markdown.
|
|
Mutates ``spec`` in place. See the module-level enrichment
|
|
constants for the rationale behind each value.
|
|
|
|
:param spec: The generated OpenAPI dict; mutated in place.
|
|
"""
|
|
info = spec.setdefault("info", {})
|
|
info["description"] = _INFO_DESCRIPTION
|
|
spec["servers"] = _SERVERS
|
|
spec["tags"] = _TAGS
|
|
|
|
components = spec.setdefault("components", {})
|
|
components["securitySchemes"] = _SECURITY_SCHEMES
|
|
|
|
paths = spec.setdefault("paths", {})
|
|
_tag_system_routes(paths)
|
|
_retag_session_resources(paths)
|
|
_reformat_descriptions(paths)
|
|
_reformat_component_schemas(components)
|
|
_normalize_inline_descriptions(spec)
|
|
|
|
|
|
def generate_spec() -> dict[str, Any]:
|
|
"""
|
|
Build, generate, and post-process the OpenAPI 3.2 spec.
|
|
|
|
Encapsulates every step (app construction, generation, version
|
|
bump, schema injection, SSE rewrite) so callers can compare the
|
|
generated dict against ``openapi.json`` without writing to disk.
|
|
|
|
:returns: The post-processed OpenAPI dict, ready to serialize.
|
|
"""
|
|
app = _build_app_with_stub_stores()
|
|
spec = app.openapi()
|
|
# Bump the OpenAPI version literal — we don't change any
|
|
# 3.1-only constructs because FastAPI's emitted shape is also
|
|
# valid 3.2.x (3.2 is JSON-Schema-aligned and largely additive
|
|
# over 3.1).
|
|
spec["openapi"] = _OPENAPI_VERSION
|
|
|
|
# Inject the ServerStreamEvent union + per-variant defs into
|
|
# ``components.schemas`` so the SSE routes' $ref points resolve.
|
|
components = spec.setdefault("components", {})
|
|
schemas = components.setdefault("schemas", {})
|
|
union = _server_stream_event_schema()
|
|
schemas["ServerStreamEvent"] = union["root"]
|
|
for name, definition in union["definitions"].items():
|
|
# Don't clobber a same-named schema FastAPI already
|
|
# synthesized — the union's per-variant defs include
|
|
# ``ResponseObject`` (referenced from terminal events), and
|
|
# FastAPI also emits one. Keep FastAPI's version; the
|
|
# serialized shape is identical for our models.
|
|
schemas.setdefault(name, definition)
|
|
|
|
# Rewrite SSE routes' content entries to use ``itemSchema``.
|
|
paths = spec.get("paths", {})
|
|
for path, method in _SSE_ROUTES:
|
|
_rewrite_sse_route(paths, path, method)
|
|
|
|
# Inject document-level metadata (servers, auth, tags, prose) that
|
|
# FastAPI doesn't emit but docs / SDK tooling needs.
|
|
_enrich_spec(spec)
|
|
|
|
return spec # type: ignore[no-any-return]
|
|
|
|
|
|
def main() -> int:
|
|
"""
|
|
CLI entry point.
|
|
|
|
With no arguments, regenerates ``openapi.json``. With
|
|
``--check``, compares the generated spec to the on-disk file
|
|
and exits 1 if they differ.
|
|
|
|
:returns: 0 on success / no drift, 1 on drift in ``--check``
|
|
mode.
|
|
"""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help=(
|
|
"CI mode — exit 1 if the on-disk openapi.json differs from "
|
|
"the generated spec. Use to fail PRs that change the spec "
|
|
"without regenerating."
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
spec = generate_spec()
|
|
serialized = json.dumps(spec, indent=2, sort_keys=True) + "\n"
|
|
|
|
if args.check:
|
|
if not _OPENAPI_OUT.exists():
|
|
sys.stderr.write(
|
|
f"openapi.json not found at {_OPENAPI_OUT}; "
|
|
"run `python scripts/dump_openapi.py` to generate it.\n",
|
|
)
|
|
return 1
|
|
existing = _OPENAPI_OUT.read_text()
|
|
if existing != serialized:
|
|
sys.stderr.write(
|
|
"openapi.json is out of sync with the generated spec.\n"
|
|
"Run `python scripts/dump_openapi.py` to regenerate.\n",
|
|
)
|
|
return 1
|
|
sys.stdout.write("openapi.json is up to date.\n")
|
|
return 0
|
|
|
|
_OPENAPI_OUT.write_text(serialized)
|
|
sys.stdout.write(f"Wrote {_OPENAPI_OUT}\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|