Skip to content

Commit 232cffc

Browse files
feat: structured {error, code} bodies across all API blueprints
1 parent f022aae commit 232cffc

14 files changed

Lines changed: 557 additions & 224 deletions

api/composers.py

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from flask import Blueprint, Response
1515

16-
from api.flask_config import json_response
16+
from api.flask_config import api_error, json_response
1717

1818
from utils.workspace_path import resolve_workspace_path
1919
from utils.path_helpers import to_epoch_ms
@@ -28,6 +28,25 @@ def _read_json_file(path: str) -> Any:
2828
return json.load(f)
2929

3030

31+
def _parse_workspace_composer_rows(data: Any) -> list[Any]:
32+
"""Validate ``composer.composerData`` envelope and return ``allComposers``."""
33+
if not isinstance(data, dict):
34+
raise SchemaError(
35+
"WorkspaceComposers",
36+
"composer.composerData",
37+
hint=f"expected object, got {type(data).__name__}",
38+
)
39+
if "allComposers" not in data:
40+
raise SchemaError("WorkspaceComposers", "allComposers")
41+
all_composers = data.get("allComposers")
42+
if not isinstance(all_composers, list):
43+
raise SchemaError(
44+
"WorkspaceComposers",
45+
"allComposers",
46+
hint=f"expected list, got {type(all_composers).__name__}",
47+
)
48+
return all_composers
49+
3150
@bp.route("/api/composers")
3251
def list_composers() -> tuple[Response, int] | Response:
3352
"""List all composers across workspace databases (GET /api/composers).
@@ -67,22 +86,7 @@ def list_composers() -> tuple[Response, int] | Response:
6786
).fetchone()
6887

6988
if row and row[0]:
70-
data = json.loads(row[0])
71-
if not isinstance(data, dict):
72-
raise SchemaError(
73-
"WorkspaceComposers",
74-
"composer.composerData",
75-
hint=f"expected object, got {type(data).__name__}",
76-
)
77-
if "allComposers" not in data:
78-
raise SchemaError("WorkspaceComposers", "allComposers")
79-
all_composers = data.get("allComposers")
80-
if not isinstance(all_composers, list):
81-
raise SchemaError(
82-
"WorkspaceComposers",
83-
"allComposers",
84-
hint=f"expected list, got {type(all_composers).__name__}",
85-
)
89+
all_composers = _parse_workspace_composer_rows(json.loads(row[0]))
8690
for c in all_composers:
8791
try:
8892
local = WorkspaceLocalComposer.from_dict(c)
@@ -126,7 +130,7 @@ def list_composers() -> tuple[Response, int] | Response:
126130

127131
except Exception:
128132
_logger.exception("Failed to get composers")
129-
return json_response({"error": "Failed to get composers"}, 500)
133+
return api_error("Failed to get composers", "composers_list_failed", 500)
130134

131135

132136
@bp.route("/api/composers/<composer_id>")
@@ -163,25 +167,7 @@ def get_composer(composer_id: str) -> tuple[Response, int] | Response:
163167
).fetchone()
164168

165169
if row and row[0]:
166-
data = json.loads(row[0])
167-
# Mirror the envelope guards list_composers() applies at line 60–74
168-
# so a drifted local row (data not a dict, or allComposers missing
169-
# / non-list) surfaces as a logged SchemaError, not a 500.
170-
if not isinstance(data, dict):
171-
raise SchemaError(
172-
"WorkspaceComposers",
173-
"composer.composerData",
174-
hint=f"expected object, got {type(data).__name__}",
175-
)
176-
if "allComposers" not in data:
177-
raise SchemaError("WorkspaceComposers", "allComposers")
178-
all_composers = data.get("allComposers")
179-
if not isinstance(all_composers, list):
180-
raise SchemaError(
181-
"WorkspaceComposers",
182-
"allComposers",
183-
hint=f"expected list, got {type(all_composers).__name__}",
184-
)
170+
all_composers = _parse_workspace_composer_rows(json.loads(row[0]))
185171
for c in all_composers:
186172
if isinstance(c, dict) and c.get("composerId") == composer_id:
187173
try:
@@ -240,14 +226,14 @@ def get_composer(composer_id: str) -> tuple[Response, int] | Response:
240226
e,
241227
type(e).__name__,
242228
)
243-
return json_response({"error": "Composer schema drift"}, 404)
229+
return api_error("Composer schema drift", "composer_schema_drift", 404)
244230
payload = composer.cursor_storage_payload()
245231
payload["conversation"] = payload.get("conversation") or []
246232
return json_response(payload)
247233
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):
248234
pass
249235

250-
return json_response({"error": "Composer not found"}, 404)
236+
return api_error("Composer not found", "composer_not_found", 404)
251237
except Exception:
252238
_logger.exception("Failed to get composer")
253-
return json_response({"error": "Failed to get composer"}, 500)
239+
return api_error("Failed to get composer", "composer_get_failed", 500)

api/config_api.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,37 @@
1313

1414
from flask import Blueprint, Response, request
1515

16-
from api.flask_config import json_response
16+
from api.flask_config import api_error, json_response
1717

1818
from utils.path_validation import WorkspacePathError, validate_workspace_path
1919
from utils.workspace_path import set_workspace_path_override
2020

2121
bp = Blueprint("config_api", __name__)
2222
_logger = logging.getLogger(__name__)
2323

24+
_WORKSPACE_PATH_ERROR_CODES: dict[str, str] = {
25+
"path is required": "path_required",
26+
"path does not exist": "path_not_found",
27+
"path is not a directory": "path_not_directory",
28+
(
29+
"path does not look like a Cursor workspaceStorage directory "
30+
"(no immediate subdirectory contains state.vscdb)"
31+
): "path_not_workspace_storage",
32+
}
33+
34+
35+
def _workspace_path_error_code(message: str) -> str:
36+
return _WORKSPACE_PATH_ERROR_CODES.get(message, "invalid_workspace_path")
37+
38+
39+
def _validate_path_error(
40+
message: str,
41+
code: str,
42+
) -> Response | tuple[Response, int]:
43+
return json_response(
44+
{"valid": False, "error": message, "code": code, "workspaceCount": 0},
45+
)
46+
2447

2548
@bp.route("/api/detect-environment")
2649
def detect_environment() -> Response:
@@ -84,14 +107,13 @@ def validate_path() -> tuple[Response, int] | Response:
84107
try:
85108
body = request.get_json(silent=True) or {}
86109
if not isinstance(body, dict):
87-
return json_response(
88-
{"valid": False, "error": "invalid JSON body", "workspaceCount": 0}
89-
)
110+
return _validate_path_error("invalid JSON body", "invalid_json_body")
90111
raw = body.get("path", "")
91112
try:
92113
canonical = validate_workspace_path(raw)
93114
except WorkspacePathError as e:
94-
return json_response({"valid": False, "error": str(e), "workspaceCount": 0})
115+
message = str(e)
116+
return _validate_path_error(message, _workspace_path_error_code(message))
95117

96118
workspace_count = 0
97119
for name in os.listdir(canonical):
@@ -116,7 +138,10 @@ def validate_path() -> tuple[Response, int] | Response:
116138
type(e).__name__,
117139
exc_info=True,
118140
)
119-
return json_response({"valid": False, "error": "Failed to validate path"}, 500)
141+
return json_response(
142+
{"valid": False, "error": "Failed to validate path", "code": "validate_path_failed", "workspaceCount": 0},
143+
500,
144+
)
120145

121146

122147
@bp.route("/api/set-workspace", methods=["POST"])
@@ -139,7 +164,7 @@ def set_workspace() -> tuple[Response, int] | Response:
139164
# instead of a 400 client error. (CodeRabbit on PR #16.)
140165
body = request.get_json(silent=True)
141166
if not isinstance(body, dict):
142-
return json_response({"error": "request body must be a JSON object"}, 400)
167+
return api_error("request body must be a JSON object", "invalid_json_body", 400)
143168
raw = body.get("path", "")
144169
# Validate the supplied path BEFORE storing the override (issue #15).
145170
# validate_workspace_path collapses `..` traversal AND resolves symlinks
@@ -149,13 +174,14 @@ def set_workspace() -> tuple[Response, int] | Response:
149174
try:
150175
canonical = validate_workspace_path(raw)
151176
except WorkspacePathError as e:
152-
return json_response({"error": str(e)}, 400)
177+
message = str(e)
178+
return api_error(message, _workspace_path_error_code(message), 400)
153179
except Exception: # noqa: BLE001 — only here as a fallback
154-
return json_response({"error": "Failed to validate workspace path"}, 500)
180+
return api_error("Failed to validate workspace path", "validate_workspace_path_failed", 500)
155181
try:
156182
set_workspace_path_override(canonical)
157183
except Exception: # noqa: BLE001 — keep the response shape structured JSON
158-
return json_response({"error": "Failed to set workspace path"}, 500)
184+
return api_error("Failed to set workspace path", "set_workspace_path_failed", 500)
159185
return json_response({"success": True, "path": canonical})
160186

161187

0 commit comments

Comments
 (0)