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.

This commit is contained in:
2026-04-06 10:48:03 +02:00
parent 155c7a65d6
commit 739cb37157
2 changed files with 51 additions and 79 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"alpinejs": "^3.14.9", "alpinejs": "^3.14.9",
+49 -78
View File
@@ -1,68 +1,5 @@
import { API_PATHS } from '../app/config.js'; import { API_PATHS } from '../app/config.js';
import { resolveApiRequestUrl } from './url.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();
}
async function parseJsonResponse(response) { async function parseJsonResponse(response) {
const text = await response.text(); const text = await response.text();
@@ -154,14 +91,31 @@ function extractErrorMessage(response, payload) {
function normalizeError(response, payload, request) { function normalizeError(response, payload, request) {
const details = flattenErrorObject(payload?.errors || payload?.details) || null; const details = flattenErrorObject(payload?.errors || payload?.details) || null;
const message = const payloadMessage = extractErrorMessage(response, payload);
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, { const error = new Error(message, {
cause: { cause: {
status: response.status, status: response.status,
details, details,
url: request.url, url: request.resolvedUrl,
fetchUrl: request.fetchUrl,
method: request.method, method: request.method,
payload, payload,
}, },
@@ -169,7 +123,8 @@ function normalizeError(response, payload, request) {
error.name = 'ApiRequestError'; error.name = 'ApiRequestError';
error.status = response.status; error.status = response.status;
error.url = request.url; error.url = request.resolvedUrl;
error.fetchUrl = request.fetchUrl;
error.method = request.method; error.method = request.method;
error.details = details; error.details = details;
error.payload = payload; error.payload = payload;
@@ -189,8 +144,10 @@ export async function apiRequest(store, path, options = {}) {
} }
const method = options.method || 'GET'; const method = options.method || 'GET';
const url = buildUrl({ const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '';
const requestTarget = resolveApiRequestUrl({
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
currentOrigin,
database: config.database, database: config.database,
kitchenId: activeKitchen?.id, kitchenId: activeKitchen?.id,
path, path,
@@ -210,7 +167,7 @@ export async function apiRequest(store, path, options = {}) {
let response; let response;
try { try {
response = await fetch(url, { response = await fetch(requestTarget.fetchUrl, {
method, method,
headers, headers,
credentials: options.credentials || 'same-origin', credentials: options.credentials || 'same-origin',
@@ -220,21 +177,29 @@ export async function apiRequest(store, path, options = {}) {
: options.body || undefined, : options.body || undefined,
}); });
} catch (error) { } 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( 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: { cause: {
url, url: requestTarget.resolvedUrl,
fetchUrl: requestTarget.fetchUrl,
method, method,
isCrossOrigin: requestTarget.isCrossOrigin,
originalError: error, originalError: error,
}, },
}, },
); );
networkError.name = 'ApiNetworkError'; networkError.name = 'ApiNetworkError';
networkError.url = url; networkError.url = requestTarget.resolvedUrl;
networkError.fetchUrl = requestTarget.fetchUrl;
networkError.method = method; networkError.method = method;
networkError.isCrossOrigin = requestTarget.isCrossOrigin;
logApiFailure('API network request failed', { logApiFailure('API network request failed', {
url, url: requestTarget.resolvedUrl,
fetchUrl: requestTarget.fetchUrl,
method, method,
error, error,
}); });
@@ -243,9 +208,14 @@ export async function apiRequest(store, path, options = {}) {
const payload = await parseResponse(response); const payload = await parseResponse(response);
if (!response.ok) { 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', { logApiFailure('API request returned an error response', {
url, url: requestTarget.resolvedUrl,
fetchUrl: requestTarget.fetchUrl,
method, method,
status: response.status, status: response.status,
payload, payload,
@@ -261,12 +231,13 @@ export function getPath(key) {
} }
export function buildKitchenApiUrl(store, path, query = {}) { export function buildKitchenApiUrl(store, path, query = {}) {
return buildUrl({ return resolveApiRequestUrl({
baseUrl: store.config.baseUrl, baseUrl: store.config.baseUrl,
currentOrigin: typeof window !== 'undefined' ? window.location.origin : '',
database: store.config.database, database: store.config.database,
kitchenId: store.activeKitchen?.id, kitchenId: store.activeKitchen?.id,
path, path,
query, query,
includeKitchen: true, includeKitchen: true,
}); }).resolvedUrl;
} }