Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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`."
}
}
},
Expand Down Expand Up @@ -4382,7 +4403,7 @@
"format": "date-time"
},
"location": {
"$ref": "#/components/schemas/CreateOptionLocationV1Dto"
"$ref": "#/components/schemas/CreateEventLocationV1Dto"
},
"eventType": {
"type": "string",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
81 changes: 81 additions & 0 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,87 @@ describe('createApiClient', () => {
global.fetch = originalFetch
})

async function failingRequest(response: Response): Promise<Error> {
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 = `<html>\n<body>${'x'.repeat(500)}</body>\n</html>`
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' }), {
Expand Down
64 changes: 60 additions & 4 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<paths>({ baseUrl })

Expand All @@ -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
Expand Down
Loading