Skip to content

Commit c3d9b8b

Browse files
test: HTTP/CLI route matrix and structured API error codes (#42)
1 parent 731338b commit c3d9b8b

19 files changed

Lines changed: 601 additions & 1179 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,14 @@ jobs:
6868

6969
mypy:
7070
runs-on: ubuntu-latest
71+
permissions:
72+
contents: read
7173
steps:
72-
- uses: actions/checkout@v4
74+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
75+
with:
76+
persist-credentials: false
7377

74-
- uses: actions/setup-python@v5
78+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
7579
with:
7680
python-version: "3.12"
7781
cache: pip

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: 82 additions & 37 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,
@@ -36,6 +36,14 @@
3636
# Tests monkeypatch this path; keep in sync with utils.export_state_store.
3737
_STATE_FILE = EXPORT_STATE_FILE
3838

39+
_EXPORT_ERRORS = (
40+
json.JSONDecodeError,
41+
KeyError,
42+
ValueError,
43+
OSError,
44+
FileNotFoundError,
45+
)
46+
3947

4048
def _state_lock() -> Any:
4149
return export_state_lock(_STATE_FILE)
@@ -84,11 +92,20 @@ def bulk_export() -> FlaskReturn:
8492
if body is None:
8593
body = {}
8694
if not isinstance(body, dict):
87-
return json_error("Invalid request body", 400)
95+
return error_response(
96+
ErrorCode.INVALID_REQUEST_BODY,
97+
"Invalid request body",
98+
400,
99+
)
88100

89101
since = body.get("since", "all")
90102
if since not in ("all", "last", "incremental"):
91-
return json_error({"error": "Invalid since mode", "since": since}, 400)
103+
return error_response(
104+
ErrorCode.INVALID_SINCE_MODE,
105+
"Invalid since mode",
106+
400,
107+
since=since,
108+
)
92109

93110
base = (
94111
current_app.config.get("CLAUDE_PROJECTS_DIR")
@@ -153,7 +170,7 @@ def bulk_export() -> FlaskReturn:
153170
)
154171
new_sessions_map[sid] = sess_info.get("modified", 0)
155172
count += 1
156-
except Exception as e:
173+
except _EXPORT_ERRORS as e:
157174
current_app.logger.warning(
158175
"Failed to export %s: %s", sid[:10], e
159176
)
@@ -215,7 +232,7 @@ def bulk_export() -> FlaskReturn:
215232
)
216233
new_sessions_map[sid] = sess_info.get("modified", 0)
217234
count += 1
218-
except Exception as e:
235+
except _EXPORT_ERRORS as e:
219236
current_app.logger.warning(
220237
"Failed to export %s: %s", sid[:10], e
221238
)
@@ -226,11 +243,15 @@ def bulk_export() -> FlaskReturn:
226243
)
227244
zf.writestr("manifest.jsonl", manifest_str)
228245

229-
if count > 0:
230-
_write_state(new_sessions_map, count)
231-
232246
if count == 0:
233-
return json_error({"error": "Nothing to export", "since": since}, 422)
247+
return error_response(
248+
ErrorCode.EXPORT_NOTHING_TO_EXPORT,
249+
"Nothing to export",
250+
422,
251+
since=since,
252+
)
253+
254+
_write_state(new_sessions_map, count)
234255

235256
buf.seek(0)
236257
date_tag = datetime.now().strftime("%Y-%m-%d")
@@ -253,7 +274,6 @@ def bulk_export() -> FlaskReturn:
253274

254275
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
255276
def export_session(project_name: str, session_id: str) -> FlaskReturn:
256-
import os
257277
from utils.session_path import safe_join
258278

259279
base = (
@@ -263,42 +283,67 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
263283
try:
264284
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
265285
except ValueError:
266-
return json_error("Invalid path", 400)
286+
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
267287

268288
if not os.path.isfile(filepath):
269-
return json_error("Session not found", 404)
289+
return error_response(
290+
ErrorCode.SESSION_NOT_FOUND,
291+
"Session not found",
292+
404,
293+
)
270294

271295
fmt = request.args.get("format", "md")
272296
try:
273297
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)
298+
except _EXPORT_ERRORS:
299+
current_app.logger.exception(
300+
"Failed to parse session %s for export", session_id
301+
)
302+
return error_response(
303+
ErrorCode.PARSE_ERROR,
304+
"Failed to parse session",
305+
500,
306+
)
307+
308+
rules = current_app.config.get("EXCLUSION_RULES") or []
309+
if is_session_excluded(rules, session, project_name):
310+
return error_response(
311+
ErrorCode.SESSION_NOT_FOUND,
312+
"Session not found",
313+
404,
314+
)
315+
316+
try:
277317
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-
)
318+
except _EXPORT_ERRORS:
319+
current_app.logger.exception(
320+
"Failed to compute stats for export %s", session_id
321+
)
322+
return error_response(
323+
ErrorCode.INTERNAL_ERROR,
324+
"Failed to compute session stats",
325+
500,
326+
)
290327

291-
md = session_to_markdown(session, stats)
292-
buf = io.BytesIO(md.encode("utf-8"))
328+
title_slug = slugify(session["title"], default="session")
329+
330+
if fmt == "json":
331+
content = session_to_json(session, stats)
332+
buf = io.BytesIO(content.encode("utf-8"))
293333
buf.seek(0)
294334
return send_file(
295335
buf,
296-
mimetype="text/markdown",
336+
mimetype="application/json",
297337
as_attachment=True,
298-
download_name=f"{title_slug}.md", # type: ignore[call-arg]
338+
download_name=f"{title_slug}.json", # type: ignore[call-arg]
299339
)
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)
340+
341+
md = session_to_markdown(session, stats)
342+
buf = io.BytesIO(md.encode("utf-8"))
343+
buf.seek(0)
344+
return send_file(
345+
buf,
346+
mimetype="text/markdown",
347+
as_attachment=True,
348+
download_name=f"{title_slug}.md", # type: ignore[call-arg]
349+
)

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)

0 commit comments

Comments
 (0)