@biffo/cli 0.215.2 → 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.
- package/_skeletons/sibling-template/apps/frontend/src/app/page.test.tsx +1 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/api-client.test.ts +63 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/api-client.ts +48 -2
- package/_skeletons/sibling-template/services/api/src/api/core_client.py +31 -2
- package/_skeletons/sibling-template/services/api/tests/test_core_client.py +73 -0
- package/package.json +1 -1
|
@@ -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(() =>
|
|
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
|
|
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
|
|
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
|