ghost-api-cli 0.2.0__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.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/events.py ADDED
@@ -0,0 +1,213 @@
1
+ """Structured events emitted while a run happens.
2
+
3
+ The pipeline never prints. It emits these events, and each interface renders them:
4
+ the terminal (`ghost_api/ui/terminal.py`), the web dashboard (`ghost_api/web/`), and the
5
+ database (`run_events`), so any past run can be replayed in full.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ import time
13
+ from collections.abc import Callable
14
+ from dataclasses import asdict, dataclass, field
15
+ from typing import Any, ClassVar, Literal
16
+
17
+ Phase = Literal["checks", "explore", "test", "generate", "verify", "learn", "done"]
18
+ PHASES: tuple[Phase, ...] = ("checks", "explore", "test", "generate", "verify", "learn", "done")
19
+ Level = Literal["info", "success", "warning", "error"]
20
+
21
+
22
+ _log = logging.getLogger("ghost_api")
23
+
24
+
25
+ @dataclass
26
+ class Event:
27
+ kind: ClassVar[str] = "event"
28
+
29
+ def to_dict(self) -> dict[str, Any]:
30
+ return {"type": self.kind, **asdict(self)}
31
+
32
+
33
+ @dataclass
34
+ class RunStarted(Event):
35
+ kind: ClassVar[str] = "run_started"
36
+ run_id: int
37
+ domain: str
38
+ start_url: str
39
+ goal: str
40
+ mode: Literal["run", "refresh"] = "run"
41
+ lang: str = "python"
42
+
43
+
44
+ @dataclass
45
+ class PhaseChanged(Event):
46
+ kind: ClassVar[str] = "phase"
47
+ phase: Phase
48
+
49
+
50
+ @dataclass
51
+ class Notice(Event):
52
+ """A message for the person: progress, warnings, errors (already plain-language)."""
53
+
54
+ kind: ClassVar[str] = "notice"
55
+ text: str
56
+ level: Level = "info"
57
+ detail: str = ""
58
+
59
+
60
+ @dataclass
61
+ class RequestCaptured(Event):
62
+ kind: ClassVar[str] = "request"
63
+ method: str
64
+ url: str # redacted
65
+ status: int
66
+ third_party: bool = False
67
+
68
+
69
+ @dataclass
70
+ class AgentStep(Event):
71
+ kind: ClassVar[str] = "step"
72
+ step: int
73
+ action: str
74
+ args: dict[str, Any]
75
+ thought: str
76
+ result: str
77
+ new_endpoints: list[str] = field(default_factory=list)
78
+ requests: int = 0
79
+ screenshot: str | None = None # file name in the run's screenshot folder
80
+ box: dict[str, float] | None = None # element acted on: x, y, width, height (page px)
81
+
82
+
83
+ @dataclass
84
+ class Screenshot(Event):
85
+ """A browser screenshot outside agent steps (e.g. the first page load)."""
86
+
87
+ kind: ClassVar[str] = "screenshot"
88
+ name: str
89
+ url: str
90
+
91
+
92
+ @dataclass
93
+ class EndpointsUpdated(Event):
94
+ """The current endpoint map (summaries; full details come from the API/database)."""
95
+
96
+ kind: ClassVar[str] = "endpoints"
97
+ endpoints: list[dict[str, Any]]
98
+
99
+
100
+ @dataclass
101
+ class HypothesisTested(Event):
102
+ kind: ClassVar[str] = "hypothesis"
103
+ endpoint: str # display label
104
+ text: str
105
+ result: Literal["confirmed", "rejected", "inconclusive"]
106
+ evidence: str
107
+
108
+
109
+ @dataclass
110
+ class ProbeFinished(Event):
111
+ kind: ClassVar[str] = "probe_done"
112
+ endpoints: int
113
+ direct_calls: int
114
+
115
+
116
+ @dataclass
117
+ class VerifyRound(Event):
118
+ kind: ClassVar[str] = "verify_round"
119
+ number: int
120
+ passed: int
121
+ failed: int
122
+ skipped: int
123
+ failures: list[dict[str, str]] = field(default_factory=list) # name, message
124
+ repairs: list[dict[str, str]] = field(default_factory=list) # method, kind, action
125
+
126
+
127
+ @dataclass
128
+ class ClientGenerated(Event):
129
+ kind: ClassVar[str] = "client"
130
+ class_name: str
131
+ lang: str
132
+ named_by_llm: bool
133
+ directory: str
134
+ methods: list[dict[str, Any]] # name, args, returns, iterator
135
+ credentials: list[dict[str, str]] = field(default_factory=list) # arg, env
136
+ skipped: list[str] = field(default_factory=list)
137
+ files: list[str] = field(default_factory=list)
138
+
139
+
140
+ @dataclass
141
+ class NetworkSummary(Event):
142
+ """What a run sent where, and which rules applied (the report's permissions section)."""
143
+
144
+ kind: ClassVar[str] = "network"
145
+ target: str # the site's host
146
+ robots: str # how robots.txt was checked ("allowed by robots.txt", "check skipped"...)
147
+ rate_limit: float # requests per second, at most
148
+ local_target: bool # private addresses allowed (the target itself is local)
149
+ saved_login: bool # a saved login (ghost-api login) was used
150
+ api_calls_seen: int # API calls the page made (kept after filtering)
151
+ direct_calls: int # requests Ghost API sent itself (endpoint tests)
152
+ third_party_hosts: list[str] = field(default_factory=list) # API hosts besides the site
153
+ blocked: list[str] = field(default_factory=list) # requests the rules stopped
154
+ llm_calls: int = 0
155
+
156
+
157
+ @dataclass
158
+ class UsageUpdated(Event):
159
+ kind: ClassVar[str] = "usage"
160
+ answered: int
161
+ tokens: int
162
+ cache_hits: int = 0
163
+ retries: int = 0
164
+ fallbacks: int = 0
165
+ by_provider: dict[str, int] = field(default_factory=dict)
166
+ problems: list[str] = field(default_factory=list)
167
+
168
+
169
+ @dataclass
170
+ class RunFinished(Event):
171
+ kind: ClassVar[str] = "run_finished"
172
+ status: str
173
+ reason: str
174
+ seconds: float
175
+ requests: int
176
+ endpoints: int
177
+ report: str = ""
178
+ openapi: str = ""
179
+ report_html: str = ""
180
+ lessons_learned: int = 0
181
+ filtered: dict[str, int] = field(default_factory=dict)
182
+ generation_error: str = ""
183
+ exposure: str = "" # "1 endpoint exposes personal data without a login: ..."
184
+
185
+
186
+ Subscriber = Callable[[Event], object] # return values are ignored
187
+
188
+
189
+ class EventBus:
190
+ """Delivers events to subscribers, in order. Thread-safe; a failing subscriber never
191
+ breaks the run (its error is kept in `errors`)."""
192
+
193
+ def __init__(self) -> None:
194
+ self._subscribers: list[Subscriber] = []
195
+ self._lock = threading.Lock()
196
+ self.errors: list[str] = []
197
+ self.started = time.monotonic()
198
+
199
+ def subscribe(self, callback: Subscriber) -> None:
200
+ with self._lock:
201
+ self._subscribers.append(callback)
202
+
203
+ def emit(self, event: Event) -> None:
204
+ with self._lock:
205
+ subscribers = list(self._subscribers)
206
+ for callback in subscribers:
207
+ try:
208
+ callback(event)
209
+ except Exception as exc: # noqa: BLE001 - interfaces must not stop the run
210
+ message = f"{type(exc).__name__}: {exc}"
211
+ if not self.errors: # say it once, so a broken display can't flood the log
212
+ _log.warning("A display or listener failed (the run continues): %s", message)
213
+ self.errors.append(message)
ghost_api/exitcodes.py ADDED
@@ -0,0 +1,21 @@
1
+ """Exit codes of the `ghost-api` command, for scripts and CI. Stable across releases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ OK = 0 # done (a run may still have found nothing: read the JSON or the report)
6
+ FAILED = 1 # the command failed: an error, failed checks, nothing stored to work on
7
+ USAGE = 2 # wrong command line or setup: bad option, missing LLM key, bad project file
8
+ NOT_ALLOWED = 3 # the site said no: robots.txt disallows it, or a bot-protection page
9
+ INTERRUPTED = 130 # stopped with Ctrl+C
10
+
11
+ _BY_STATUS = {
12
+ "failed": FAILED,
13
+ "llm-unavailable": FAILED,
14
+ "bot-protection": NOT_ALLOWED,
15
+ "interrupted": INTERRUPTED,
16
+ }
17
+
18
+
19
+ def for_run_status(status: str) -> int:
20
+ """The exit code for a run or refresh that ended with `status`."""
21
+ return _BY_STATUS.get(status, OK)
@@ -0,0 +1,71 @@
1
+ """Write report.md, report.html and openapi.yaml for a site (Stage 9, UI phase 5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from ghost_api.export.html import ViewerMissing, build_report_html
10
+ from ghost_api.export.openapi import build_openapi, to_yaml
11
+ from ghost_api.export.report import build_report
12
+ from ghost_api.export.site_data import report_data
13
+ from ghost_api.generator.naming import NamingPlan, apply_plan
14
+ from ghost_api.generator.spec import ClientSpec
15
+ from ghost_api.memory.store import Store
16
+
17
+ __all__ = [
18
+ "Exports",
19
+ "write_exports",
20
+ ]
21
+
22
+
23
+ @dataclass
24
+ class Exports:
25
+ report: Path
26
+ openapi: Path
27
+ html: Path | None # None if the report viewer is not built
28
+
29
+
30
+ def write_exports(
31
+ store: Store,
32
+ domain: str,
33
+ run_id: int,
34
+ out_root: Path,
35
+ spec: ClientSpec | None = None,
36
+ ) -> Exports:
37
+ """Write the files into out_root/<domain>/ from what the database holds."""
38
+ run = store.get_run(run_id)
39
+ if run is None:
40
+ raise ValueError(f"run {run_id} not found")
41
+ endpoints = store.current_endpoints(domain)
42
+ plan = None
43
+ stored_plan = store.load_naming_plan(domain)
44
+ if stored_plan:
45
+ try:
46
+ plan = NamingPlan.model_validate_json(stored_plan)
47
+ except ValueError:
48
+ plan = None # fall back to URL-based names
49
+ _, names = apply_plan(plan, endpoints, domain)
50
+
51
+ folder = out_root / domain
52
+ folder.mkdir(parents=True, exist_ok=True)
53
+ openapi_path = folder / "openapi.yaml"
54
+ openapi_path.write_text(
55
+ to_yaml(build_openapi(domain, run.start_url, endpoints, names)), encoding="utf-8"
56
+ )
57
+ report_path = folder / "report.md"
58
+ markdown = build_report(store, domain, run_id, spec)
59
+ report_path.write_text(markdown, encoding="utf-8")
60
+
61
+ generated_at = datetime.now(UTC).isoformat(timespec="seconds")
62
+ data = report_data(store, domain, run_id, folder, markdown, generated_at)
63
+ html_path: Path | None = None
64
+ try:
65
+ page = build_report_html(data)
66
+ except ViewerMissing:
67
+ pass # a source checkout without the built viewer: report.md is still there
68
+ else:
69
+ html_path = folder / "report.html"
70
+ html_path.write_text(page, encoding="utf-8")
71
+ return Exports(report_path, openapi_path, html_path)
@@ -0,0 +1,45 @@
1
+ """How much to trust each endpoint, from evidence only.
2
+
3
+ Points:
4
+ verified by live tests +2
5
+ failed after repairs -3
6
+ callable directly (replayable) +1
7
+ pagination confirmed +1
8
+ samples: 2-4 -> +1, 5+ -> +2
9
+
10
+ High is 4 or more, Medium 2-3, Low below 2.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Literal
16
+
17
+ from ghost_api.analysis.models import Endpoint
18
+
19
+ Level = Literal["High", "Medium", "Low"]
20
+
21
+
22
+ def confidence_points(ep: Endpoint) -> int:
23
+ points = 0
24
+ if ep.status == "verified":
25
+ points += 2
26
+ elif ep.status == "failed":
27
+ points -= 3
28
+ if ep.replayable:
29
+ points += 1
30
+ if ep.pagination and ep.pagination.get("confirmed"):
31
+ points += 1
32
+ if ep.sample_count >= 5:
33
+ points += 2
34
+ elif ep.sample_count >= 2:
35
+ points += 1
36
+ return points
37
+
38
+
39
+ def confidence(ep: Endpoint) -> Level:
40
+ points = confidence_points(ep)
41
+ if points >= 4:
42
+ return "High"
43
+ if points >= 2:
44
+ return "Medium"
45
+ return "Low"
@@ -0,0 +1,41 @@
1
+ """report.html: one self-contained page to share a site's results (no server needed).
2
+
3
+ The page is the React report viewer built from web/ (`npm run build` writes
4
+ viewer/report.html with all scripts and styles inlined). This module puts the site's
5
+ data into it as JSON.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ TEMPLATE = Path(__file__).parent / "viewer" / "report.html"
15
+ DATA_TAG = '<script id="ghost-report" type="application/json">__GHOST_REPORT__</script>'
16
+
17
+
18
+ class ViewerMissing(RuntimeError):
19
+ """The report viewer was not built (run `npm run build` in web/)."""
20
+
21
+
22
+ def embed_json(data: Any) -> str:
23
+ """JSON that is safe inside a <script> element: no '</script>', '<!--' or '&' tricks."""
24
+ text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
25
+ return (
26
+ text.replace("&", "\\u0026")
27
+ .replace("<", "\\u003c")
28
+ .replace(">", "\\u003e")
29
+ .replace("
", "\\u2028")
30
+ .replace("
", "\\u2029")
31
+ )
32
+
33
+
34
+ def build_report_html(data: dict[str, Any], template: Path = TEMPLATE) -> str:
35
+ if not template.is_file():
36
+ raise ViewerMissing(f"report viewer not found at {template}")
37
+ page = template.read_text(encoding="utf-8")
38
+ if page.count(DATA_TAG) != 1:
39
+ raise ViewerMissing("report viewer has no data placeholder; rebuild it")
40
+ tag = DATA_TAG.replace("__GHOST_REPORT__", embed_json(data))
41
+ return page.replace(DATA_TAG, tag)
@@ -0,0 +1,201 @@
1
+ """Export the endpoint map as an OpenAPI 3.1 document (FR-30).
2
+
3
+ 3.1 is used because the inferred schemas are plain JSON Schema (for example
4
+ `"type": ["null", "string"]`), which 3.1 supports and 3.0 does not.
5
+
6
+ - Only read-only endpoints are exported, matching the generated client.
7
+ - Secrets never appear: secret parameters and headers become security schemes.
8
+ - Endpoints on other hosts (third-party APIs) get their own `servers` entry.
9
+ - Each operation carries `x-ghost-api` with status, confidence and sample count.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ from datetime import UTC, datetime
16
+ from typing import Any
17
+ from urllib.parse import urlsplit
18
+
19
+ import yaml
20
+
21
+ from ghost_api import __version__
22
+ from ghost_api.analysis.models import Endpoint
23
+ from ghost_api.capture.filters import is_sensitive_name
24
+ from ghost_api.capture.models import RawRequest
25
+ from ghost_api.export.confidence import confidence
26
+ from ghost_api.generator.naming import EndpointNames, pascal, snake
27
+ from ghost_api.probe.replay import is_replayable
28
+
29
+ OPENAPI_VERSION = "3.1.0"
30
+ JSON_TYPES = {"integer", "number", "boolean", "string"}
31
+
32
+
33
+ def build_openapi(
34
+ domain: str,
35
+ start_url: str,
36
+ endpoints: list[Endpoint],
37
+ names: dict[str, EndpointNames],
38
+ ) -> dict[str, Any]:
39
+ parts = urlsplit(start_url)
40
+ main_host = (parts.hostname or "").lower()
41
+ doc: dict[str, Any] = {
42
+ "openapi": OPENAPI_VERSION,
43
+ "info": {
44
+ "title": f"{domain} (unofficial)",
45
+ "version": datetime.now(UTC).strftime("%Y.%m.%d"),
46
+ "description": (
47
+ f"The internal API of {domain}, discovered by Ghost API {__version__} from the "
48
+ "site's own traffic. Unofficial: it can change without notice, and the site's "
49
+ "terms of service apply."
50
+ ),
51
+ },
52
+ "servers": [{"url": f"{parts.scheme}://{parts.netloc}"}],
53
+ "paths": {},
54
+ "components": {"schemas": {}, "securitySchemes": {}},
55
+ }
56
+ schemas: dict[str, Any] = doc["components"]["schemas"]
57
+ security_schemes: dict[str, Any] = doc["components"]["securitySchemes"]
58
+
59
+ for ep in endpoints:
60
+ if not _exportable(ep):
61
+ continue
62
+ n = names.get(ep.key)
63
+ operation: dict[str, Any] = {
64
+ "operationId": n.method_name if n else snake(f"{ep.method}_{ep.path_template}"),
65
+ "summary": n.summary if n else f"{ep.method} {ep.path_template}",
66
+ "parameters": [],
67
+ "responses": {},
68
+ "x-ghost-api": {
69
+ "status": ep.status,
70
+ "confidence": confidence(ep),
71
+ "samples": ep.sample_count,
72
+ "replayable": ep.replayable,
73
+ **({"pagination": ep.pagination} if ep.pagination else {}),
74
+ **({"note": ep.status_note} if ep.status_note else {}),
75
+ },
76
+ }
77
+ if ep.host != main_host:
78
+ operation["servers"] = [{"url": f"https://{ep.host}"}]
79
+
80
+ security: dict[str, list[str]] = {}
81
+ for pp in ep.path_params:
82
+ operation["parameters"].append(
83
+ {
84
+ "name": pp.name,
85
+ "in": "path",
86
+ "required": True,
87
+ "schema": _param_schema(pp.type, pp.examples),
88
+ }
89
+ )
90
+ for qp in ep.query_params:
91
+ if qp.sensitive or is_sensitive_name(qp.name):
92
+ scheme = _scheme_name(qp.name)
93
+ security_schemes[scheme] = {"type": "apiKey", "in": "query", "name": qp.name}
94
+ security[scheme] = []
95
+ continue
96
+ schema = _param_schema(qp.type, qp.examples)
97
+ is_page = bool(ep.pagination) and ep.pagination.get("param") == qp.name # type: ignore[union-attr]
98
+ if qp.constant and qp.examples:
99
+ schema["default"] = _typed(qp.examples[0], qp.type)
100
+ elif is_page and qp.examples:
101
+ # Paging starts at the lowest value seen (page 1, offset 0...).
102
+ values = [_typed(e, qp.type) for e in qp.examples]
103
+ numbers = [v for v in values if isinstance(v, int | float)]
104
+ schema["default"] = min(numbers) if numbers else values[0]
105
+ operation["parameters"].append(
106
+ {
107
+ "name": qp.name,
108
+ "in": "query",
109
+ "required": qp.required and not qp.constant and not is_page,
110
+ "schema": schema,
111
+ }
112
+ )
113
+ for header in ep.required_headers:
114
+ if header.lower() == "cookie":
115
+ security_schemes["sessionCookie"] = {
116
+ "type": "apiKey",
117
+ "in": "cookie",
118
+ "name": "session",
119
+ "description": "Session cookie(s) from a logged-in browser.",
120
+ }
121
+ security["sessionCookie"] = []
122
+ elif header.lower() == "authorization":
123
+ kind = "basic" if ep.auth_type == "basic" else "bearer"
124
+ security_schemes[f"{kind}Auth"] = {"type": "http", "scheme": kind}
125
+ security[f"{kind}Auth"] = []
126
+ elif is_sensitive_name(header):
127
+ scheme = _scheme_name(header)
128
+ security_schemes[scheme] = {"type": "apiKey", "in": "header", "name": header}
129
+ security[scheme] = []
130
+ else:
131
+ operation["parameters"].append(
132
+ {"name": header, "in": "header", "required": True, "schema": {"type": "string"}}
133
+ )
134
+ if security:
135
+ operation["security"] = [security]
136
+ if not operation["parameters"]:
137
+ del operation["parameters"]
138
+
139
+ if ep.request_schema is not None:
140
+ operation["requestBody"] = {
141
+ "required": True,
142
+ "content": {"application/json": {"schema": _clean(ep.request_schema)}},
143
+ }
144
+ model = n.response_model if n else pascal(f"{ep.method}_{ep.path_template}") + "Response"
145
+ if ep.response_schema is not None:
146
+ schemas[model] = _clean(ep.response_schema)
147
+ content = {"application/json": {"schema": {"$ref": f"#/components/schemas/{model}"}}}
148
+ operation["responses"]["200"] = {"description": "Success", "content": content}
149
+ else:
150
+ operation["responses"]["200"] = {"description": "Success (no JSON sample captured)"}
151
+
152
+ path_item = doc["paths"].setdefault(ep.path_template, {})
153
+ path_item[ep.method.lower()] = operation
154
+
155
+ if not schemas:
156
+ del doc["components"]["schemas"]
157
+ if not security_schemes:
158
+ del doc["components"]["securitySchemes"]
159
+ if not doc["components"]:
160
+ del doc["components"]
161
+ return doc
162
+
163
+
164
+ def to_yaml(doc: dict[str, Any]) -> str:
165
+ return yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100)
166
+
167
+
168
+ def _exportable(ep: Endpoint) -> bool:
169
+ probe = RawRequest(ep.method, f"https://{ep.host}{ep.path_template}", {}, None)
170
+ return is_replayable(probe) and ep.replayable is not False
171
+
172
+
173
+ def _param_schema(json_type: str, examples: list[str]) -> dict[str, Any]:
174
+ schema: dict[str, Any] = {"type": json_type if json_type in JSON_TYPES else "string"}
175
+ if examples:
176
+ schema["examples"] = [_typed(e, json_type) for e in examples[:3]]
177
+ return schema
178
+
179
+
180
+ def _typed(value: str, json_type: str) -> Any:
181
+ try:
182
+ if json_type == "integer":
183
+ return int(value)
184
+ if json_type == "number":
185
+ return float(value)
186
+ except ValueError:
187
+ return value
188
+ if json_type == "boolean":
189
+ return value.lower() == "true"
190
+ return value
191
+
192
+
193
+ def _scheme_name(wire: str) -> str:
194
+ return pascal(wire)[:1].lower() + pascal(wire)[1:]
195
+
196
+
197
+ def _clean(schema: dict[str, Any]) -> dict[str, Any]:
198
+ """A copy without genson's '$schema' marker (not allowed inside OpenAPI)."""
199
+ out = copy.deepcopy(schema)
200
+ out.pop("$schema", None)
201
+ return out