api-medic 1.0.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 (43) hide show
  1. api_medic/__init__.py +3 -0
  2. api_medic/cli/__init__.py +1 -0
  3. api_medic/cli/main.py +306 -0
  4. api_medic/core/__init__.py +23 -0
  5. api_medic/core/captured.py +94 -0
  6. api_medic/core/checks/__init__.py +52 -0
  7. api_medic/core/checks/auth.py +248 -0
  8. api_medic/core/checks/body.py +139 -0
  9. api_medic/core/checks/http.py +208 -0
  10. api_medic/core/checks/network.py +215 -0
  11. api_medic/core/checks/rate_limit.py +129 -0
  12. api_medic/core/engine.py +72 -0
  13. api_medic/core/export_schema.py +24 -0
  14. api_medic/core/models.py +139 -0
  15. api_medic/core/parser.py +141 -0
  16. api_medic/core/render/__init__.py +8 -0
  17. api_medic/core/render/html.py +205 -0
  18. api_medic/core/render/json.py +14 -0
  19. api_medic/core/render/markdown.py +136 -0
  20. api_medic/core/render/terminal.py +159 -0
  21. api_medic/core/runner.py +248 -0
  22. api_medic/core/runner_safety.py +97 -0
  23. api_medic/web/__init__.py +1 -0
  24. api_medic/web/__main__.py +5 -0
  25. api_medic/web/app.py +87 -0
  26. api_medic/web/frontend/assets/index-B9VfTj3I.css +1 -0
  27. api_medic/web/frontend/assets/index-DdtPIxCH.js +40 -0
  28. api_medic/web/frontend/fixtures/01-healthy.json +49 -0
  29. api_medic/web/frontend/fixtures/02-jwt-expired.json +62 -0
  30. api_medic/web/frontend/fixtures/03-rate-limited.json +53 -0
  31. api_medic/web/frontend/fixtures/04-cors-misconfigured.json +63 -0
  32. api_medic/web/frontend/fixtures/05-tls-expiring.json +50 -0
  33. api_medic/web/frontend/fixtures/06-malformed-body.json +52 -0
  34. api_medic/web/frontend/fixtures/07-redirect-loop.json +42 -0
  35. api_medic/web/frontend/fixtures/08-slow-tls.json +60 -0
  36. api_medic/web/frontend/fixtures/index.json +1 -0
  37. api_medic/web/frontend/index.html +13 -0
  38. api_medic/web/server.py +25 -0
  39. api_medic-1.0.0.dist-info/METADATA +135 -0
  40. api_medic-1.0.0.dist-info/RECORD +43 -0
  41. api_medic-1.0.0.dist-info/WHEEL +4 -0
  42. api_medic-1.0.0.dist-info/entry_points.txt +2 -0
  43. api_medic-1.0.0.dist-info/licenses/LICENSE +21 -0
api_medic/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """api-medic — diagnostic tool for HTTP API issues."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """CLI surface (Typer). Populated in Phase 4."""
api_medic/cli/main.py ADDED
@@ -0,0 +1,306 @@
1
+ """api-medic command-line interface.
2
+
3
+ The `api-medic` entry point lives at api_medic.cli.main:cli_entry
4
+ (see pyproject.toml). `api-medic <URL>` is shorthand for `api-medic run
5
+ <URL>` — the bare-URL form is handled by `_rewrite_bare_url` in
6
+ `cli_entry` before Typer parses, since putting a positional on the
7
+ parent group would swallow subcommand names like `serve`.
8
+
9
+ Subcommands:
10
+ run Execute an HTTP request and produce a Report.
11
+ from-curl Parse a curl command and (by default) execute it.
12
+ from-har Analyze a captured HAR file (no execution).
13
+ serve Launch the local web UI.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import sys
20
+ from enum import Enum
21
+ from importlib.metadata import PackageNotFoundError
22
+ from importlib.metadata import version as _pkg_version
23
+ from pathlib import Path
24
+ from typing import Annotated
25
+
26
+ import typer
27
+
28
+ from ..core.engine import analyze
29
+ from ..core.models import Report
30
+ from ..core.parser import parse_curl, parse_har
31
+ from ..core.render import render_html, render_json, render_markdown, render_terminal
32
+ from ..core.runner import run as runner_run
33
+
34
+ try:
35
+ __version__ = _pkg_version("api-medic")
36
+ except PackageNotFoundError: # pragma: no cover
37
+ __version__ = "0.0.0+unknown"
38
+
39
+
40
+ app = typer.Typer(
41
+ name="api-medic",
42
+ help="Diagnose HTTP API issues — run a request, parse a curl, analyze a HAR.",
43
+ add_completion=False,
44
+ no_args_is_help=True,
45
+ )
46
+
47
+ _SUBCOMMANDS = {"run", "from-curl", "from-har", "serve"}
48
+
49
+
50
+ def _rewrite_bare_url(argv: list[str]) -> list[str]:
51
+ """Rewrite `<URL> [flags]` to `run <URL> [flags]` so the bare-URL form
52
+ works without putting a positional on the parent callback (which would
53
+ swallow subcommand names like `serve`).
54
+ """
55
+ if not argv:
56
+ return argv
57
+ first = argv[0]
58
+ if first.startswith(("http://", "https://")) and first not in _SUBCOMMANDS:
59
+ return ["run", *argv]
60
+ return argv
61
+
62
+
63
+ def cli_entry() -> None:
64
+ """Production entry point referenced by pyproject.toml's `api-medic` script."""
65
+ # On Windows the default console encoding is cp1252, which can't encode
66
+ # the arrow / em-dash glyphs the renderers use. Reconfigure to UTF-8 so
67
+ # output round-trips on every platform the architecture commits to
68
+ # (macOS, Linux, Windows).
69
+ for stream in (sys.stdout, sys.stderr):
70
+ reconfigure = getattr(stream, "reconfigure", None)
71
+ if reconfigure is not None:
72
+ with contextlib.suppress(OSError, ValueError):
73
+ reconfigure(encoding="utf-8")
74
+
75
+ argv = _rewrite_bare_url(sys.argv[1:])
76
+ app(argv)
77
+
78
+
79
+ class OutputFormat(str, Enum):
80
+ terminal = "terminal"
81
+ json = "json"
82
+ markdown = "markdown"
83
+ html = "html"
84
+
85
+
86
+ def _version_callback(value: bool) -> None:
87
+ if value:
88
+ typer.echo(f"api-medic {__version__}")
89
+ raise typer.Exit()
90
+
91
+
92
+ # Shared option types -------------------------------------------------------
93
+
94
+ OutputOption = Annotated[
95
+ OutputFormat,
96
+ typer.Option("--output", "-o", help="Output format.", case_sensitive=False),
97
+ ]
98
+
99
+ SaveOption = Annotated[
100
+ Path | None,
101
+ typer.Option("--save", help="Write the rendered report to a file instead of stdout."),
102
+ ]
103
+
104
+ NoColorOption = Annotated[
105
+ bool, typer.Option("--no-color", help="Disable ANSI colors in terminal output.")
106
+ ]
107
+
108
+ VerboseOption = Annotated[
109
+ bool, typer.Option("--verbose", "-v", help="Print a one-line findings summary to stderr.")
110
+ ]
111
+
112
+
113
+ # Helpers -------------------------------------------------------------------
114
+
115
+
116
+ def _emit(
117
+ report: Report,
118
+ *,
119
+ output: OutputFormat,
120
+ save: Path | None,
121
+ no_color: bool,
122
+ ) -> None:
123
+ """Render `report` and write it to stdout (or `save` if given)."""
124
+ if output is OutputFormat.terminal:
125
+ # Colour when stdout is a terminal AND user didn't suppress. When
126
+ # writing to a file we still emit colour — modern terminals can `cat`
127
+ # ANSI files happily — except when --no-color is set.
128
+ color = not no_color and (save is not None or sys.stdout.isatty())
129
+ text = render_terminal(report, color=color)
130
+ elif output is OutputFormat.json:
131
+ text = render_json(report)
132
+ elif output is OutputFormat.markdown:
133
+ text = render_markdown(report)
134
+ elif output is OutputFormat.html:
135
+ text = render_html(report)
136
+ else: # pragma: no cover — Enum exhausts above
137
+ raise typer.BadParameter(f"Unknown output format: {output}")
138
+
139
+ if save is not None:
140
+ save.write_text(text, encoding="utf-8")
141
+ typer.echo(f"Wrote {save}", err=True)
142
+ else:
143
+ typer.echo(text)
144
+
145
+
146
+ def _emit_findings_summary(report: Report) -> None:
147
+ """One-line stderr summary, used by --verbose."""
148
+ n = len(report.findings)
149
+ if n == 0:
150
+ typer.echo("No findings.", err=True)
151
+ return
152
+ critical = sum(1 for f in report.findings if f.severity == "critical")
153
+ warning = sum(1 for f in report.findings if f.severity == "warning")
154
+ info = sum(1 for f in report.findings if f.severity == "info")
155
+ typer.echo(
156
+ f"Findings: {n} total ({critical} critical, {warning} warning, {info} info)",
157
+ err=True,
158
+ )
159
+
160
+
161
+ def _parse_headers(items: list[str]) -> dict[str, str]:
162
+ """Convert ['Name: value', ...] to {Name: value, ...}."""
163
+ out: dict[str, str] = {}
164
+ for raw in items:
165
+ if ":" not in raw:
166
+ raise typer.BadParameter(f"Header must be 'Name: value' — got {raw!r}.")
167
+ name, value = raw.split(":", 1)
168
+ out[name.strip()] = value.strip()
169
+ return out
170
+
171
+
172
+ # Callback (top-level options only — bare-URL is handled by cli_entry) -----
173
+
174
+
175
+ @app.callback()
176
+ def main(
177
+ _version: Annotated[
178
+ bool,
179
+ typer.Option(
180
+ "--version",
181
+ callback=_version_callback,
182
+ is_eager=True,
183
+ help="Show the api-medic version and exit.",
184
+ ),
185
+ ] = False,
186
+ ) -> None:
187
+ """api-medic — diagnose HTTP API issues.
188
+
189
+ Pass a URL as the first argument as a shortcut for `run <URL>` —
190
+ e.g. `api-medic https://api.example.com/health`.
191
+ """
192
+ pass
193
+
194
+
195
+ # Subcommands --------------------------------------------------------------
196
+
197
+
198
+ @app.command()
199
+ def run(
200
+ url: Annotated[str, typer.Argument(help="URL to request.")],
201
+ method: Annotated[str, typer.Option("--method", "-X", help="HTTP method.")] = "GET",
202
+ header: Annotated[
203
+ list[str] | None,
204
+ typer.Option(
205
+ "--header",
206
+ "-H",
207
+ help="Header in 'Name: value' form. Repeat for multiple.",
208
+ ),
209
+ ] = None,
210
+ body: Annotated[
211
+ str | None,
212
+ typer.Option("--body", "-d", help="Request body as a literal string."),
213
+ ] = None,
214
+ body_file: Annotated[
215
+ Path | None,
216
+ typer.Option("--body-file", help="Read the request body from a file."),
217
+ ] = None,
218
+ timeout: Annotated[
219
+ float, typer.Option("--timeout", help="Per-request timeout in seconds.")
220
+ ] = 30.0,
221
+ output: OutputOption = OutputFormat.terminal,
222
+ save: SaveOption = None,
223
+ no_color: NoColorOption = False,
224
+ verbose: VerboseOption = False,
225
+ ) -> None:
226
+ """Execute an HTTP request and produce a diagnostic report."""
227
+ if body is not None and body_file is not None:
228
+ raise typer.BadParameter("--body and --body-file are mutually exclusive.")
229
+
230
+ body_data: str | bytes | None = body_file.read_bytes() if body_file is not None else body
231
+
232
+ headers = _parse_headers(header or [])
233
+
234
+ captured = runner_run(method, url, headers=headers, body=body_data, timeout=timeout)
235
+ report = analyze(captured)
236
+ if verbose:
237
+ _emit_findings_summary(report)
238
+ _emit(report, output=output, save=save, no_color=no_color)
239
+
240
+
241
+ @app.command(name="from-curl")
242
+ def from_curl(
243
+ curl: Annotated[str, typer.Argument(help="curl command string to parse.")],
244
+ no_execute: Annotated[
245
+ bool,
246
+ typer.Option(
247
+ "--no-execute",
248
+ help=(
249
+ "Skip executing the parsed request — analyze the request shape "
250
+ "only. Useful when inspecting credentials you don't want to send."
251
+ ),
252
+ ),
253
+ ] = False,
254
+ output: OutputOption = OutputFormat.terminal,
255
+ save: SaveOption = None,
256
+ no_color: NoColorOption = False,
257
+ verbose: VerboseOption = False,
258
+ ) -> None:
259
+ """Parse a curl command and (by default) execute it."""
260
+ captured = parse_curl(curl)
261
+ if not no_execute:
262
+ # Re-issue the parsed request through the live Runner so we get a real
263
+ # response, timing, and DNS/TLS data.
264
+ captured = runner_run(
265
+ captured.method,
266
+ captured.url,
267
+ headers=captured.headers,
268
+ body=captured.body if captured.body else None,
269
+ )
270
+ report = analyze(captured)
271
+ if verbose:
272
+ _emit_findings_summary(report)
273
+ _emit(report, output=output, save=save, no_color=no_color)
274
+
275
+
276
+ @app.command(name="from-har")
277
+ def from_har(
278
+ file: Annotated[Path, typer.Argument(help="Path to a .har file.")],
279
+ output: OutputOption = OutputFormat.terminal,
280
+ save: SaveOption = None,
281
+ no_color: NoColorOption = False,
282
+ verbose: VerboseOption = False,
283
+ ) -> None:
284
+ """Analyze a captured HAR file (no execution)."""
285
+ raw = file.read_text(encoding="utf-8")
286
+ captured = parse_har(raw)
287
+ report = analyze(captured)
288
+ if verbose:
289
+ _emit_findings_summary(report)
290
+ _emit(report, output=output, save=save, no_color=no_color)
291
+
292
+
293
+ @app.command()
294
+ def serve(
295
+ host: Annotated[str, typer.Option("--host", help="Host to bind.")] = "127.0.0.1",
296
+ port: Annotated[int, typer.Option("--port", help="Port to listen on.")] = 8765,
297
+ ) -> None:
298
+ """Start the local web UI."""
299
+ import uvicorn
300
+
301
+ typer.echo(f"api-medic web UI on http://{host}:{port} (Ctrl+C to stop)", err=True)
302
+ uvicorn.run("api_medic.web.app:app", host=host, port=port, reload=False)
303
+
304
+
305
+ if __name__ == "__main__": # pragma: no cover
306
+ cli_entry()
@@ -0,0 +1,23 @@
1
+ """Core engine: pure-Python diagnostic library with no web/CLI/AWS dependencies."""
2
+
3
+ from api_medic.core.models import (
4
+ Finding,
5
+ Report,
6
+ RequestSummary,
7
+ ResponseSummary,
8
+ Severity,
9
+ Source,
10
+ TimingBreakdown,
11
+ )
12
+
13
+ __all__ = [
14
+ "Finding",
15
+ "Report",
16
+ "RequestSummary",
17
+ "ResponseSummary",
18
+ "Severity",
19
+ "Source",
20
+ "TimingBreakdown",
21
+ ]
22
+
23
+ SCHEMA_VERSION = "1.0"
@@ -0,0 +1,94 @@
1
+ """Internal types used by the engine before it produces a Report.
2
+
3
+ CapturedRequest is what Runner and Parser both produce. It carries full
4
+ request and response bodies and any extra metadata the checks need, before
5
+ the engine summarises it down into a public-shape Report (with truncated
6
+ body previews) for downstream consumers.
7
+
8
+ These types are deliberately separate from `models.py` — they're an internal
9
+ contract, not the public schema, and don't need TypeScript codegen.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from .models import Source, TimingBreakdown
19
+
20
+
21
+ class CapturedDns(BaseModel):
22
+ """DNS lookup result for the request's host. None when DNS wasn't probed
23
+ (HAR/curl sources, or live runs with probing disabled)."""
24
+
25
+ model_config = ConfigDict(extra="forbid")
26
+
27
+ records: list[str] = Field(
28
+ default_factory=list, description="Resolved A/AAAA records (IPv4 + IPv6)."
29
+ )
30
+
31
+
32
+ class CapturedTls(BaseModel):
33
+ """TLS handshake details — populated only for HTTPS live runs. None for
34
+ HAR, curl, and HTTP-scheme requests."""
35
+
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+ not_before: datetime
39
+ not_after: datetime
40
+ subject_common_name: str | None = None
41
+ subject_alt_names: list[str] = Field(default_factory=list)
42
+ issuer_common_name: str | None = None
43
+ negotiated_protocol_version: str = Field(default="", description="e.g. 'TLSv1.2', 'TLSv1.3'.")
44
+
45
+
46
+ class CapturedResponse(BaseModel):
47
+ """Full response captured by the Runner, or extracted from a HAR/raw
48
+ HTTP source by the Parser. Body is the raw bytes; renderers truncate
49
+ for display.
50
+
51
+ `raw_headers` is the original (name, value) sequence preserving
52
+ duplicates and case. None when the source didn't preserve them
53
+ (Phase 3a Runner output, for instance — populated in 3b-F).
54
+ """
55
+
56
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
57
+
58
+ status_code: int = Field(..., ge=100, le=599)
59
+ status_text: str = ""
60
+ headers: dict[str, str] = Field(default_factory=dict)
61
+ raw_headers: list[tuple[str, str]] | None = None
62
+ body: bytes = b""
63
+ protocol: str = "HTTP/1.1"
64
+
65
+
66
+ class CapturedRequest(BaseModel):
67
+ """Full captured request + optional response, used by every check.
68
+
69
+ `response` is None for inputs that don't include one (e.g. a curl
70
+ command we haven't executed yet). Checks that need a response should
71
+ no-op when it's missing.
72
+
73
+ `raw_headers` mirrors CapturedResponse.raw_headers — original
74
+ (name, value) sequence with duplicates preserved.
75
+
76
+ `redirect_chain` is the list of URLs visited starting from the
77
+ user-supplied URL and ending at the final one. None when not captured
78
+ (HAR/curl don't carry it; Runner populates in 3b-F).
79
+ """
80
+
81
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
82
+
83
+ method: str
84
+ url: str
85
+ headers: dict[str, str] = Field(default_factory=dict)
86
+ raw_headers: list[tuple[str, str]] | None = None
87
+ body: bytes = b""
88
+
89
+ response: CapturedResponse | None = None
90
+ timing: TimingBreakdown = Field(default_factory=TimingBreakdown)
91
+ redirect_chain: list[str] | None = None
92
+ dns: CapturedDns | None = None
93
+ tls: CapturedTls | None = None
94
+ source: Source
@@ -0,0 +1,52 @@
1
+ """Check registry.
2
+
3
+ A check is a function `(CapturedRequest) -> Finding | list[Finding] | None`.
4
+ Returning `None` means the check didn't fire — return only when something
5
+ is actually wrong (or worth reporting as info).
6
+
7
+ Each check module under `core.checks` registers its functions on import. The
8
+ engine glue (core.engine) imports those modules so registration happens
9
+ before `run_all_checks` is called.
10
+
11
+ Tests that want isolation can instantiate their own `CheckRegistry` rather
12
+ than touching `default_registry`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+
19
+ from ..captured import CapturedRequest
20
+ from ..models import Finding
21
+
22
+ CheckResult = Finding | list[Finding] | None
23
+ CheckFn = Callable[[CapturedRequest], CheckResult]
24
+
25
+
26
+ class CheckRegistry:
27
+ def __init__(self) -> None:
28
+ self._checks: list[CheckFn] = []
29
+
30
+ def register(self, fn: CheckFn) -> CheckFn:
31
+ self._checks.append(fn)
32
+ return fn
33
+
34
+ def all(self) -> list[CheckFn]:
35
+ return list(self._checks)
36
+
37
+ def run(self, captured: CapturedRequest) -> list[Finding]:
38
+ out: list[Finding] = []
39
+ for fn in self._checks:
40
+ result = fn(captured)
41
+ if result is None:
42
+ continue
43
+ if isinstance(result, list):
44
+ out.extend(result)
45
+ else:
46
+ out.append(result)
47
+ return out
48
+
49
+
50
+ default_registry = CheckRegistry()
51
+ register = default_registry.register
52
+ run_all_checks = default_registry.run