From 6aeee884fb380e6693f7498ec85e8117ee8d52ba Mon Sep 17 00:00:00 2001 From: Akos Orban Date: Sat, 1 Aug 2026 22:51:25 +0200 Subject: [PATCH 1/2] fix(api): surface server error messages on failed requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every non-2xx used to render as a bare `✖ HTTP 400`, which cost a full diagnostic round when `variants add` was broken. The client now reads the error body once and derives a detail from `message` (string, or a string array joined with `; `), then `error`, then a short printable non-JSON body — rendering `✖ HTTP 400 — ` on the existing error path, so every command benefits without per-command changes. Bodies with nothing usable — today's production `{statusCode, timestamp, path}`, an empty body, a stripped body, an HTML error page, or the intentionally bare 500 — degrade to exactly the previous `✖ HTTP ` output. `--json` behaviour is unchanged: errors go to stderr and stdout stays empty, so machine output stays machine output. Bumps the version to 0.3.1. The release itself waits until travel-plans #127 is merged and deployed, so the improvement is real when it ships. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AsdP2hvt4XQhYxkdNREXUq --- package.json | 2 +- src/api/client.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++++ src/api/client.ts | 64 ++++++++++++++++++++++++++++++--- 3 files changed, 142 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1bb2374..eb9920a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mantacodedevs/mna-cli", - "version": "0.3.0", + "version": "0.3.1", "description": "Command-line tool for My Next Adventure trip planning.", "license": "MIT", "type": "module", diff --git a/src/api/client.test.ts b/src/api/client.test.ts index ae5e0b7..5179b35 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -58,6 +58,87 @@ describe('createApiClient', () => { global.fetch = originalFetch }) + async function failingRequest(response: Response): Promise { + global.fetch = (async () => response.clone()) as unknown as typeof fetch + + const client = createApiClient({ + baseUrl: 'https://api.example.invalid', + apiKey: 'mna_live_test', + }) + + try { + await client.GET('/v1/trips', { + params: { query: { includeExample: false, status: 'planning' } }, + }) + throw new Error('expected the request to reject') + } catch (err) { + return err as Error + } finally { + global.fetch = originalFetch + } + } + + const jsonResponse = (body: unknown, status: number) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + + test('surfaces a string message from the error body', async () => { + const err = await failingRequest( + jsonResponse({ statusCode: 400, message: 'location.coordinates must be { lat, lng }' }, 400), + ) + + expect(err.message).toBe('HTTP 400 — location.coordinates must be { lat, lng }') + }) + + test('joins an array of validation messages with "; "', async () => { + const err = await failingRequest( + jsonResponse({ statusCode: 400, message: ['startDate must be a date', 'name should not be empty'] }, 400), + ) + + expect(err.message).toBe('HTTP 400 — startDate must be a date; name should not be empty') + }) + + test('falls back to the `error` field when there is no message', async () => { + const err = await failingRequest(jsonResponse({ statusCode: 409, error: 'Conflict' }, 409)) + + expect(err.message).toBe('HTTP 409 — Conflict') + }) + + test('uses a short printable non-JSON body as the detail', async () => { + const err = await failingRequest(new Response('Bad Gateway', { status: 502 })) + + expect(err.message).toBe('HTTP 502 — Bad Gateway') + }) + + test('ignores a long or unprintable non-JSON body', async () => { + const html = `\n${'x'.repeat(500)}\n` + const err = await failingRequest(new Response(html, { status: 503 })) + + expect(err.message).toBe('HTTP 503') + }) + + test('degrades to the bare status line on an empty body', async () => { + const err = await failingRequest(new Response(null, { status: 400 })) + + expect(err.message).toBe('HTTP 400') + }) + + test('degrades to the bare status line on a message-less body', async () => { + const err = await failingRequest( + jsonResponse({ statusCode: 400, timestamp: '2026-07-28T00:00:00.000Z', path: '/v1/trips' }, 400), + ) + + expect(err.message).toBe('HTTP 400') + }) + + test('never renders undefined for a bare 500', async () => { + const err = await failingRequest( + jsonResponse({ statusCode: 500, timestamp: '2026-07-28T00:00:00.000Z', path: '/v1/trips' }, 500), + ) + + expect(err.message).toBe('HTTP 500') + expect(err.message).not.toContain('undefined') + }) + test('throws ApiError with status + body on non-2xx', async () => { global.fetch = (async () => { return new Response(JSON.stringify({ message: 'API key is missing' }), { diff --git a/src/api/client.ts b/src/api/client.ts index c6592d4..3b7753b 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -19,6 +19,58 @@ export class ApiError extends Error { } } +const MAX_RAW_BODY_DETAIL_LENGTH = 200 +const FIRST_PRINTABLE_CHAR_CODE = 0x20 + +function joinMessage(value: unknown): string | undefined { + if (typeof value === 'string') return value.trim() || undefined + if (Array.isArray(value)) { + const parts = value + .filter((part): part is string => typeof part === 'string') + .map((part) => part.trim()) + .filter(Boolean) + return parts.length > 0 ? parts.join('; ') : undefined + } + return undefined +} + +function isPrintable(text: string): boolean { + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) < FIRST_PRINTABLE_CHAR_CODE) return false + } + return true +} + +function printableRawBody(text: string): string | undefined { + const trimmed = text.trim() + if (!trimmed || trimmed.length > MAX_RAW_BODY_DETAIL_LENGTH || !isPrintable(trimmed)) { + return undefined + } + return trimmed +} + +function parseJsonBody(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +/** + * Best-effort human-readable detail for a failed response. A body with nothing + * usable in it — today's production `{statusCode, timestamp, path}`, a stripped + * body, an HTML error page — yields undefined so the caller degrades to the + * bare status line. + */ +function extractErrorDetail(body: unknown, rawText: string): string | undefined { + if (body !== null && typeof body === 'object') { + const { message, error } = body as { message?: unknown; error?: unknown } + return joinMessage(message) ?? joinMessage(error) + } + return joinMessage(body) ?? printableRawBody(rawText) +} + export function createApiClient({ baseUrl, apiKey }: CreateApiClientOptions): Api { const client = createOpenApiFetch({ baseUrl }) @@ -29,10 +81,14 @@ export function createApiClient({ baseUrl, apiKey }: CreateApiClientOptions): Ap }, async onResponse({ response }) { if (!response.ok) { - const body = await response.clone().json().catch(() => undefined) - const message = - (body as { message?: string } | undefined)?.message ?? `HTTP ${response.status}` - throw new ApiError(response.status, message, body) + const rawText = await response + .clone() + .text() + .catch(() => '') + const body = parseJsonBody(rawText) + const detail = extractErrorDetail(body, rawText) + const status = `HTTP ${response.status}` + throw new ApiError(response.status, detail ? `${status} — ${detail}` : status, body) } // Some endpoints (e.g. option creation) return a 2xx with an empty body and From 18831b09e888510eb3db553137b6aa9a5c508d27 Mon Sep 17 00:00:00 2001 From: Akos Orban Date: Sat, 1 Aug 2026 22:54:19 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20refresh=20openapi=20snapshot=20?= =?UTF-8?q?=E2=80=94=20#127=20deployed=20(event=20flat=20location,=20error?= =?UTF-8?q?=20messages)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AsdP2hvt4XQhYxkdNREXUq --- openapi.json | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/openapi.json b/openapi.json index af1a79a..c9de80d 100644 --- a/openapi.json +++ b/openapi.json @@ -3802,14 +3802,35 @@ "googlePlaceId": { "type": "string" }, + "locationiqPlaceId": { + "type": "string" + }, "name": { "type": "string" }, "formattedAddress": { - "type": "string" + "type": "string", + "description": "Full postal address. Takes precedence over `address` when both are sent." + }, + "address": { + "type": "string", + "description": "Alias of `formattedAddress`, matching the option endpoints." }, "coordinates": { - "$ref": "#/components/schemas/CreateEventLocationCoordinatesV1Dto" + "description": "Coordinates as `{ lat, lng }`. Takes precedence over `latitude`/`longitude` when both are sent.", + "allOf": [ + { + "$ref": "#/components/schemas/CreateEventLocationCoordinatesV1Dto" + } + ] + }, + "latitude": { + "type": "number", + "description": "Alias of `coordinates.lat`, matching the option endpoints. Must be sent with `longitude`." + }, + "longitude": { + "type": "number", + "description": "Alias of `coordinates.lng`, matching the option endpoints. Must be sent with `latitude`." } } }, @@ -4382,7 +4403,7 @@ "format": "date-time" }, "location": { - "$ref": "#/components/schemas/CreateOptionLocationV1Dto" + "$ref": "#/components/schemas/CreateEventLocationV1Dto" }, "eventType": { "type": "string",