Skip to content

Commit ac941ef

Browse files
test: HTTP/CLI route matrix and structured API error codes
- Add ErrorCode enum and error_response() helper; migrate api/search, api/sessions, and api/export_api error paths to include stable "code" - Validate search limit query param (400 SEARCH_INVALID_LIMIT; cap at 500) - Add tests/conftest.py, fixtures, test_api_routes, test_cli_e2e, test_error_codes, test_search; extend test_export_api_bulk for codes - Document error code catalog in README
1 parent 387791b commit ac941ef

13 files changed

Lines changed: 488 additions & 75 deletions

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,22 @@ Browse and export Claude Code chat history — Web GUI and CLI.
1919
- **Smooth transitions** — staggered card/message animations, crossfade content swaps
2020
- **Scroll-to-top button** in bottom-right corner
2121
- **Per-model badges** in session header
22-
- **Bulk export** — download all sessions, incremental updates, or latest-day slice as a zip; if there is nothing to export, the API returns **422** with JSON body `{"error": "Nothing to export", "since": "<mode>"}` (the `since` field echoes your request: `"all"`, `"last"`, or `"incremental"`) instead of an empty zip
22+
- **Bulk export** — download all sessions, incremental updates, or latest-day slice as a zip; if there is nothing to export, the API returns **422** with JSON body `{"error": "Nothing to export", "code": "EXPORT_NOTHING_TO_EXPORT", "since": "<mode>"}` (the `since` field echoes your request: `"all"`, `"last"`, or `"incremental"`) instead of an empty zip
23+
24+
### API error codes
25+
26+
JSON error responses include a machine-readable `"code"` (stable `UPPER_SNAKE_CASE`) and a human-readable `"error"` message. Common codes:
27+
28+
| Code | Typical HTTP | Meaning |
29+
|------|--------------|---------|
30+
| `SEARCH_INVALID_LIMIT` | 400 | Query param `limit` is not a positive integer |
31+
| `INVALID_PATH` | 400 | Path traversal or unsafe project/session path |
32+
| `SESSION_NOT_FOUND` | 404 | Session file missing or excluded |
33+
| `INVALID_REQUEST_BODY` | 400 | POST body is not a JSON object |
34+
| `INVALID_SINCE_MODE` | 400 | Bulk export `since` is not `all`, `last`, or `incremental` |
35+
| `EXPORT_NOTHING_TO_EXPORT` | 422 | No sessions matched the export scope |
36+
| `PARSE_ERROR` | 500 | Session file could not be parsed |
37+
| `INTERNAL_ERROR` | 500 | Unexpected failure (e.g. stats computation) |
2338

2439
### CLI Export
2540
- Standalone script to export all sessions to Markdown with YAML frontmatter

api/error_codes.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Stable machine-readable error codes for API JSON error responses."""
2+
3+
from __future__ import annotations
4+
5+
from enum import StrEnum
6+
7+
from flask import Response, jsonify
8+
9+
10+
class ErrorCode(StrEnum):
11+
SEARCH_INVALID_LIMIT = "SEARCH_INVALID_LIMIT"
12+
INVALID_PATH = "INVALID_PATH"
13+
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
14+
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"
15+
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
16+
PARSE_ERROR = "PARSE_ERROR"
17+
EXPORT_NOTHING_TO_EXPORT = "EXPORT_NOTHING_TO_EXPORT"
18+
INTERNAL_ERROR = "INTERNAL_ERROR"
19+
20+
21+
def error_response(
22+
code: ErrorCode,
23+
message: str,
24+
status: int,
25+
**extra: object,
26+
) -> tuple[Response, int]:
27+
body: dict[str, object] = {"error": message, "code": code}
28+
reserved = frozenset({"error", "code"})
29+
for key, value in extra.items():
30+
if key not in reserved:
31+
body[key] = value
32+
return jsonify(body), status

api/export_api.py

Lines changed: 72 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
from flask import Blueprint, current_app, request, send_file
1111

12-
from api._flask_types import FlaskReturn, json_error, json_response
12+
from api._flask_types import FlaskReturn, json_response
13+
from api.error_codes import ErrorCode, error_response
1314
from models.export import ExportStateDict
14-
1515
from utils.export_state_store import (
1616
EXPORT_STATE_FILE,
1717
atomic_write_export_state,
@@ -84,11 +84,20 @@ def bulk_export() -> FlaskReturn:
8484
if body is None:
8585
body = {}
8686
if not isinstance(body, dict):
87-
return json_error("Invalid request body", 400)
87+
return error_response(
88+
ErrorCode.INVALID_REQUEST_BODY,
89+
"Invalid request body",
90+
400,
91+
)
8892

8993
since = body.get("since", "all")
9094
if since not in ("all", "last", "incremental"):
91-
return json_error({"error": "Invalid since mode", "since": since}, 400)
95+
return error_response(
96+
ErrorCode.INVALID_SINCE_MODE,
97+
"Invalid since mode",
98+
400,
99+
since=since,
100+
)
92101

93102
base = (
94103
current_app.config.get("CLAUDE_PROJECTS_DIR")
@@ -226,11 +235,15 @@ def bulk_export() -> FlaskReturn:
226235
)
227236
zf.writestr("manifest.jsonl", manifest_str)
228237

229-
if count > 0:
230-
_write_state(new_sessions_map, count)
231-
232238
if count == 0:
233-
return json_error({"error": "Nothing to export", "since": since}, 422)
239+
return error_response(
240+
ErrorCode.EXPORT_NOTHING_TO_EXPORT,
241+
"Nothing to export",
242+
422,
243+
since=since,
244+
)
245+
246+
_write_state(new_sessions_map, count)
234247

235248
buf.seek(0)
236249
date_tag = datetime.now().strftime("%Y-%m-%d")
@@ -253,7 +266,6 @@ def bulk_export() -> FlaskReturn:
253266

254267
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
255268
def export_session(project_name: str, session_id: str) -> FlaskReturn:
256-
import os
257269
from utils.session_path import safe_join
258270

259271
base = (
@@ -263,42 +275,67 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
263275
try:
264276
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
265277
except ValueError:
266-
return json_error("Invalid path", 400)
278+
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
267279

268280
if not os.path.isfile(filepath):
269-
return json_error("Session not found", 404)
281+
return error_response(
282+
ErrorCode.SESSION_NOT_FOUND,
283+
"Session not found",
284+
404,
285+
)
270286

271287
fmt = request.args.get("format", "md")
272288
try:
273289
session = parse_session(filepath)
274-
rules = current_app.config.get("EXCLUSION_RULES") or []
275-
if is_session_excluded(rules, session, project_name):
276-
return json_error("Session not found", 404)
290+
except Exception:
291+
current_app.logger.exception(
292+
"Failed to parse session %s for export", session_id
293+
)
294+
return error_response(
295+
ErrorCode.PARSE_ERROR,
296+
"Failed to parse session",
297+
500,
298+
)
299+
300+
rules = current_app.config.get("EXCLUSION_RULES") or []
301+
if is_session_excluded(rules, session, project_name):
302+
return error_response(
303+
ErrorCode.SESSION_NOT_FOUND,
304+
"Session not found",
305+
404,
306+
)
307+
308+
try:
277309
stats = compute_stats(session)
278-
title_slug = slugify(session["title"], default="session")
279-
280-
if fmt == "json":
281-
content = session_to_json(session, stats)
282-
buf = io.BytesIO(content.encode("utf-8"))
283-
buf.seek(0)
284-
return send_file(
285-
buf,
286-
mimetype="application/json",
287-
as_attachment=True,
288-
download_name=f"{title_slug}.json", # type: ignore[call-arg]
289-
)
310+
except Exception:
311+
current_app.logger.exception(
312+
"Failed to compute stats for export %s", session_id
313+
)
314+
return error_response(
315+
ErrorCode.INTERNAL_ERROR,
316+
"Failed to compute session stats",
317+
500,
318+
)
319+
320+
title_slug = slugify(session["title"], default="session")
290321

291-
md = session_to_markdown(session, stats)
292-
buf = io.BytesIO(md.encode("utf-8"))
322+
if fmt == "json":
323+
content = session_to_json(session, stats)
324+
buf = io.BytesIO(content.encode("utf-8"))
293325
buf.seek(0)
294326
return send_file(
295327
buf,
296-
mimetype="text/markdown",
328+
mimetype="application/json",
297329
as_attachment=True,
298-
download_name=f"{title_slug}.md", # type: ignore[call-arg]
330+
download_name=f"{title_slug}.json", # type: ignore[call-arg]
299331
)
300-
except Exception:
301-
current_app.logger.exception(
302-
"Failed to export session %s/%s", project_name, session_id
303-
)
304-
return json_error("Internal server error exporting session", 500)
332+
333+
md = session_to_markdown(session, stats)
334+
buf = io.BytesIO(md.encode("utf-8"))
335+
buf.seek(0)
336+
return send_file(
337+
buf,
338+
mimetype="text/markdown",
339+
as_attachment=True,
340+
download_name=f"{title_slug}.md", # type: ignore[call-arg]
341+
)

api/search.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import os
44

5-
from flask import Blueprint, current_app, jsonify, request
5+
from flask import Blueprint, current_app, request
66

7-
from api._flask_types import FlaskReturn, json_error, json_response
7+
from api._flask_types import FlaskReturn, json_response
8+
from api.error_codes import ErrorCode, error_response
89
from models.search import SearchHitDict
910
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
1011
from utils.jsonl_parser import parse_session
@@ -37,8 +38,13 @@ def search() -> FlaskReturn:
3738

3839
try:
3940
max_results = _parse_limit(request.args.get("limit"))
40-
except ValueError as e:
41-
return json_error(str(e), 400)
41+
except ValueError:
42+
return error_response(
43+
ErrorCode.SEARCH_INVALID_LIMIT,
44+
"Invalid limit: must be a positive integer",
45+
400,
46+
)
47+
4248
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
4349
projects = list_projects(base)
4450

@@ -60,7 +66,6 @@ def search() -> FlaskReturn:
6066
for msg in session["messages"]:
6167
text = msg.get("text", "") or msg.get("content", "")
6268
if query in text.lower():
63-
# Find the matching snippet
6469
idx = text.lower().index(query)
6570
start = max(0, idx - 80)
6671
end = min(len(text), idx + len(query) + 80)

api/sessions.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
from flask import Blueprint, current_app
66

7-
from api._flask_types import FlaskReturn, json_error, json_response
8-
7+
from api._flask_types import FlaskReturn, json_response
8+
from api.error_codes import ErrorCode, error_response
99
from utils.session_path import get_claude_projects_dir, safe_join
1010
from utils.jsonl_parser import parse_session
1111
from utils.session_stats import compute_stats
@@ -20,25 +20,32 @@ def get_session(project_name: str, session_id: str) -> FlaskReturn:
2020
try:
2121
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
2222
except ValueError:
23-
return json_error("Invalid path", 400)
23+
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
2424

2525
if not os.path.isfile(filepath):
26-
return json_error(f"Session {session_id} not found", 404)
26+
return error_response(
27+
ErrorCode.SESSION_NOT_FOUND,
28+
f"Session {session_id} not found",
29+
404,
30+
)
2731

2832
try:
2933
session = parse_session(filepath)
3034
rules = current_app.config.get("EXCLUSION_RULES") or []
3135
if is_session_excluded(rules, session, project_name):
32-
return json_error("Session not found", 404)
36+
return error_response(
37+
ErrorCode.SESSION_NOT_FOUND,
38+
"Session not found",
39+
404,
40+
)
3341
return json_response(session)
3442
except Exception:
35-
# Full traceback (class name, message, stack) goes to the server log
36-
# via logger.exception. The HTTP body returns a stable, generic
37-
# message — never the class name or `e` itself, which would leak
38-
# internal field names, file paths, and user values to any client
39-
# (issue #25).
4043
current_app.logger.exception("Failed to parse session %s", session_id)
41-
return json_error("Failed to parse session", 500)
44+
return error_response(
45+
ErrorCode.PARSE_ERROR,
46+
"Failed to parse session",
47+
500,
48+
)
4249

4350

4451
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>/stats")
@@ -47,20 +54,40 @@ def get_session_stats(project_name: str, session_id: str) -> FlaskReturn:
4754
try:
4855
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
4956
except ValueError:
50-
return json_error("Invalid path", 400)
57+
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
5158

5259
if not os.path.isfile(filepath):
53-
return json_error(f"Session {session_id} not found", 404)
60+
return error_response(
61+
ErrorCode.SESSION_NOT_FOUND,
62+
f"Session {session_id} not found",
63+
404,
64+
)
5465

5566
try:
5667
session = parse_session(filepath)
57-
rules = current_app.config.get("EXCLUSION_RULES") or []
58-
if is_session_excluded(rules, session, project_name):
59-
return json_error("Session not found", 404)
68+
except Exception:
69+
current_app.logger.exception("Failed to parse session %s", session_id)
70+
return error_response(
71+
ErrorCode.PARSE_ERROR,
72+
"Failed to parse session",
73+
500,
74+
)
75+
76+
rules = current_app.config.get("EXCLUSION_RULES") or []
77+
if is_session_excluded(rules, session, project_name):
78+
return error_response(
79+
ErrorCode.SESSION_NOT_FOUND,
80+
"Session not found",
81+
404,
82+
)
83+
84+
try:
6085
stats = compute_stats(session)
6186
return json_response(stats)
6287
except Exception:
63-
# Same pattern as get_session above — full detail to the server log,
64-
# generic message in the HTTP body (issue #25).
6588
current_app.logger.exception("Failed to compute stats for %s", session_id)
66-
return json_error("Failed to compute session stats", 500)
89+
return error_response(
90+
ErrorCode.INTERNAL_ERROR,
91+
"Failed to compute session stats",
92+
500,
93+
)

0 commit comments

Comments
 (0)