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:
+2
-1
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user