fastapi-breakgate 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ """Typer CLI for fastapi-contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.markdown import Markdown
13
+ from rich.table import Table
14
+
15
+ from fastapi_contract.__version__ import __version__
16
+ from fastapi_contract.check import run_check
17
+ from fastapi_contract.comment import github_check_annotation_lines, render_text_summary
18
+ from fastapi_contract.config import load_config
19
+ from fastapi_contract.diff.classifier import diff_openapi
20
+ from fastapi_contract.doctor import run_doctor
21
+ from fastapi_contract.export import ExportError, export_openapi, write_snapshot
22
+ from fastapi_contract.models import ExportMode
23
+
24
+ app = typer.Typer(
25
+ name="fastapi-contract",
26
+ help="Production merge gate for FastAPI OpenAPI breaking changes.",
27
+ add_completion=False,
28
+ no_args_is_help=True,
29
+ )
30
+ console = Console(stderr=True)
31
+ out = Console()
32
+
33
+
34
+ def _version_callback(value: bool) -> None:
35
+ if value:
36
+ out.print(__version__)
37
+ raise typer.Exit(0)
38
+
39
+
40
+ @app.callback()
41
+ def main(
42
+ version: bool = typer.Option(
43
+ False,
44
+ "--version",
45
+ "-V",
46
+ help="Show version and exit.",
47
+ callback=_version_callback,
48
+ is_eager=True,
49
+ ),
50
+ ) -> None:
51
+ """fastapi-contract CLI."""
52
+
53
+
54
+ @app.command("doctor")
55
+ def doctor_cmd(
56
+ config: Path | None = typer.Option(None, "--config", "-c", help="Path to TOML config"),
57
+ app_path: str | None = typer.Option(None, "--app", help="module:attr FastAPI target"),
58
+ json_out: bool = typer.Option(False, "--json", help="Emit JSON"),
59
+ ) -> None:
60
+ """Diagnose OpenAPI export readiness."""
61
+ cfg = load_config(config, overrides={"app": app_path})
62
+ report = run_doctor(cfg)
63
+ if json_out:
64
+ payload = {
65
+ "ok": report.ok,
66
+ "checks": [
67
+ {
68
+ "name": c.name,
69
+ "ok": c.ok,
70
+ "detail": c.detail,
71
+ "hint": c.hint,
72
+ }
73
+ for c in report.checks
74
+ ],
75
+ "suggested_env": report.suggested_env,
76
+ }
77
+ out.print_json(data=payload)
78
+ else:
79
+ table = Table(title="fastapi-contract doctor")
80
+ table.add_column("Status")
81
+ table.add_column("Check")
82
+ table.add_column("Detail")
83
+ for check in report.checks:
84
+ table.add_row(
85
+ "[green]OK[/green]" if check.ok else "[red]FAIL[/red]",
86
+ check.name,
87
+ check.detail + (f"\nhint: {check.hint}" if check.hint else ""),
88
+ )
89
+ console.print(table)
90
+ console.print(
91
+ f"[bold]{'PASS' if report.ok else 'FAIL'}[/bold] — overall doctor status"
92
+ )
93
+ raise typer.Exit(0 if report.ok else 1)
94
+
95
+
96
+ @app.command("export")
97
+ def export_cmd(
98
+ output: Path = typer.Option(Path("openapi.json"), "--output", "-o"),
99
+ config: Path | None = typer.Option(None, "--config", "-c"),
100
+ app_path: str | None = typer.Option(None, "--app"),
101
+ export_mode: str | None = typer.Option(None, "--mode"),
102
+ app_factory: bool | None = typer.Option(None, "--factory/--no-factory"),
103
+ ) -> None:
104
+ """Export live (or snapshot) OpenAPI JSON."""
105
+ cfg = load_config(
106
+ config,
107
+ overrides={
108
+ "app": app_path,
109
+ "export_mode": export_mode,
110
+ "app_factory": app_factory,
111
+ },
112
+ )
113
+ try:
114
+ result = export_openapi(cfg)
115
+ except ExportError as exc:
116
+ console.print(f"[red]Export failed:[/red] {exc}")
117
+ raise typer.Exit(2) from exc
118
+ output.write_text(result.to_json(), encoding="utf-8")
119
+ for warning in result.warnings:
120
+ console.print(f"[yellow]warning:[/yellow] {warning}")
121
+ console.print(
122
+ f"[green]Wrote[/green] {output} via {result.strategy.value} "
123
+ f"({len(result.schema.get('paths', {}))} paths)"
124
+ )
125
+
126
+
127
+ @app.command("snapshot")
128
+ def snapshot_cmd(
129
+ config: Path | None = typer.Option(None, "--config", "-c"),
130
+ app_path: str | None = typer.Option(None, "--app"),
131
+ snapshot_path: Path | None = typer.Option(None, "--path"),
132
+ app_factory: bool | None = typer.Option(None, "--factory/--no-factory"),
133
+ ) -> None:
134
+ """Write/update committed OpenAPI snapshot from live export."""
135
+ cfg = load_config(
136
+ config,
137
+ overrides={
138
+ "app": app_path,
139
+ "app_factory": app_factory,
140
+ "export_mode": ExportMode.LIVE.value,
141
+ },
142
+ )
143
+ try:
144
+ result = export_openapi(cfg)
145
+ except ExportError as exc:
146
+ console.print(f"[red]Export failed:[/red] {exc}")
147
+ raise typer.Exit(2) from exc
148
+ path = snapshot_path or Path(cfg.snapshot_path)
149
+ write_snapshot(result.schema, path)
150
+ console.print(f"[green]Snapshot written[/green] {path}")
151
+
152
+
153
+ @app.command("diff")
154
+ def diff_cmd(
155
+ base_file: Path = typer.Option(..., "--base-file", help="Base OpenAPI JSON"),
156
+ candidate_file: Path | None = typer.Option(
157
+ None, "--candidate-file", help="Candidate OpenAPI JSON (default: live export)"
158
+ ),
159
+ config: Path | None = typer.Option(None, "--config", "-c"),
160
+ app_path: str | None = typer.Option(None, "--app"),
161
+ policy: str | None = typer.Option(None, "--policy"),
162
+ json_out: bool = typer.Option(False, "--json"),
163
+ ) -> None:
164
+ """Diff two OpenAPI documents (or live app vs base file)."""
165
+ cfg = load_config(config, overrides={"app": app_path, "policy": policy})
166
+ base = json.loads(base_file.read_text(encoding="utf-8"))
167
+ if candidate_file is not None:
168
+ candidate = json.loads(candidate_file.read_text(encoding="utf-8"))
169
+ else:
170
+ try:
171
+ candidate = export_openapi(cfg).schema
172
+ except ExportError as exc:
173
+ console.print(f"[red]Export failed:[/red] {exc}")
174
+ raise typer.Exit(2) from exc
175
+ result = diff_openapi(base, candidate, policy=cfg.policy)
176
+ if json_out:
177
+ out.print_json(
178
+ data={
179
+ "engine": result.engine,
180
+ "breaking": [c.to_dict() for c in result.breaking],
181
+ "risky": [c.to_dict() for c in result.risky],
182
+ "safe": [c.to_dict() for c in result.safe],
183
+ }
184
+ )
185
+ else:
186
+ console.print(render_text_summary(result))
187
+ for change in result.changes:
188
+ color = {
189
+ "breaking": "red",
190
+ "risky": "yellow",
191
+ "safe": "green",
192
+ }[change.severity.value]
193
+ method = f"{change.method.upper()} " if change.method else ""
194
+ console.print(
195
+ f"[{color}]{change.severity.value}[/{color}] "
196
+ f"{method}{change.path} — {change.message}"
197
+ )
198
+ raise typer.Exit(1 if result.breaking else 0)
199
+
200
+
201
+ @app.command("check")
202
+ def check_cmd(
203
+ config: Path | None = typer.Option(None, "--config", "-c"),
204
+ app_path: str | None = typer.Option(None, "--app"),
205
+ base_file: Path | None = typer.Option(None, "--base-file"),
206
+ policy: str | None = typer.Option(None, "--policy"),
207
+ fail_on: str | None = typer.Option(None, "--fail-on"),
208
+ pr_title: str | None = typer.Option(
209
+ None, "--pr-title", help="PR title for intentional protocol"
210
+ ),
211
+ pr_labels: str | None = typer.Option(
212
+ None, "--pr-labels", help="Comma-separated PR labels"
213
+ ),
214
+ report_file: Path | None = typer.Option(
215
+ None, "--report", help="Write markdown report to file"
216
+ ),
217
+ annotations: bool = typer.Option(
218
+ False, "--annotations", help="Emit GitHub Actions annotations"
219
+ ),
220
+ app_factory: bool | None = typer.Option(None, "--factory/--no-factory"),
221
+ ) -> None:
222
+ """CI entrypoint: fail on accidental breaking changes."""
223
+ # Allow GitHub Actions env defaults
224
+ if pr_title is None:
225
+ pr_title = os.environ.get("FAC_PR_TITLE") or os.environ.get("PR_TITLE")
226
+ labels_list: list[str] = []
227
+ raw_labels = pr_labels or os.environ.get("FAC_PR_LABELS") or os.environ.get("PR_LABELS")
228
+ if raw_labels:
229
+ labels_list = [part.strip() for part in raw_labels.split(",") if part.strip()]
230
+
231
+ cfg = load_config(
232
+ config,
233
+ overrides={
234
+ "app": app_path,
235
+ "policy": policy,
236
+ "fail_on": fail_on,
237
+ "app_factory": app_factory,
238
+ },
239
+ )
240
+ try:
241
+ result = run_check(
242
+ cfg,
243
+ base_schema_path=base_file,
244
+ pr_title=pr_title,
245
+ pr_labels=labels_list,
246
+ )
247
+ except (ExportError, FileNotFoundError, ValueError) as exc:
248
+ console.print(f"[red]Check failed to run:[/red] {exc}")
249
+ raise typer.Exit(2) from exc
250
+
251
+ out.print(Markdown(result.report_markdown))
252
+ if report_file is not None:
253
+ report_file.write_text(result.report_markdown, encoding="utf-8")
254
+ console.print(f"Report written to {report_file}")
255
+ if annotations:
256
+ for line in github_check_annotation_lines(result.diff):
257
+ # annotations must go to stdout for GHA
258
+ sys.stdout.write(line + "\n")
259
+ for message in result.messages:
260
+ console.print(f"[dim]{message}[/dim]")
261
+ raise typer.Exit(result.exit_code)
262
+
263
+
264
+ @app.command("comment")
265
+ def comment_cmd(
266
+ config: Path | None = typer.Option(None, "--config", "-c"),
267
+ app_path: str | None = typer.Option(None, "--app"),
268
+ base_file: Path | None = typer.Option(None, "--base-file"),
269
+ policy: str | None = typer.Option(None, "--policy"),
270
+ output: Path = typer.Option(Path("contract-comment.md"), "--output", "-o"),
271
+ pr_title: str | None = typer.Option(None, "--pr-title"),
272
+ pr_labels: str | None = typer.Option(None, "--pr-labels"),
273
+ ) -> None:
274
+ """Render PR markdown comment without enforcing exit semantics beyond generation."""
275
+ labels_list = [
276
+ part.strip() for part in (pr_labels or "").split(",") if part.strip()
277
+ ]
278
+ cfg = load_config(config, overrides={"app": app_path, "policy": policy})
279
+ try:
280
+ result = run_check(
281
+ cfg,
282
+ base_schema_path=base_file,
283
+ pr_title=pr_title,
284
+ pr_labels=labels_list,
285
+ )
286
+ except (ExportError, FileNotFoundError, ValueError) as exc:
287
+ console.print(f"[red]Comment generation failed:[/red] {exc}")
288
+ raise typer.Exit(2) from exc
289
+ output.write_text(result.report_markdown, encoding="utf-8")
290
+ console.print(f"[green]Wrote[/green] {output}")
291
+ raise typer.Exit(0)
292
+
293
+
294
+ if __name__ == "__main__":
295
+ app()
@@ -0,0 +1,101 @@
1
+ """Render human-readable PR / terminal reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi_contract.diff.classifier import ChangeSeverity, DiffResult
6
+ from fastapi_contract.intentional import IntentionalStatus, suggest_semver_bump
7
+
8
+
9
+ def render_markdown_report(
10
+ diff: DiffResult,
11
+ *,
12
+ intentional: IntentionalStatus | None = None,
13
+ failed: bool = False,
14
+ title: str = "fastapi-contract report",
15
+ ) -> str:
16
+ breaking = diff.breaking
17
+ risky = diff.risky
18
+ safe = diff.safe
19
+ bump = suggest_semver_bump(bool(breaking), bool(safe or risky))
20
+
21
+ status = "FAILED" if failed else "PASSED"
22
+ lines = [
23
+ f"## {title}",
24
+ "",
25
+ f"**Status:** {status} ",
26
+ f"**Engine:** `{diff.engine}` ",
27
+ f"**Paths:** base={diff.base_paths}, candidate={diff.candidate_paths} ",
28
+ f"**Suggested SemVer bump:** `{bump}`",
29
+ "",
30
+ "| Severity | Count |",
31
+ "|----------|------:|",
32
+ f"| Breaking | {len(breaking)} |",
33
+ f"| Risky | {len(risky)} |",
34
+ f"| Safe | {len(safe)} |",
35
+ "",
36
+ ]
37
+
38
+ def section(name: str, items: list) -> None:
39
+ if not items:
40
+ return
41
+ lines.append(f"### {name}")
42
+ lines.append("")
43
+ for change in items:
44
+ method = f"{change.method.upper()} " if change.method else ""
45
+ lines.append(
46
+ f"- **{change.severity.value}** `{method}{change.path}` — {change.message}"
47
+ )
48
+ lines.append("")
49
+
50
+ section("Breaking changes", breaking)
51
+ section("Risky changes", risky)
52
+ section("Safe / additive changes", safe)
53
+
54
+ if intentional is not None:
55
+ lines.append("### Intentional break protocol")
56
+ lines.append("")
57
+ if intentional.allowed:
58
+ lines.append("Protocol **satisfied** — treat as reviewed intentional break.")
59
+ else:
60
+ lines.append("Protocol **not satisfied**:")
61
+ for reason in intentional.reasons:
62
+ lines.append(f"- {reason}")
63
+ lines.append("")
64
+
65
+ lines.append("### Next steps")
66
+ lines.append("")
67
+ if breaking and (intentional is None or not intentional.allowed):
68
+ lines.append(
69
+ "1. Revert accidental breaks, **or** complete the intentional protocol "
70
+ "(label/title marker + changelog `### Breaking` bullets)."
71
+ )
72
+ lines.append(
73
+ "2. Regenerate frontend clients if applicable "
74
+ "(`openapi-typescript`, hey-api, etc.)."
75
+ )
76
+ elif breaking and intentional and intentional.allowed:
77
+ lines.append("1. Confirm consumers are notified / versioned.")
78
+ lines.append("2. Regenerate generated clients and bump major version.")
79
+ else:
80
+ lines.append("1. No accidental breaking changes detected.")
81
+ lines.append("")
82
+ return "\n".join(lines)
83
+
84
+
85
+ def render_text_summary(diff: DiffResult) -> str:
86
+ return (
87
+ f"{len(diff.breaking)} breaking, {len(diff.risky)} risky, "
88
+ f"{len(diff.safe)} safe ({diff.engine})"
89
+ )
90
+
91
+
92
+ def github_check_annotation_lines(diff: DiffResult) -> list[str]:
93
+ """Simple annotation-style lines for CI logs."""
94
+ lines: list[str] = []
95
+ for change in diff.changes:
96
+ if change.severity == ChangeSeverity.SAFE:
97
+ continue
98
+ level = "error" if change.severity == ChangeSeverity.BREAKING else "warning"
99
+ method = f"{change.method.upper()} " if change.method else ""
100
+ lines.append(f"::{level} title=fastapi-contract::{method}{change.path}: {change.message}")
101
+ return lines
@@ -0,0 +1,161 @@
1
+ """Configuration loading from pyproject.toml / fastapi-contract.toml / CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass, field, replace
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fastapi_contract.models import ExportMode, FailOn, PolicyName
11
+
12
+ if sys.version_info >= (3, 11):
13
+ import tomllib
14
+ else:
15
+ import tomli as tomllib # type: ignore[no-redef]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ContractConfig:
20
+ app: str = "main:app"
21
+ app_factory: bool = False
22
+ base_ref: str = "origin/main"
23
+ fail_on: FailOn = FailOn.BREAKING
24
+ policy: PolicyName = PolicyName.ADDITIVE_FRIENDLY
25
+ intentional_label: str = "breaking-change"
26
+ intentional_title_marker: str = "[breaking-change]"
27
+ changelog_path: str = "CHANGELOG.md"
28
+ changelog_required_heading: str = "### Breaking"
29
+ snapshot_path: str = "openapi.snapshot.json"
30
+ export_mode: ExportMode = ExportMode.AUTO
31
+ stub_env_file: str | None = ".env.contract"
32
+ skip_lifespan: bool = True
33
+ extra_env: dict[str, str] = field(default_factory=dict)
34
+ oasdiff_bin: str | None = None
35
+ prefer_oasdiff: bool = False
36
+
37
+ def with_overrides(self, **kwargs: Any) -> ContractConfig:
38
+ cleaned = {k: v for k, v in kwargs.items() if v is not None}
39
+ if "fail_on" in cleaned and not isinstance(cleaned["fail_on"], FailOn):
40
+ cleaned["fail_on"] = FailOn(str(cleaned["fail_on"]))
41
+ if "policy" in cleaned and not isinstance(cleaned["policy"], PolicyName):
42
+ cleaned["policy"] = PolicyName(str(cleaned["policy"]))
43
+ if "export_mode" in cleaned and not isinstance(cleaned["export_mode"], ExportMode):
44
+ cleaned["export_mode"] = ExportMode(str(cleaned["export_mode"]))
45
+ if "app_factory" in cleaned:
46
+ cleaned["app_factory"] = _as_bool(cleaned["app_factory"], self.app_factory)
47
+ if "skip_lifespan" in cleaned:
48
+ cleaned["skip_lifespan"] = _as_bool(cleaned["skip_lifespan"], self.skip_lifespan)
49
+ if "prefer_oasdiff" in cleaned:
50
+ cleaned["prefer_oasdiff"] = _as_bool(cleaned["prefer_oasdiff"], self.prefer_oasdiff)
51
+ return replace(self, **cleaned)
52
+
53
+
54
+ _TRUE = {"1", "true", "yes", "on"}
55
+ _FALSE = {"0", "false", "no", "off"}
56
+
57
+
58
+ def _as_bool(value: Any, default: bool) -> bool:
59
+ if value is None:
60
+ return default
61
+ if isinstance(value, bool):
62
+ return value
63
+ if isinstance(value, str):
64
+ lowered = value.strip().lower()
65
+ if lowered in _TRUE:
66
+ return True
67
+ if lowered in _FALSE:
68
+ return False
69
+ return bool(value)
70
+
71
+
72
+ def _as_enum(enum_cls: type[Any], value: Any, default: Any) -> Any:
73
+ if value is None:
74
+ return default
75
+ if isinstance(value, enum_cls):
76
+ return value
77
+ return enum_cls(str(value))
78
+
79
+
80
+ def _find_config_file(start: Path | None = None) -> Path | None:
81
+ cwd = (start or Path.cwd()).resolve()
82
+ for directory in [cwd, *cwd.parents]:
83
+ for name in ("fastapi-contract.toml", "pyproject.toml"):
84
+ candidate = directory / name
85
+ if candidate.is_file():
86
+ return candidate
87
+ if (directory / ".git").exists():
88
+ break
89
+ return None
90
+
91
+
92
+ def _section_from_toml(path: Path) -> dict[str, Any]:
93
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
94
+ if path.name == "fastapi-contract.toml":
95
+ return dict(raw.get("tool", {}).get("fastapi_contract", raw.get("fastapi_contract", raw)))
96
+ tool = raw.get("tool", {})
97
+ section = tool.get("fastapi_contract") or tool.get("fastapi-contract")
98
+ return dict(section or {})
99
+
100
+
101
+ def load_config(
102
+ path: Path | None = None,
103
+ *,
104
+ overrides: dict[str, Any] | None = None,
105
+ ) -> ContractConfig:
106
+ """Load config from TOML, then apply optional CLI overrides."""
107
+ cfg = ContractConfig()
108
+ config_path = path or _find_config_file()
109
+ data: dict[str, Any] = {}
110
+ if config_path is not None:
111
+ data = _section_from_toml(config_path)
112
+
113
+ if data:
114
+ extra_env = data.get("extra_env") or data.get("env") or {}
115
+ if not isinstance(extra_env, dict):
116
+ extra_env = {}
117
+ cfg = ContractConfig(
118
+ app=str(data.get("app", cfg.app)),
119
+ app_factory=_as_bool(data.get("app_factory"), cfg.app_factory),
120
+ base_ref=str(data.get("base_ref", cfg.base_ref)),
121
+ fail_on=_as_enum(FailOn, data.get("fail_on"), cfg.fail_on),
122
+ policy=_as_enum(PolicyName, data.get("policy"), cfg.policy),
123
+ intentional_label=str(data.get("intentional_label", cfg.intentional_label)),
124
+ intentional_title_marker=str(
125
+ data.get("intentional_title_marker", cfg.intentional_title_marker)
126
+ ),
127
+ changelog_path=str(data.get("changelog_path", cfg.changelog_path)),
128
+ changelog_required_heading=str(
129
+ data.get("changelog_required_heading", cfg.changelog_required_heading)
130
+ ),
131
+ snapshot_path=str(data.get("snapshot_path", cfg.snapshot_path)),
132
+ export_mode=_as_enum(ExportMode, data.get("export_mode"), cfg.export_mode),
133
+ stub_env_file=(
134
+ None
135
+ if data.get("stub_env_file") in (None, "", False)
136
+ else str(data.get("stub_env_file", cfg.stub_env_file))
137
+ ),
138
+ skip_lifespan=_as_bool(data.get("skip_lifespan"), cfg.skip_lifespan),
139
+ extra_env={str(k): str(v) for k, v in extra_env.items()},
140
+ oasdiff_bin=(
141
+ str(data["oasdiff_bin"]) if data.get("oasdiff_bin") else cfg.oasdiff_bin
142
+ ),
143
+ prefer_oasdiff=_as_bool(data.get("prefer_oasdiff"), cfg.prefer_oasdiff),
144
+ )
145
+
146
+ if overrides:
147
+ mapped = dict(overrides)
148
+ if "fail_on" in mapped and mapped["fail_on"] is not None:
149
+ mapped["fail_on"] = _as_enum(FailOn, mapped["fail_on"], cfg.fail_on)
150
+ if "policy" in mapped and mapped["policy"] is not None:
151
+ mapped["policy"] = _as_enum(PolicyName, mapped["policy"], cfg.policy)
152
+ if "export_mode" in mapped and mapped["export_mode"] is not None:
153
+ mapped["export_mode"] = _as_enum(ExportMode, mapped["export_mode"], cfg.export_mode)
154
+ if "app_factory" in mapped and mapped["app_factory"] is not None:
155
+ mapped["app_factory"] = _as_bool(mapped["app_factory"], cfg.app_factory)
156
+ if "skip_lifespan" in mapped and mapped["skip_lifespan"] is not None:
157
+ mapped["skip_lifespan"] = _as_bool(mapped["skip_lifespan"], cfg.skip_lifespan)
158
+ if "prefer_oasdiff" in mapped and mapped["prefer_oasdiff"] is not None:
159
+ mapped["prefer_oasdiff"] = _as_bool(mapped["prefer_oasdiff"], cfg.prefer_oasdiff)
160
+ cfg = cfg.with_overrides(**mapped)
161
+ return cfg
@@ -0,0 +1,5 @@
1
+ """OpenAPI diff and policy classification."""
2
+
3
+ from fastapi_contract.diff.classifier import Change, ChangeSeverity, DiffResult, diff_openapi
4
+
5
+ __all__ = ["Change", "ChangeSeverity", "DiffResult", "diff_openapi"]