@biffo/cli 0.215.3 → 0.215.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,6 +37,7 @@ describe('HomePage', () => {
37
37
  get: vi.fn().mockResolvedValueOnce({ username: 'keiran' }),
38
38
  post: vi.fn(),
39
39
  put: vi.fn(),
40
+ patch: vi.fn(),
40
41
  delete: vi.fn(),
41
42
  })
42
43
 
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it, vi, afterEach } from 'vitest'
2
+ import { ApiError, createApiClient, extractErrorMessage } from './api-client'
3
+
4
+ /**
5
+ * A backend's error body is JSON like `{"detail": "..."}`. Throwing the whole
6
+ * body as the message rendered `{"detail":"Internal Server Error"}` at the user
7
+ * in a course player (tabsii-lms#13) and `{"detail":"Administrator access
8
+ * required"}` where a user list belonged (biffo-template#1107). The API is
9
+ * behaving; the presentation is not.
10
+ *
11
+ * These mirror `services/api/tests/test_core_client.py::TestExtractDetail`
12
+ * case for case, because the two layers solve the same problem and drifting
13
+ * apart would make neither reasonable about.
14
+ */
15
+ describe('extractErrorMessage', () => {
16
+ it('unwraps a JSON detail body', () => {
17
+ expect(extractErrorMessage('{"detail":"Internal Server Error"}', 'Server Error')).toBe(
18
+ 'Internal Server Error',
19
+ )
20
+ })
21
+
22
+ it('falls back to the raw text when the body is not JSON', () => {
23
+ expect(extractErrorMessage('<html>Bad Gateway</html>', 'Bad Gateway')).toBe(
24
+ '<html>Bad Gateway</html>',
25
+ )
26
+ })
27
+
28
+ it('falls back to the raw text when the JSON has no detail key', () => {
29
+ expect(extractErrorMessage('{"message":"nope"}', 'Bad Request')).toBe('{"message":"nope"}')
30
+ })
31
+
32
+ it('falls back to the raw text when detail is not a string', () => {
33
+ const body = '{"detail":[{"loc":["body","x"],"msg":"bad"}]}'
34
+ expect(extractErrorMessage(body, 'Unprocessable Entity')).toBe(body)
35
+ })
36
+
37
+ it('uses the status text when the body is empty', () => {
38
+ expect(extractErrorMessage('', 'Internal Server Error')).toBe('Internal Server Error')
39
+ })
40
+ })
41
+
42
+ describe('createApiClient error handling', () => {
43
+ afterEach(() => {
44
+ vi.unstubAllGlobals()
45
+ })
46
+
47
+ it('throws an ApiError carrying the message, not the wire format', async () => {
48
+ vi.stubGlobal(
49
+ 'fetch',
50
+ vi.fn().mockResolvedValue({
51
+ ok: false,
52
+ status: 500,
53
+ statusText: 'Internal Server Error',
54
+ text: () => Promise.resolve('{"detail":"Internal Server Error"}'),
55
+ }),
56
+ )
57
+
58
+ const api = createApiClient(() => 'token')
59
+ await expect(api.get('/api/v1/courses')).rejects.toThrow(
60
+ new ApiError(500, 'Internal Server Error'),
61
+ )
62
+ })
63
+ })
@@ -15,10 +15,46 @@ export class ApiError extends Error {
15
15
  }
16
16
  }
17
17
 
18
+ /**
19
+ * The message a person should see for a failed request — not the wire format.
20
+ *
21
+ * A Biffo backend's errors are JSON like `{"detail": "..."}`. Throwing the
22
+ * whole body as the message renders `{"detail":"Internal Server Error"}` at
23
+ * the user (tabsii-lms#13, biffo-template#1107) — the API is behaving, the
24
+ * presentation is not.
25
+ *
26
+ * This is the browser-side twin of `_extract_detail` in this repo's
27
+ * `services/api/src/api/core_client.py`, which solves the identical problem
28
+ * server-side, and it keeps that function's reasoning deliberately:
29
+ *
30
+ * - **Parsed, never assumed.** A non-JSON body (a proxy timeout page, a
31
+ * CloudFront error), or JSON with no `detail`, falls back to the raw text
32
+ * unchanged — so this can never hide information the caller had before.
33
+ * - **A non-string `detail` also falls back.** FastAPI's own 422 makes
34
+ * `detail` a list of field errors; picking something out of it would just
35
+ * move the problem rather than fix it.
36
+ *
37
+ * The one addition over the Python version: an empty body yields the HTTP
38
+ * status text, because a browser rendering `''` shows the user nothing at all.
39
+ */
40
+ export function extractErrorMessage(body: string, statusText: string): string {
41
+ let parsed: unknown
42
+ try {
43
+ parsed = JSON.parse(body)
44
+ } catch {
45
+ return body || statusText
46
+ }
47
+ if (typeof parsed === 'object' && parsed !== null && 'detail' in parsed) {
48
+ const { detail } = parsed
49
+ if (typeof detail === 'string') return detail || statusText
50
+ }
51
+ return body || statusText
52
+ }
53
+
18
54
  async function handleResponse<T>(res: Response): Promise<T> {
19
55
  if (!res.ok) {
20
- const body = await res.text().catch(() => res.statusText)
21
- throw new ApiError(res.status, body)
56
+ const body = await res.text().catch(() => '')
57
+ throw new ApiError(res.status, extractErrorMessage(body, res.statusText))
22
58
  }
23
59
  return res.json() as Promise<T>
24
60
  }
@@ -50,6 +86,16 @@ export function createApiClient(getIdToken: () => string | null) {
50
86
  body: JSON.stringify(body),
51
87
  }).then((r) => handleResponse<T>(r)),
52
88
 
89
+ // Folded in from tabsii-crm, which had added it locally. It costs a sibling
90
+ // that never calls it nothing, and leaving it out would mean this file
91
+ // could never be distributed without destroying crm's copy.
92
+ patch: <T>(path: string, body: unknown): Promise<T> =>
93
+ fetch(`${API_URL}${path}`, {
94
+ method: 'PATCH',
95
+ headers: authHeaders(),
96
+ body: JSON.stringify(body),
97
+ }).then((r) => handleResponse<T>(r)),
98
+
53
99
  delete: <T>(path: string): Promise<T> =>
54
100
  fetch(`${API_URL}${path}`, { method: 'DELETE', headers: authHeaders() }).then((r) =>
55
101
  handleResponse<T>(r),
@@ -14,6 +14,35 @@ class CoreApiError(Exception):
14
14
  super().__init__(detail)
15
15
 
16
16
 
17
+ def _extract_detail(response: httpx.Response) -> str:
18
+ """The message a caller should see for an upstream error, not the whole body.
19
+
20
+ The core's own error responses are JSON like ``{"detail": "..."}`` --
21
+ ``response.text`` is that ENTIRE body, so raising it as-is and then handing
22
+ it to FastAPI's ``HTTPException(detail=...)`` serialises it a second time.
23
+ The client then receives ``{"detail": "{\\"detail\\": \\"<message>\\"}"}``:
24
+ valid JSON, but a JSON *string* rather than the message inside it, and any
25
+ component that renders ``detail`` shows that raw escaped blob to a user
26
+ (tabsii-crm#137).
27
+
28
+ Parsed rather than assumed: a non-JSON or JSON-without-``detail`` upstream
29
+ body (a proxy timeout page, a differently-shaped error) falls back to the
30
+ raw text unchanged, so this never hides information the caller had before.
31
+
32
+ Fixed in two siblings independently before it was ever fixed HERE, which is
33
+ why three more were still shipping ``response.text`` months later. The
34
+ browser-side twin is ``extractErrorMessage`` in
35
+ ``apps/frontend/src/lib/api-client.ts``; keep the two in step.
36
+ """
37
+ try:
38
+ body = response.json()
39
+ except ValueError:
40
+ return response.text
41
+ if isinstance(body, dict) and isinstance(body.get("detail"), str):
42
+ return body["detail"]
43
+ return response.text
44
+
45
+
17
46
  class CoreApiClient:
18
47
  """
19
48
  Thin per-request client for calling the core project's API (ADR-0002/
@@ -38,7 +67,7 @@ class CoreApiClient:
38
67
  headers={"Authorization": f"Bearer {self._bearer_token}"},
39
68
  )
40
69
  if response.is_error:
41
- raise CoreApiError(response.status_code, response.text)
70
+ raise CoreApiError(response.status_code, _extract_detail(response))
42
71
  return response.json() # type: ignore[no-any-return]
43
72
 
44
73
  async def post(self, path: str, body: dict) -> dict:
@@ -49,7 +78,7 @@ class CoreApiClient:
49
78
  headers={"Authorization": f"Bearer {self._bearer_token}"},
50
79
  )
51
80
  if response.is_error:
52
- raise CoreApiError(response.status_code, response.text)
81
+ raise CoreApiError(response.status_code, _extract_detail(response))
53
82
  return response.json() # type: ignore[no-any-return]
54
83
 
55
84
 
@@ -0,0 +1,73 @@
1
+ """A proxied error must reach a caller as a plain message, not a JSON string
2
+ re-serialised from an already-JSON upstream body.
3
+
4
+ `CoreApiClient` raises `CoreApiError(status_code, detail)` on every verb, and a
5
+ router turns that into `HTTPException(status_code=exc.status_code,
6
+ detail=exc.detail)`. When `detail` was `response.text` — the WHOLE upstream
7
+ body — a core error like `{"detail": "Method Not Allowed"}` became, once
8
+ FastAPI serialised it a second time, `{"detail": "{\\"detail\\": \\"Method Not
9
+ Allowed\\"}"}`: valid JSON, but a JSON *string* rather than the message inside
10
+ it. Any component that renders `detail` showed that raw escaped blob to a user.
11
+
12
+ Found and fixed independently in two siblings (tabsii-crm#137, tabsii-lms#13)
13
+ before it was ever fixed in this skeleton, so three more siblings were still
14
+ shipping the bug months later. The test lives here now so a sibling is born
15
+ with it.
16
+ """
17
+
18
+ import httpx
19
+ import pytest
20
+
21
+ from api.config import settings
22
+ from api.core_client import CoreApiClient, CoreApiError, _extract_detail
23
+
24
+
25
+ class TestExtractDetail:
26
+ """The parsing rule alone, with no network involved."""
27
+
28
+ def test_unwraps_a_json_detail_body(self) -> None:
29
+ response = httpx.Response(422, json={"detail": "Method Not Allowed"})
30
+ assert _extract_detail(response) == "Method Not Allowed"
31
+
32
+ def test_falls_back_to_raw_text_when_not_json(self) -> None:
33
+ response = httpx.Response(502, content=b"<html>Bad Gateway</html>")
34
+ assert _extract_detail(response) == "<html>Bad Gateway</html>"
35
+
36
+ def test_falls_back_to_raw_text_when_json_has_no_detail_key(self) -> None:
37
+ response = httpx.Response(400, json={"message": "nope"})
38
+ assert _extract_detail(response) == response.text
39
+
40
+ def test_falls_back_to_raw_text_when_detail_is_not_a_string(self) -> None:
41
+ # A validation-error body shaped like FastAPI's own 422 (`detail` is a
42
+ # list of field errors) — extracting a non-string would just move the
43
+ # double-encoding problem rather than fix it, so this is left as the
44
+ # raw body precisely like the "not JSON" case.
45
+ response = httpx.Response(422, json={"detail": [{"loc": ["body", "x"], "msg": "bad"}]})
46
+ assert _extract_detail(response) == response.text
47
+
48
+
49
+ class TestCoreApiClientErrorPassthrough:
50
+ """A mocked upstream error reaches `CoreApiError.detail` flat."""
51
+
52
+ async def test_get_surfaces_the_inner_message_not_the_whole_body(
53
+ self, monkeypatch: pytest.MonkeyPatch
54
+ ) -> None:
55
+ monkeypatch.setattr(settings, "core_api_url", "https://core.example.com")
56
+
57
+ def handler(request: httpx.Request) -> httpx.Response:
58
+ return httpx.Response(403, json={"detail": "Administrator access required"})
59
+
60
+ transport = httpx.MockTransport(handler)
61
+ original = httpx.AsyncClient
62
+
63
+ def patched(*args, **kwargs): # type: ignore[no-untyped-def]
64
+ kwargs["transport"] = transport
65
+ return original(*args, **kwargs)
66
+
67
+ monkeypatch.setattr(httpx, "AsyncClient", patched)
68
+
69
+ with pytest.raises(CoreApiError) as excinfo:
70
+ await CoreApiClient("token").get("/api/v1/admin/users")
71
+
72
+ assert excinfo.value.detail == "Administrator access required"
73
+ assert excinfo.value.status_code == 403
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.215.3",
3
+ "version": "0.215.4",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",