Skip to content

Commit 41ed2cc

Browse files
authored
Merge pull request #129 from jmjava/cursor/wizard-json-object-2ccd
Fail closed when wizard JSON bodies are not objects
2 parents 758f463 + 199132e commit 41ed2cc

5 files changed

Lines changed: 138 additions & 20 deletions

File tree

‎milestones/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ repositories that install `docgen` and maintain their own demo bundle. The
55
library no longer ships an in-repo dogfood; consumers are the integration test
66
of record.
77

8-
**Active:** **[wizard-state-segments.md](wizard-state-segments.md)** —
9-
wizard `.docgen-state.json` `segments` must be a mapping of objects.
8+
**Active:** **[wizard-json-object.md](wizard-json-object.md)** —
9+
wizard POST/PUT bodies must be JSON objects; bool fields must be booleans.
1010

1111
**Shipped:**
12+
- **[wizard-state-segments.md](wizard-state-segments.md)** —
13+
wizard `.docgen-state.json` `segments` must be a mapping of objects
14+
(#128).
1215
- **[timing-inner-lists.md](timing-inner-lists.md)** —
1316
`timing.json` `words` / `segments` must be JSON arrays of objects
1417
(#127).

‎milestones/wizard-json-object.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Milestone: wizard POST bodies must be JSON objects
2+
3+
**Status:** Active
4+
**PR:** [#129](https://github.com/jmjava/documentation-generator/pull/129)
5+
**Depends on:** `milestones/wizard-state-segments.md` (PR #128)
6+
7+
## Problem
8+
9+
PR #128 typed ``POST /api/state``. Other wizard POST/PUT handlers still
10+
did ``request.json or {}`` then ``.get``. A JSON **array** is truthy, so
11+
the default never applied and Flask raised ``AttributeError``.
12+
13+
``bool(data.get("with_manim", False))`` treated the string ``"false"`` as
14+
true.
15+
16+
## Goal
17+
18+
Every wizard JSON body must be an object (missing body still ``{}``).
19+
Boolean fields (``with_manim``, ``update_requirements``, ``also_manim``,
20+
``yaml_generate``, ``llm_scene_spec``) must be JSON booleans when present.
21+
22+
## Done when
23+
24+
- [x] List / scalar POST bodies return 400 on all wizard JSON endpoints
25+
- [x] String ``"false"`` / ``"true"`` for bool fields return 400
26+
- [x] Existing object-body tests still pass
27+
- [x] `ruff check src/ tests/`
28+
- [x] `pytest tests/` (745 passed, 1 skipped)
29+
- [x] `docgen benchmark` (no clock change; meets baseline)
30+
31+
## Out of scope
32+
33+
- Invalid JSON still becomes ``{}`` via ``get_json(silent=True)``
34+
- Wizard ``except Exception`` around ``narration_topic_label``

‎milestones/wizard-state-segments.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Milestone: wizard state segments must be objects
22

3-
**Status:** Active
3+
**Status:** Shipped
44
**PR:** [#128](https://github.com/jmjava/documentation-generator/pull/128)
55
**Depends on:** `milestones/timing-inner-lists.md` (PR #127)
66

‎src/docgen/wizard.py‎

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,32 @@ def require_state_segments(data: dict[str, Any], *, label: str) -> dict[str, Any
3737
return segs
3838

3939

40+
def request_json_object() -> dict[str, Any]:
41+
"""Return the JSON request body as an object. A missing body is ``{}``.
42+
43+
A JSON array, string, number, bool, or ``null`` raises :class:`WizardError`
44+
so handlers cannot ``.get`` on a list.
45+
"""
46+
raw = request.get_json(silent=True)
47+
if raw is None:
48+
return {}
49+
if not isinstance(raw, dict):
50+
raise WizardError(
51+
f"request body must be a JSON object, not {_json_kind(raw)}"
52+
)
53+
return raw
54+
55+
56+
def require_json_bool(data: dict[str, Any], key: str, *, default: bool) -> bool:
57+
"""Return ``data[key]`` when present; reject non-bool JSON (including ``\"false\"``)."""
58+
if key not in data or data[key] is None:
59+
return default
60+
val = data[key]
61+
if not isinstance(val, bool):
62+
raise WizardError(f"{key} must be a JSON boolean, not {_json_kind(val)}")
63+
return val
64+
65+
4066
def session_payload(config: Any | None) -> dict[str, Any]:
4167
"""GUI session: frozen shell vs pip CLI, and whether a bundle is attached."""
4268
from docgen.resources import is_frozen
@@ -477,7 +503,10 @@ def api_session():
477503

478504
@app.route("/api/open-bundle", methods=["POST"])
479505
def api_open_bundle():
480-
data = request.get_json(silent=True) or {}
506+
try:
507+
data = request_json_object()
508+
except WizardError as exc:
509+
return jsonify({"error": str(exc)}), 400
481510
try:
482511
cfg = open_bundle_config(str(data.get("path") or ""))
483512
except ValueError as exc:
@@ -514,10 +543,13 @@ def api_tool_update():
514543
if blocked is not None:
515544
return blocked
516545
cfg = _cfg()
517-
data = request.json or {}
546+
try:
547+
data = request_json_object()
548+
with_manim = require_json_bool(data, "with_manim", default=False)
549+
update_req = require_json_bool(data, "update_requirements", default=True)
550+
except WizardError as exc:
551+
return jsonify({"error": str(exc)}), 400
518552
ref = str(data.get("ref") or "main")
519-
with_manim = bool(data.get("with_manim", False))
520-
update_req = bool(data.get("update_requirements", True))
521553
bundle = cfg.base_dir if cfg else None
522554
try:
523555
result = update_docgen_install(
@@ -577,7 +609,10 @@ def api_generate_narration():
577609
if blocked is not None:
578610
return blocked
579611
cfg = _cfg()
580-
data = request.json or {}
612+
try:
613+
data = request_json_object()
614+
except WizardError as exc:
615+
return jsonify({"error": str(exc)}), 400
581616
source_paths: list[str] = list(data.get("source_paths") or [])
582617
guidance: str = data.get("guidance", "")
583618
segment_name: str = data.get("segment_name", "untitled")
@@ -686,12 +721,8 @@ def api_get_state():
686721
def api_set_state():
687722
cfg = _cfg()
688723
base = cfg.base_dir if cfg else Path.cwd()
689-
raw = request.get_json(silent=True)
690-
if raw is None:
691-
raw = {}
692-
if not isinstance(raw, dict):
693-
return jsonify({"error": "state must be a JSON object"}), 400
694724
try:
725+
raw = request_json_object()
695726
segs = require_state_segments(raw, label="state")
696727
except WizardError as exc:
697728
return jsonify({"error": str(exc)}), 400
@@ -792,12 +823,15 @@ def api_put_focus(segment_id: str):
792823
cfg = _cfg()
793824
if not cfg:
794825
return jsonify({"error": "no config"}), 400
795-
data = request.json or {}
826+
try:
827+
data = request_json_object()
828+
also_manim = require_json_bool(data, "also_manim", default=True)
829+
do_yaml = require_json_bool(data, "yaml_generate", default=True)
830+
except WizardError as exc:
831+
return jsonify({"error": str(exc)}), 400
796832
paths = data.get("paths")
797833
if not isinstance(paths, list):
798834
return jsonify({"error": "paths must be a list of repo-root-relative strings"}), 400
799-
also_manim = data.get("also_manim", True)
800-
do_yaml = data.get("yaml_generate", True)
801835

802836
root = cfg.repo_root.resolve()
803837
clean: list[str] = []
@@ -879,7 +913,10 @@ def api_put_narration(segment_id: str):
879913
cfg = _cfg()
880914
if not cfg:
881915
return jsonify({"error": "no config"}), 400
882-
data = request.json or {}
916+
try:
917+
data = request_json_object()
918+
except WizardError as exc:
919+
return jsonify({"error": str(exc)}), 400
883920
text = data.get("text", "")
884921
seg_name = cfg.resolve_segment_name(segment_id)
885922
found = _find_asset(cfg.narration_dir, seg_name, segment_id, ".md")
@@ -1056,8 +1093,11 @@ def api_run_from(step: str, segment_id: str):
10561093
cfg = _cfg()
10571094
if not cfg:
10581095
return jsonify({"error": "no config"}), 400
1059-
data = request.json or {}
1060-
llm_scene = bool(data.get("llm_scene_spec", False))
1096+
try:
1097+
data = request_json_object()
1098+
llm_scene = require_json_bool(data, "llm_scene_spec", default=False)
1099+
except WizardError as exc:
1100+
return jsonify({"error": str(exc)}), 400
10611101
from docgen.asset_graph import cascade_steps
10621102

10631103
try:

‎tests/test_wizard.py‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def test_api_state_post_rejects_list_body(tmp_path):
133133
client, _cfg = _wizard_client(tmp_path)
134134
res = client.post("/api/state", json=["not", "an", "object"])
135135
assert res.status_code == 400
136-
assert res.get_json()["error"] == "state must be a JSON object"
136+
assert res.get_json()["error"] == "request body must be a JSON object, not list"
137137

138138

139139
def test_api_state_post_rejects_list_segments(tmp_path):
@@ -158,6 +158,47 @@ def test_api_state_roundtrip_object_segments(tmp_path):
158158
assert (cfg.base_dir / ".docgen-state.json").is_file()
159159

160160

161+
def test_api_post_rejects_list_json_bodies(tmp_path):
162+
client, _cfg = _wizard_client(tmp_path)
163+
endpoints = (
164+
("POST", "/api/open-bundle"),
165+
("POST", "/api/tool/update"),
166+
("POST", "/api/generate-narration"),
167+
("POST", "/api/run-from/tts/01"),
168+
("PUT", "/api/narration/01"),
169+
("PUT", "/api/segments/01/focus"),
170+
)
171+
for method, path in endpoints:
172+
res = client.open(path, method=method, json=["not", "an", "object"])
173+
assert res.status_code == 400, path
174+
assert res.get_json()["error"] == "request body must be a JSON object, not list"
175+
176+
177+
def test_api_tool_update_rejects_string_with_manim(tmp_path):
178+
client, _cfg = _wizard_client(tmp_path)
179+
res = client.post("/api/tool/update", json={"with_manim": "false"})
180+
assert res.status_code == 400
181+
assert "with_manim must be a JSON boolean" in res.get_json()["error"]
182+
183+
184+
def test_api_run_from_rejects_string_llm_scene_spec(tmp_path):
185+
client, _cfg = _wizard_client(tmp_path)
186+
res = client.post("/api/run-from/tts/01", json={"llm_scene_spec": "true"})
187+
assert res.status_code == 400
188+
assert "llm_scene_spec must be a JSON boolean" in res.get_json()["error"]
189+
190+
191+
def test_api_put_focus_rejects_string_yaml_generate(tmp_path):
192+
client, _cfg = _wizard_client(tmp_path)
193+
res = client.put(
194+
"/api/segments/01/focus",
195+
json={"paths": ["README.md"], "yaml_generate": "true"},
196+
)
197+
assert res.status_code == 400
198+
assert "yaml_generate must be a JSON boolean" in res.get_json()["error"]
199+
200+
201+
161202
def test_api_file_rejects_prefix_escape(tmp_path):
162203
from docgen.config import Config
163204
from docgen.wizard import create_app

0 commit comments

Comments
 (0)