chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,849 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user