Skip to content

Commit 3aa3a18

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
feat(doctor): hawkapi doctor — one-shot health check CLI with 18 rules across 5 categories
Adds `hawkapi doctor app:app` subcommand that lints a live HawkAPI instance against 18 rules spanning security (DOC010-014), observability (DOC020-023), performance (DOC030-033), correctness (DOC040-042), and deps (DOC050-051). Outputs human-readable or JSON reports; exits 0/1/2.
1 parent e201656 commit 3aa3a18

16 files changed

Lines changed: 1647 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- `hawkapi doctor <APP_SPEC>` — one-shot health-check CLI that lints a running HawkAPI app against 18 rules across 5 categories (security, observability, performance, correctness, deps). Human and JSON output, `--severity` filter, `--fix` scaffold, exit codes 0/1/2. Target v0.1.4.
11+
1012
## [0.1.3] - 2026-04-19
1113

1214
### Added

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,11 @@ hawkapi migrate path/to/fastapi_project
10041004
hawkapi migrate path/to/fastapi_project --dry-run # preview diffs only
10051005
hawkapi migrate path/to/fastapi_project --convert-models # also rewrite Pydantic BaseModel → msgspec.Struct
10061006
hawkapi migrate path/to/fastapi_project --output ./migrated # write to separate directory
1007+
1008+
# Health-check a running app (18 rules across 5 categories)
1009+
hawkapi doctor app:app
1010+
hawkapi doctor app:app --format=json
1011+
hawkapi doctor app:app --severity=warn # only warnings and errors
10071012
```
10081013

10091014
`hawkapi init` creates `.env` and `.env.example` files with commented-out HawkAPI configuration templates. Existing files are skipped.
@@ -1012,6 +1017,8 @@ hawkapi migrate path/to/fastapi_project --output ./migrated # write to separate
10121017

10131018
`hawkapi migrate` uses AST rewriting (libcst) to replace FastAPI imports, decorators, and patterns with their HawkAPI equivalents. See [docs/guide/migration-from-fastapi.md](docs/guide/migration-from-fastapi.md).
10141019

1020+
`hawkapi doctor` lints a live app for security misconfigurations, missing observability middleware, performance anti-patterns, and outdated dependencies. Exits 0 (clean), 1 (warnings), or 2 (errors). See [docs/guide/doctor.md](docs/guide/doctor.md).
1021+
10151022
---
10161023

10171024
## Configuration

docs/guide/doctor.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Doctor
2+
3+
`hawkapi doctor` is a one-shot health-check CLI that lints a running HawkAPI application for common misconfigurations across security, observability, performance, correctness, and dependency hygiene.
4+
5+
## Usage
6+
7+
```bash
8+
hawkapi doctor <APP_SPEC> [--format={human,json}] [--severity={info,warn,error}] [--fix]
9+
```
10+
11+
| Argument | Default | Description |
12+
|---|---|---|
13+
| `APP_SPEC` | required | `module:attr` reference, e.g. `main:app` |
14+
| `--format` | `human` | Output format: `human` (coloured TTY) or `json` |
15+
| `--severity` | `info` | Minimum severity to report: `info`, `warn`, or `error` |
16+
| `--fix` | off | Apply safe auto-fixes (v1: prints a notice; no fixes implemented yet) |
17+
18+
**Exit codes:**
19+
20+
| Code | Meaning |
21+
|---|---|
22+
| `0` | No findings at or above the chosen severity |
23+
| `1` | At least one warning |
24+
| `2` | At least one error |
25+
26+
### Example
27+
28+
```bash
29+
$ hawkapi doctor main:app
30+
hawkapi doctor — main:app
31+
32+
✗ DOC010 DOC010
33+
CORSMiddleware allows all origins ('*'). This exposes all endpoints to any origin.
34+
Fix: Whitelist specific origins in CORSMiddleware(allow_origins=[...]).
35+
https://hawkapi.ashimov.com/doctor/DOC010
36+
37+
⚠ DOC011 DOC011
38+
State-changing routes (POST/PUT/PATCH/DELETE) exist but CSRFMiddleware is not installed.
39+
Fix: Add app.add_middleware(CSRFMiddleware, secret=...) to protect browser-facing endpoints.
40+
https://hawkapi.ashimov.com/doctor/DOC011
41+
42+
Summary: 1 errors, 1 warnings, 0 info · exit 2
43+
```
44+
45+
### JSON output
46+
47+
```bash
48+
hawkapi doctor main:app --format=json
49+
```
50+
51+
```json
52+
{
53+
"app": "main:app",
54+
"summary": {"errors": 1, "warnings": 1, "info": 0, "total": 2},
55+
"findings": [
56+
{
57+
"rule_id": "DOC010",
58+
"severity": "error",
59+
"message": "CORSMiddleware allows all origins ('*').",
60+
"fix": "Whitelist specific origins in CORSMiddleware(allow_origins=[...]).",
61+
"location": null,
62+
"docs_url": "https://hawkapi.ashimov.com/doctor/DOC010"
63+
}
64+
]
65+
}
66+
```
67+
68+
## Rule reference
69+
70+
### Security
71+
72+
| ID | Severity | Title | Fix |
73+
|---|---|---|---|
74+
| `DOC010` | error | CORS allows `*` in production | Whitelist specific origins in `CORSMiddleware(allow_origins=[...])` |
75+
| `DOC011` | warn | CSRFMiddleware not installed but state-changing routes exist | Add `CSRFMiddleware` |
76+
| `DOC012` | warn | TrustedProxyMiddleware missing behind a known proxy | Add `TrustedProxyMiddleware` |
77+
| `DOC013` | error | Hardcoded placeholder secrets on `app.state` | Load secrets from env vars or a secrets manager |
78+
| `DOC014` | info/warn | HTTPSRedirectMiddleware absent | Add `HTTPSRedirectMiddleware` (warn if `ENV=production`) |
79+
80+
### Observability
81+
82+
| ID | Severity | Title | Fix |
83+
|---|---|---|---|
84+
| `DOC020` | warn | No request-ID / observability middleware | Add `RequestIDMiddleware` or `StructuredLoggingMiddleware` |
85+
| `DOC021` | info | No `/metrics` endpoint (prometheus_client installed) | Add `PrometheusMiddleware` |
86+
| `DOC022` | info | opentelemetry installed but no OTel wiring | Install hawkapi-otel and register the plugin |
87+
| `DOC023` | info | sentry_sdk installed but no Sentry wiring | Install hawkapi-sentry and register the plugin |
88+
89+
### Performance
90+
91+
| ID | Severity | Title | Fix |
92+
|---|---|---|---|
93+
| `DOC030` | info/error | `debug=True` in production | Set `debug=False` (error if `ENV=production`) |
94+
| `DOC031` | warn | GZipMiddleware absent and routes return large payloads | Add `GZipMiddleware(minimum_size=1000)` |
95+
| `DOC032` | warn | Handler returns bare `dict`/`list` without `response_model` | Add a msgspec.Struct return annotation |
96+
| `DOC033` | info | Heavy I/O routes without a bulkhead | Wrap with `@bulkhead(...)` |
97+
98+
### Correctness
99+
100+
| ID | Severity | Title | Fix |
101+
|---|---|---|---|
102+
| `DOC040` | info | Route handler missing return annotation | Add a return type annotation |
103+
| `DOC041` | info | Route without docstring or `summary=` | Add a docstring or `summary=` parameter |
104+
| `DOC042` | warn | Suspicious middleware order: CORS after auth | Add `CORSMiddleware` before authentication middleware |
105+
106+
### Dependencies
107+
108+
| ID | Severity | Title | Fix |
109+
|---|---|---|---|
110+
| `DOC050` | info | HawkAPI version older than latest on PyPI | `pip install --upgrade hawkapi` |
111+
| `DOC051` | warn | msgspec < 0.19 | `pip install --upgrade 'msgspec>=0.19'` |
112+
113+
## `--fix` mode
114+
115+
`--fix` is reserved for safe, deterministic auto-fixes. In v1, no rules implement `auto_fix`, so the flag prints a notice and exits normally. Future rules may opt in on a case-by-case basis.
116+
117+
## CI integration
118+
119+
Add a step to your GitHub Actions workflow to gate deployments on doctor output:
120+
121+
```yaml
122+
- name: hawkapi doctor
123+
run: |
124+
uv run hawkapi doctor main:app --severity=warn --format=json
125+
```
126+
127+
This step exits non-zero if any warning or error is found, blocking the workflow. Use `--severity=error` to only gate on errors:
128+
129+
```yaml
130+
- name: hawkapi doctor (errors only)
131+
run: uv run hawkapi doctor main:app --severity=error
132+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ nav:
5757
- Bulkhead: guide/bulkhead.md
5858
- Free-threaded Python (PEP 703): guide/free-threaded.md
5959
- Migration from FastAPI: guide/migration-from-fastapi.md
60+
- Doctor: guide/doctor.md
6061
- API Reference: api/index.md
6162

6263
markdown_extensions:

src/hawkapi/cli.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,34 @@ def main(argv: list[str] | None = None) -> None:
149149
_source.add_argument("--spec", help="path to openapi.json")
150150
p_gen.add_argument("--out", required=True, help="output directory")
151151

152+
# `hawkapi doctor` subcommand
153+
doctor_parser = subparsers.add_parser(
154+
"doctor",
155+
help="Run health checks on a HawkAPI application",
156+
)
157+
doctor_parser.add_argument(
158+
"app",
159+
help="Application to check (module:attribute format, e.g. main:app)",
160+
)
161+
doctor_parser.add_argument(
162+
"--format",
163+
dest="output_format",
164+
choices=["human", "json"],
165+
default="human",
166+
help="Output format (default: human)",
167+
)
168+
doctor_parser.add_argument(
169+
"--severity",
170+
choices=["info", "warn", "error"],
171+
default="info",
172+
help="Minimum severity to report (default: info)",
173+
)
174+
doctor_parser.add_argument(
175+
"--fix",
176+
action="store_true",
177+
help="Apply safe auto-fixes where available (v1: none implemented)",
178+
)
179+
152180
# `hawkapi migrate` subcommand
153181
migrate_parser = subparsers.add_parser(
154182
"migrate",
@@ -192,12 +220,39 @@ def main(argv: list[str] | None = None) -> None:
192220
_run_new(args)
193221
elif args.command == "init":
194222
_run_init(args)
223+
elif args.command == "doctor":
224+
sys.exit(_run_doctor(args))
195225
elif args.command == "migrate":
196226
_run_migrate(args)
197227
elif args.command == "gen-client":
198228
_run_gen_client(args)
199229

200230

231+
def _run_doctor(args: argparse.Namespace) -> int:
232+
"""Run health checks on the given application and return an exit code."""
233+
from hawkapi.doctor._formatter import exit_code, format_human, format_json
234+
from hawkapi.doctor._runner import load_app, run
235+
from hawkapi.doctor._types import Severity
236+
237+
module_path, attr_name = _parse_ref(args.app)
238+
app = load_app(module_path, attr_name)
239+
240+
_SEV_MAP = {"info": Severity.INFO, "warn": Severity.WARN, "error": Severity.ERROR}
241+
min_sev = _SEV_MAP[args.severity]
242+
243+
if args.fix:
244+
print("--fix: no auto-fixable findings in v1.")
245+
246+
findings = run(app, min_severity=min_sev)
247+
248+
if args.output_format == "json":
249+
print(format_json(findings, args.app))
250+
else:
251+
print(format_human(findings, args.app))
252+
253+
return exit_code(findings)
254+
255+
201256
def _run_dev(args: argparse.Namespace) -> None:
202257
"""Run the development server using uvicorn."""
203258
try:

src/hawkapi/doctor/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""hawkapi doctor — one-shot health-check CLI for HawkAPI applications."""
2+
3+
from __future__ import annotations
4+
5+
from hawkapi.doctor._formatter import exit_code, format_human, format_json
6+
from hawkapi.doctor._runner import load_app, run
7+
from hawkapi.doctor._types import Finding, Rule, Severity
8+
from hawkapi.doctor.rules import ALL_RULES
9+
10+
__all__ = [
11+
"ALL_RULES",
12+
"Finding",
13+
"Rule",
14+
"Severity",
15+
"exit_code",
16+
"format_human",
17+
"format_json",
18+
"load_app",
19+
"run",
20+
]

src/hawkapi/doctor/_formatter.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Human-readable and JSON output formatters for doctor findings."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
from typing import Any
8+
9+
from hawkapi.doctor._types import Finding, Severity
10+
11+
# ANSI colour codes — only applied when stdout is a TTY.
12+
_RED = "\033[31m"
13+
_YELLOW = "\033[33m"
14+
_CYAN = "\033[36m"
15+
_RESET = "\033[0m"
16+
17+
_EMOJI = {
18+
Severity.ERROR: "✗",
19+
Severity.WARN: "⚠",
20+
Severity.INFO: "ℹ",
21+
}
22+
23+
24+
def _colour(text: str, code: str) -> str:
25+
if sys.stdout.isatty():
26+
return f"{code}{text}{_RESET}"
27+
return text
28+
29+
30+
def _severity_colour(sev: Severity) -> str:
31+
if sev == Severity.ERROR:
32+
return _RED
33+
if sev == Severity.WARN:
34+
return _YELLOW
35+
return _CYAN
36+
37+
38+
def exit_code(findings: list[Finding]) -> int:
39+
"""Return the appropriate exit code for the given findings list."""
40+
if any(f.severity == Severity.ERROR for f in findings):
41+
return 2
42+
if any(f.severity == Severity.WARN for f in findings):
43+
return 1
44+
return 0
45+
46+
47+
def format_human(findings: list[Finding], app_spec: str) -> str:
48+
"""Render findings as a human-readable report grouped by severity."""
49+
lines: list[str] = []
50+
lines.append(f"hawkapi doctor — {app_spec}")
51+
lines.append("")
52+
53+
if not findings:
54+
lines.append("No findings. All checks passed.")
55+
lines.append("")
56+
lines.append("Summary: 0 errors, 0 warnings, 0 info · exit 0")
57+
return "\n".join(lines)
58+
59+
ordered = sorted(findings, key=lambda f: -f.severity)
60+
61+
for finding in ordered:
62+
emoji = _EMOJI[finding.severity]
63+
col = _severity_colour(finding.severity)
64+
loc = finding.location or finding.rule_id
65+
header = _colour(f"{emoji} {finding.rule_id} {loc}", col)
66+
lines.append(header)
67+
lines.append(f" {finding.message}")
68+
if finding.fix:
69+
lines.append(f" Fix: {finding.fix}")
70+
if finding.docs_url:
71+
lines.append(f" {finding.docs_url}")
72+
lines.append("")
73+
74+
errors = sum(1 for f in findings if f.severity == Severity.ERROR)
75+
warnings = sum(1 for f in findings if f.severity == Severity.WARN)
76+
info = sum(1 for f in findings if f.severity == Severity.INFO)
77+
code = exit_code(findings)
78+
lines.append(
79+
f"Summary: {errors} error{'s' if errors != 1 else ''}, "
80+
f"{warnings} warning{'s' if warnings != 1 else ''}, "
81+
f"{info} info · exit {code}"
82+
)
83+
return "\n".join(lines)
84+
85+
86+
def format_json(findings: list[Finding], app_spec: str) -> str:
87+
"""Render findings as a stable JSON document."""
88+
errors = sum(1 for f in findings if f.severity == Severity.ERROR)
89+
warnings = sum(1 for f in findings if f.severity == Severity.WARN)
90+
info = sum(1 for f in findings if f.severity == Severity.INFO)
91+
92+
_SEV_STR: dict[Severity, str] = {
93+
Severity.ERROR: "error",
94+
Severity.WARN: "warning",
95+
Severity.INFO: "info",
96+
}
97+
98+
payload: dict[str, Any] = {
99+
"app": app_spec,
100+
"summary": {
101+
"errors": errors,
102+
"warnings": warnings,
103+
"info": info,
104+
"total": len(findings),
105+
},
106+
"findings": [
107+
{
108+
"rule_id": f.rule_id,
109+
"severity": _SEV_STR[f.severity],
110+
"message": f.message,
111+
"fix": f.fix,
112+
"location": f.location,
113+
"docs_url": f.docs_url,
114+
}
115+
for f in findings
116+
],
117+
}
118+
return json.dumps(payload, indent=2)

0 commit comments

Comments
 (0)