From 739cb371575c22491c05e9acc1c89ee4be9825fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Mon, 6 Apr 2026 10:48:03 +0200 Subject: [PATCH] Refactor API client to use `resolveApiRequestUrl` for improved URL resolution, enhanced error handling, and clearer cross-origin request support. Add test script to `package.json`. --- package.json | 3 +- src/api/client.js | 127 ++++++++++++++++++---------------------------- 2 files changed, 51 insertions(+), 79 deletions(-) diff --git a/package.json b/package.json index 6d50036..fb42544 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test" }, "dependencies": { "alpinejs": "^3.14.9", diff --git a/src/api/client.js b/src/api/client.js index 06964a2..13e7c9b 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -1,68 +1,5 @@ import { API_PATHS } from '../app/config.js'; - -function normalizeBaseUrl(baseUrl) { - return String(baseUrl || '').trim().replace(/\/+$/, ''); -} - -function isSameOriginBaseUrl(baseUrl) { - if (!baseUrl) { - return true; - } - - if (typeof window === 'undefined') { - return false; - } - - try { - return new URL(baseUrl, window.location.origin).origin === window.location.origin; - } catch { - return false; - } -} - -function buildPathname({ database, kitchenId, path, includeKitchen = true }) { - const encodedDatabase = encodeURIComponent(database); - const encodedPathSegments = String(path || '') - .replace(/^\/+/, '') - .split('/') - .filter(Boolean) - .map((segment) => encodeURIComponent(segment)); - const segments = [encodedDatabase]; - - if (includeKitchen && kitchenId) { - segments.push('kitchen', encodeURIComponent(String(kitchenId))); - } - - segments.push(...encodedPathSegments); - - return `/${segments.join('/')}`; -} - -function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) { - const cleanBaseUrl = normalizeBaseUrl(baseUrl); - const pathname = buildPathname({ - database, - kitchenId, - path, - includeKitchen, - }); - const searchParams = new URLSearchParams(); - - Object.entries(query).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - searchParams.set(key, String(value)); - } - }); - - const queryString = searchParams.toString(); - const relativeUrl = queryString ? `${pathname}?${queryString}` : pathname; - - if (!cleanBaseUrl || isSameOriginBaseUrl(cleanBaseUrl)) { - return relativeUrl; - } - - return new URL(relativeUrl, `${cleanBaseUrl}/`).toString(); -} +import { resolveApiRequestUrl } from './url.js'; async function parseJsonResponse(response) { const text = await response.text(); @@ -154,14 +91,31 @@ function extractErrorMessage(response, payload) { function normalizeError(response, payload, request) { const details = flattenErrorObject(payload?.errors || payload?.details) || null; - const message = - extractErrorMessage(response, payload); + const payloadMessage = extractErrorMessage(response, payload); + const statusHint = + response.status === 404 + ? 'This usually means the request hit the wrong origin, port, or path.' + : response.status === 401 || response.status === 403 + ? 'Authentication or application-key validation failed.' + : response.status >= 500 + ? 'The backend reported a server error.' + : ''; + const message = [ + `${request.method} ${request.resolvedUrl} returned ${response.status}.`, + statusHint, + payloadMessage && !payloadMessage.startsWith('Request failed with status') + ? payloadMessage + : '', + ] + .filter(Boolean) + .join(' '); const error = new Error(message, { cause: { status: response.status, details, - url: request.url, + url: request.resolvedUrl, + fetchUrl: request.fetchUrl, method: request.method, payload, }, @@ -169,7 +123,8 @@ function normalizeError(response, payload, request) { error.name = 'ApiRequestError'; error.status = response.status; - error.url = request.url; + error.url = request.resolvedUrl; + error.fetchUrl = request.fetchUrl; error.method = request.method; error.details = details; error.payload = payload; @@ -189,8 +144,10 @@ export async function apiRequest(store, path, options = {}) { } const method = options.method || 'GET'; - const url = buildUrl({ + const currentOrigin = typeof window !== 'undefined' ? window.location.origin : ''; + const requestTarget = resolveApiRequestUrl({ baseUrl: config.baseUrl, + currentOrigin, database: config.database, kitchenId: activeKitchen?.id, path, @@ -210,7 +167,7 @@ export async function apiRequest(store, path, options = {}) { let response; try { - response = await fetch(url, { + response = await fetch(requestTarget.fetchUrl, { method, headers, credentials: options.credentials || 'same-origin', @@ -220,21 +177,29 @@ export async function apiRequest(store, path, options = {}) { : options.body || undefined, }); } catch (error) { + const networkHint = requestTarget.isCrossOrigin + ? 'Cross-origin request failed before a usable response was returned. Check nginx CORS/preflight handling and that the configured API origin is reachable.' + : 'Request failed before a response was returned. Check that the current origin serves the API or configure the Tryton server URL explicitly.'; const networkError = new Error( - `Network request failed for ${method} ${url}. Check the server URL, nginx proxy, and CORS configuration.`, + `${method} ${requestTarget.resolvedUrl} failed before a response was returned. ${networkHint}`, { cause: { - url, + url: requestTarget.resolvedUrl, + fetchUrl: requestTarget.fetchUrl, method, + isCrossOrigin: requestTarget.isCrossOrigin, originalError: error, }, }, ); networkError.name = 'ApiNetworkError'; - networkError.url = url; + networkError.url = requestTarget.resolvedUrl; + networkError.fetchUrl = requestTarget.fetchUrl; networkError.method = method; + networkError.isCrossOrigin = requestTarget.isCrossOrigin; logApiFailure('API network request failed', { - url, + url: requestTarget.resolvedUrl, + fetchUrl: requestTarget.fetchUrl, method, error, }); @@ -243,9 +208,14 @@ export async function apiRequest(store, path, options = {}) { const payload = await parseResponse(response); if (!response.ok) { - const apiError = normalizeError(response, payload, { url, method }); + const apiError = normalizeError(response, payload, { + resolvedUrl: requestTarget.resolvedUrl, + fetchUrl: requestTarget.fetchUrl, + method, + }); logApiFailure('API request returned an error response', { - url, + url: requestTarget.resolvedUrl, + fetchUrl: requestTarget.fetchUrl, method, status: response.status, payload, @@ -261,12 +231,13 @@ export function getPath(key) { } export function buildKitchenApiUrl(store, path, query = {}) { - return buildUrl({ + return resolveApiRequestUrl({ baseUrl: store.config.baseUrl, + currentOrigin: typeof window !== 'undefined' ? window.location.origin : '', database: store.config.database, kitchenId: store.activeKitchen?.id, path, query, includeKitchen: true, - }); + }).resolvedUrl; }