Files
soleprint/soleprint/atlas2/docgen/extractors/openapi.py
2026-09-12 06:42:49 -03:00

124 lines
4.6 KiB
Python

"""
An OpenAPI document -> IR: the endpoints, and the shapes they carry.
Adapts `modelgen/loader/extract/openapi.py`, which already parses OpenAPI 3.x
and Swagger 2.0 and resolves `$ref`. This turns its output into IR nodes; it
does not re-parse anything.
python3 -m docgen.extractors.openapi --spec petstore.yaml -o ir.json
## Why this one matters more than it looks
A hand-written API notebook is the thing nobody can keep current: the spec moves
and the document does not, and there is no way to tell by looking. Extracting
the endpoints means the document is **generated from the same file the server is
built from**, so "is this current" becomes a question about a build rather than
about somebody's diligence.
It emits schemas as `table`/`column`, the same vocabulary the database extractor
uses. That is deliberate: an API's data model and a database's are the same kind
of thing, so the ER emitter draws either without knowing which it got. Endpoints
are a separate `kind`, so a view can ask for one or the other.
only_kinds(ir, {"table", "column"}) -> the data model, as an ER diagram
only_kinds(ir, {"endpoint"}) -> the surface, as a notebook
"""
import sys
from pathlib import Path
from ..ir import Graph, Meta
def _modelgen():
"""modelgen, from wherever this instance keeps station tools.
Imported lazily and by path rather than as a hard dependency: docgen belongs
to atlas and may depend on a station tool, but it should not fail to import
because one is missing.
"""
here = Path(__file__).resolve()
for parent in here.parents:
tools = parent / "station" / "tools"
if (tools / "modelgen").is_dir():
if str(tools) not in sys.path:
sys.path.insert(0, str(tools))
from modelgen.loader.extract.openapi import OpenAPIExtractor
return OpenAPIExtractor
raise ImportError(
"modelgen not found — docgen reads OpenAPI through "
"station/tools/modelgen/loader/extract/openapi.py, which parses the spec "
"and resolves $ref. It is not reimplemented here."
)
def _type_name(hint) -> str:
if hint is None:
return "Any"
if isinstance(hint, str):
return hint
return getattr(hint, "__name__", str(hint))
def extract(spec_path, source: str = "openapi") -> Graph:
"""An OpenAPI file -> IR."""
OpenAPIExtractor = _modelgen()
path = Path(spec_path)
extractor = OpenAPIExtractor(path)
models, enums = extractor.extract()
endpoints = extractor.endpoints()
g = Graph(Meta(source=source, root=path.name))
known = {m.name for m in models}
for model in models:
attrs = {}
if getattr(model, "docstring", None):
attrs["doc"] = model.docstring.strip().split("\n")[0]
g.node(model.name, "table", model.name, attrs=attrs)
for field in model.fields:
a = {"type": _type_name(field.type_hint)}
if getattr(field, "optional", False):
a["nullable"] = True
if field.name in ("id", "uuid"):
a["pk"] = True
target = _type_name(field.type_hint)
if target in known and target != model.name:
a["references"] = target
g.edge(model.name, target, "foreign_key", attrs={"label": field.name})
g.node(
f"{model.name}.{field.name}", "column", field.name,
parent=model.name, attrs=a,
)
for e in endpoints:
eid = f"{e.method} {e.path}"
attrs = {
"method": e.method,
"path": e.path,
"status": getattr(e, "status", None) or 200,
}
for key in ("summary", "operation_id", "envelope_key"):
value = getattr(e, key, None)
if value:
attrs[key] = value
if getattr(e, "response_is_list", False):
attrs["returns_list"] = True
if getattr(e, "path_params", None):
attrs["path_params"] = list(e.path_params)
if getattr(e, "request_model", None):
attrs["request_model"] = e.request_model
if getattr(e, "response_model", None):
attrs["response_model"] = e.response_model
g.node(eid, "endpoint", eid, attrs=attrs)
# `accepts` and `returns` rather than one `uses`: which direction a
# shape travels is the thing a reader wants to know.
if getattr(e, "request_model", None) in known:
g.edge(eid, e.request_model, "accepts")
if getattr(e, "response_model", None) in known:
g.edge(eid, e.response_model, "returns")
return g