Skip to content

Commit 46130cd

Browse files
fix(api): address PR review — json_error, typed session rows, test limits
1 parent 41c1f09 commit 46130cd

8 files changed

Lines changed: 97 additions & 61 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,8 @@ jobs:
7474
- name: Install dev dependencies
7575
run: pip install -r requirements-dev.txt
7676

77-
- name: Run mypy (strict)
77+
- name: Run mypy (strict, production packages)
7878
run: mypy
79+
80+
- name: Run mypy on tests (non-strict smoke)
81+
run: mypy tests --follow-imports skip

api/_flask_types.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@
44

55
from flask import Response, jsonify
66

7+
# Narrow scope: handlers here return Response or (Response, status) only.
8+
# Widen if adding (Response, int, headers) or plain-text tuples.
79
FlaskReturn = Union[Response, tuple[Response, int]]
810

911

10-
def json_ok(*args: Any, **kwargs: Any) -> Response:
11-
"""Typed wrapper around :func:`flask.jsonify`."""
12+
def json_response(*args: Any, **kwargs: Any) -> Response:
13+
"""Typed wrapper around :func:`flask.jsonify` for JSON bodies."""
1214
return jsonify(*args, **kwargs)
15+
16+
17+
def json_error(payload: str | dict[str, Any], status: int) -> tuple[Response, int]:
18+
"""JSON error body with explicit HTTP status (avoids trailing `, 404` at call sites)."""
19+
body: dict[str, Any] = {"error": payload} if isinstance(payload, str) else payload
20+
return jsonify(body), status

api/export_api.py

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

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

12-
from api._flask_types import FlaskReturn, json_ok
12+
from api._flask_types import FlaskReturn, json_error, json_response
1313
from models.export import ExportStateDict
1414

1515
from utils.export_state_store import (
@@ -68,7 +68,7 @@ def _write_state(sessions_map: dict[str, float], count: int) -> None:
6868
def get_export_state() -> FlaskReturn:
6969
state = _read_state()
7070
n = state.get("exportedCount", 0)
71-
return json_ok(
71+
return json_response(
7272
{
7373
"last_export_time": state.get("lastExportTime"),
7474
# Sessions exported in the last completed bulk export (not a lifetime total).
@@ -84,11 +84,11 @@ def bulk_export() -> FlaskReturn:
8484
if body is None:
8585
body = {}
8686
if not isinstance(body, dict):
87-
return json_ok({"error": "Invalid request body"}), 400
87+
return json_error("Invalid request body", 400)
8888

8989
since = body.get("since", "all")
9090
if since not in ("all", "last", "incremental"):
91-
return json_ok({"error": "Invalid since mode", "since": since}), 400
91+
return json_error({"error": "Invalid since mode", "since": since}, 400)
9292

9393
base = (
9494
current_app.config.get("CLAUDE_PROJECTS_DIR")
@@ -230,15 +230,7 @@ def bulk_export() -> FlaskReturn:
230230
_write_state(new_sessions_map, count)
231231

232232
if count == 0:
233-
return (
234-
json_ok(
235-
{
236-
"error": "Nothing to export",
237-
"since": since,
238-
}
239-
),
240-
422,
241-
)
233+
return json_error({"error": "Nothing to export", "since": since}, 422)
242234

243235
buf.seek(0)
244236
date_tag = datetime.now().strftime("%Y-%m-%d")
@@ -271,17 +263,17 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
271263
try:
272264
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
273265
except ValueError:
274-
return json_ok({"error": "Invalid path"}), 400
266+
return json_error("Invalid path", 400)
275267

276268
if not os.path.isfile(filepath):
277-
return json_ok({"error": "Session not found"}), 404
269+
return json_error("Session not found", 404)
278270

279271
fmt = request.args.get("format", "md")
280272
try:
281273
session = parse_session(filepath)
282274
rules = current_app.config.get("EXCLUSION_RULES") or []
283275
if is_session_excluded(rules, session, project_name):
284-
return json_ok({"error": "Session not found"}), 404
276+
return json_error("Session not found", 404)
285277
stats = compute_stats(session)
286278
title_slug = slugify(session["title"], default="session")
287279

@@ -309,4 +301,4 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
309301
current_app.logger.exception(
310302
"Failed to export session %s/%s", project_name, session_id
311303
)
312-
return json_ok({"error": "Internal server error exporting session"}), 500
304+
return json_error("Internal server error exporting session", 500)

api/projects.py

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,44 @@
11
"""Project listing endpoints."""
22

3-
from typing import cast
3+
from flask import Blueprint, current_app
44

5-
from flask import Blueprint, current_app, jsonify
6-
7-
from api._flask_types import FlaskReturn, json_ok
8-
from models.project import ProjectSessionRowDict
5+
from api._flask_types import FlaskReturn, json_error, json_response
6+
from models.project import ProjectSessionRowDict, SessionListItemDict
7+
from models.session import SessionDict
98
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
109
from utils.exclusion_rules import is_session_excluded
1110

1211
projects_bp = Blueprint("projects", __name__)
1312

1413

14+
def _session_row_ok(s: SessionListItemDict, parsed: SessionDict) -> ProjectSessionRowDict:
15+
meta = parsed["metadata"]
16+
models = meta.get("models_used", [])
17+
return {
18+
"id": s["id"],
19+
"path": s["path"],
20+
"size_bytes": s["size_bytes"],
21+
"modified": s["modified"],
22+
"title": parsed["title"],
23+
"models": sorted(models) if isinstance(models, set) else list(models),
24+
"tokens": meta["total_input_tokens"] + meta["total_output_tokens"],
25+
"tool_calls": meta["total_tool_calls"],
26+
"first_timestamp": meta["first_timestamp"],
27+
"last_timestamp": meta["last_timestamp"],
28+
}
29+
30+
31+
def _session_row_error(s: SessionListItemDict) -> ProjectSessionRowDict:
32+
return {
33+
"id": s["id"],
34+
"path": s["path"],
35+
"size_bytes": s["size_bytes"],
36+
"modified": s["modified"],
37+
"title": "Error parsing session",
38+
"error": True,
39+
}
40+
41+
1542
@projects_bp.route("/api/projects")
1643
def get_projects() -> FlaskReturn:
1744
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
@@ -40,7 +67,7 @@ def get_projects() -> FlaskReturn:
4067
if latest_ts:
4168
project["last_modified"] = latest_ts
4269

43-
return json_ok(projects)
70+
return json_response(projects)
4471

4572

4673
@projects_bp.route("/api/projects/<path:project_name>/sessions")
@@ -49,7 +76,7 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
4976
try:
5077
project_dir = safe_join(base, project_name)
5178
except ValueError:
52-
return json_ok([]), 400
79+
return json_response([]), 400
5380
sessions = list_sessions(project_dir)
5481
# Add summary preview for each session
5582
from utils.jsonl_parser import parse_session
@@ -58,31 +85,17 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
5885
for s in sessions:
5986
try:
6087
parsed = parse_session(s["path"])
61-
meta = parsed["metadata"]
6288
# Skip untitled sessions (no real conversation)
6389
if parsed["title"] == "Untitled Session":
6490
continue
6591
if is_session_excluded(rules, parsed, project_name):
6692
continue
67-
models = meta.get("models_used", [])
68-
result.append(cast(ProjectSessionRowDict, {
69-
**s,
70-
"title": parsed["title"],
71-
"models": sorted(models) if isinstance(models, set) else list(models),
72-
"tokens": meta["total_input_tokens"] + meta["total_output_tokens"],
73-
"tool_calls": meta["total_tool_calls"],
74-
"first_timestamp": meta["first_timestamp"],
75-
"last_timestamp": meta["last_timestamp"],
76-
}))
93+
result.append(_session_row_ok(s, parsed))
7794
except Exception:
7895
# Full detail (class, message, traceback) to the server log via
7996
# logger.exception. The per-session card carries only `error: True`
8097
# — the class-name+message string was a leak (issue #25). The
8198
# operator looks at the server log for triage.
8299
current_app.logger.exception("Failed to parse session %s", s["id"])
83-
result.append(cast(ProjectSessionRowDict, {
84-
**s,
85-
"title": "Error parsing session",
86-
"error": True,
87-
}))
88-
return json_ok(result)
100+
result.append(_session_row_error(s))
101+
return json_response(result)

api/search.py

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

55
from flask import Blueprint, current_app, jsonify, request
66

7-
from api._flask_types import FlaskReturn, json_ok
7+
from api._flask_types import FlaskReturn, json_error, json_response
88
from models.search import SearchHitDict
99
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
1010
from utils.jsonl_parser import parse_session
@@ -32,12 +32,12 @@ def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
3232
def search() -> FlaskReturn:
3333
query = request.args.get("q", "").strip().lower()
3434
if not query:
35-
return json_ok([])
35+
return json_response([])
3636

3737
try:
3838
max_results = _parse_limit(request.args.get("limit"))
3939
except ValueError as e:
40-
return json_ok({"error": str(e)}), 400
40+
return json_error(str(e), 400)
4141
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
4242
projects = list_projects(base)
4343

@@ -76,4 +76,4 @@ def search() -> FlaskReturn:
7676
if len(results) >= max_results:
7777
break
7878

79-
return json_ok(results)
79+
return json_response(results)

api/sessions.py

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

33
import os
44

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

7-
from api._flask_types import FlaskReturn, json_ok
7+
from api._flask_types import FlaskReturn, json_error, json_response
88

99
from utils.session_path import get_claude_projects_dir, safe_join
1010
from utils.jsonl_parser import parse_session
@@ -20,25 +20,25 @@ 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_ok({"error": "Invalid path"}), 400
23+
return json_error("Invalid path", 400)
2424

2525
if not os.path.isfile(filepath):
26-
return json_ok({"error": f"Session {session_id} not found"}), 404
26+
return json_error(f"Session {session_id} not found", 404)
2727

2828
try:
2929
session = parse_session(filepath)
3030
rules = current_app.config.get("EXCLUSION_RULES") or []
3131
if is_session_excluded(rules, session, project_name):
32-
return json_ok({"error": "Session not found"}), 404
33-
return json_ok(session)
32+
return json_error("Session not found", 404)
33+
return json_response(session)
3434
except Exception:
3535
# Full traceback (class name, message, stack) goes to the server log
3636
# via logger.exception. The HTTP body returns a stable, generic
3737
# message — never the class name or `e` itself, which would leak
3838
# internal field names, file paths, and user values to any client
3939
# (issue #25).
4040
current_app.logger.exception("Failed to parse session %s", session_id)
41-
return json_ok({"error": "Failed to parse session"}), 500
41+
return json_error("Failed to parse session", 500)
4242

4343

4444
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>/stats")
@@ -47,20 +47,20 @@ def get_session_stats(project_name: str, session_id: str) -> FlaskReturn:
4747
try:
4848
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
4949
except ValueError:
50-
return json_ok({"error": "Invalid path"}), 400
50+
return json_error("Invalid path", 400)
5151

5252
if not os.path.isfile(filepath):
53-
return json_ok({"error": f"Session {session_id} not found"}), 404
53+
return json_error(f"Session {session_id} not found", 404)
5454

5555
try:
5656
session = parse_session(filepath)
5757
rules = current_app.config.get("EXCLUSION_RULES") or []
5858
if is_session_excluded(rules, session, project_name):
59-
return json_ok({"error": "Session not found"}), 404
59+
return json_error("Session not found", 404)
6060
stats = compute_stats(session)
61-
return json_ok(stats)
61+
return json_response(stats)
6262
except Exception:
6363
# Same pattern as get_session above — full detail to the server log,
6464
# generic message in the HTTP body (issue #25).
6565
current_app.logger.exception("Failed to compute stats for %s", session_id)
66-
return json_ok({"error": "Failed to compute session stats"}), 500
66+
return json_error("Failed to compute session stats", 500)

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@
22
python_version = "3.12"
33
strict = true
44
packages = ["api", "utils", "models"]
5-
exclude = ["tests/"]
5+
6+
[[tool.mypy.overrides]]
7+
files = ["tests"]
8+
strict = false
9+
disallow_untyped_defs = false
10+
disallow_untyped_calls = false

tests/test_search.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010

1111
REPO_ROOT = Path(__file__).resolve().parent.parent
12+
# TODO: drop sys.path hack when pyproject.toml gains a [project] table + pip install -e .
1213
sys.path.insert(0, str(REPO_ROOT))
1314

1415
from flask import Flask # noqa: E402
@@ -63,6 +64,20 @@ def test_limit_non_numeric_returns_400(self, client):
6364
assert "error" in body
6465
assert "limit" in body["error"].lower()
6566

67+
def test_limit_zero_returns_400(self, client):
68+
resp = client.get("/api/search?q=test&limit=0")
69+
assert resp.status_code == 400
70+
body = resp.get_json()
71+
assert "error" in body
72+
assert "limit" in body["error"].lower()
73+
74+
def test_limit_negative_returns_400(self, client):
75+
resp = client.get("/api/search?q=test&limit=-5")
76+
assert resp.status_code == 400
77+
body = resp.get_json()
78+
assert "error" in body
79+
assert "limit" in body["error"].lower()
80+
6681
def test_limit_default_when_omitted(self, client, tmp_path):
6782
_write_searchable_session(tmp_path, "proj-a", "sess-1", "findme keyword here")
6883
resp = client.get("/api/search?q=findme")

0 commit comments

Comments
 (0)