124 lines
4.6 KiB
Python
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
|