2026-04-06 09:24:22 +02:00
|
|
|
import { API_PATHS } from '../app/config.js';
|
2026-04-06 11:06:07 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:43:39 +02:00
|
|
|
function buildPathname({ database, path }) {
|
2026-04-06 11:06:07 +02:00
|
|
|
const encodedDatabase = encodeURIComponent(database);
|
2026-04-06 16:35:25 +02:00
|
|
|
const rawPath = String(path || '').replace(/^\/+/, '');
|
|
|
|
|
const keepTrailingSlash = rawPath.endsWith('/');
|
|
|
|
|
const encodedPathSegments = rawPath
|
2026-04-06 11:06:07 +02:00
|
|
|
.split('/')
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map((segment) => encodeURIComponent(segment));
|
|
|
|
|
const segments = [encodedDatabase];
|
|
|
|
|
|
|
|
|
|
segments.push(...encodedPathSegments);
|
|
|
|
|
|
2026-04-06 16:35:25 +02:00
|
|
|
const pathname = `/${segments.join('/')}`;
|
|
|
|
|
return keepTrailingSlash ? `${pathname}/` : pathname;
|
2026-04-06 11:06:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:43:39 +02:00
|
|
|
function buildUrl({ baseUrl, database, path, query = {} }) {
|
2026-04-06 11:06:07 +02:00
|
|
|
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
|
|
|
|
const pathname = buildPathname({
|
|
|
|
|
database,
|
|
|
|
|
path,
|
|
|
|
|
});
|
|
|
|
|
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();
|
|
|
|
|
}
|
2026-04-06 10:30:37 +02:00
|
|
|
|
|
|
|
|
async function parseJsonResponse(response) {
|
|
|
|
|
const text = await response.text();
|
|
|
|
|
if (!text) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(text);
|
|
|
|
|
} catch {
|
|
|
|
|
return text;
|
|
|
|
|
}
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function parseResponse(response) {
|
2026-04-06 10:30:37 +02:00
|
|
|
if (response.status === 204) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
const contentType = response.headers.get('content-type') || '';
|
|
|
|
|
|
|
|
|
|
if (contentType.includes('application/json')) {
|
2026-04-06 10:30:37 +02:00
|
|
|
return parseJsonResponse(response);
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contentType.includes('image/')) {
|
|
|
|
|
return response.blob();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
const text = await response.text();
|
|
|
|
|
if (!text) {
|
2026-04-06 09:24:22 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
try {
|
|
|
|
|
return JSON.parse(text);
|
|
|
|
|
} catch {
|
|
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flattenErrorObject(value) {
|
|
|
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Object.fromEntries(
|
|
|
|
|
Object.entries(value).map(([key, entry]) => {
|
|
|
|
|
if (Array.isArray(entry)) {
|
|
|
|
|
return [key, entry.join(', ')];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [key, String(entry)];
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractErrorMessage(response, payload) {
|
|
|
|
|
if (typeof payload === 'string' && payload.trim()) {
|
|
|
|
|
return payload;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(payload) && payload.length) {
|
|
|
|
|
return payload.map((entry) => String(entry)).join(', ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload && typeof payload === 'object') {
|
|
|
|
|
const directMessage =
|
|
|
|
|
payload.message ||
|
|
|
|
|
payload.error ||
|
|
|
|
|
payload.detail ||
|
|
|
|
|
payload.description ||
|
|
|
|
|
payload.title;
|
|
|
|
|
|
|
|
|
|
if (typeof directMessage === 'string' && directMessage.trim()) {
|
|
|
|
|
return directMessage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fieldErrors = flattenErrorObject(payload.errors || payload.details);
|
|
|
|
|
if (fieldErrors) {
|
|
|
|
|
return Object.entries(fieldErrors)
|
|
|
|
|
.map(([field, message]) => `${field}: ${message}`)
|
|
|
|
|
.join(' | ');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `Request failed with status ${response.status}.`;
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
function normalizeError(response, payload, request) {
|
|
|
|
|
const details = flattenErrorObject(payload?.errors || payload?.details) || null;
|
2026-04-06 11:06:07 +02:00
|
|
|
const message =
|
|
|
|
|
extractErrorMessage(response, payload);
|
2026-04-06 09:24:22 +02:00
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
const error = new Error(message, {
|
2026-04-06 09:24:22 +02:00
|
|
|
cause: {
|
|
|
|
|
status: response.status,
|
2026-04-06 10:30:37 +02:00
|
|
|
details,
|
2026-04-06 11:06:07 +02:00
|
|
|
url: request.url,
|
2026-04-06 10:30:37 +02:00
|
|
|
method: request.method,
|
|
|
|
|
payload,
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
});
|
2026-04-06 10:30:37 +02:00
|
|
|
|
|
|
|
|
error.name = 'ApiRequestError';
|
|
|
|
|
error.status = response.status;
|
2026-04-06 11:06:07 +02:00
|
|
|
error.url = request.url;
|
2026-04-06 10:30:37 +02:00
|
|
|
error.method = request.method;
|
|
|
|
|
error.details = details;
|
|
|
|
|
error.payload = payload;
|
|
|
|
|
|
|
|
|
|
return error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logApiFailure(message, context) {
|
|
|
|
|
console.error(message, context);
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:31:31 +02:00
|
|
|
function isAuthErrorStatus(status) {
|
|
|
|
|
return status === 401 || status === 403;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isKitchensPath(path) {
|
|
|
|
|
return String(path || '').replace(/^\/+/, '').replace(/\/+$/, '') === API_PATHS.kitchens;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:43:39 +02:00
|
|
|
function isKitchenApiPath(path) {
|
|
|
|
|
return String(path || '').replace(/^\/+/, '').startsWith('kitchen/');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:31:31 +02:00
|
|
|
function shouldInvalidateValidatedSession(store, path, options = {}) {
|
|
|
|
|
if (options.skipAuthFailureHandler) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!store.session?.applicationKey || !store.session?.hasValidated) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
isKitchensPath(path) ||
|
2026-04-10 15:43:39 +02:00
|
|
|
isKitchenApiPath(path) ||
|
2026-04-06 18:31:31 +02:00
|
|
|
path === API_PATHS.items ||
|
|
|
|
|
path === API_PATHS.locations ||
|
2026-04-10 15:43:39 +02:00
|
|
|
path === API_PATHS.changes ||
|
2026-04-06 18:31:31 +02:00
|
|
|
String(path || '').startsWith(`${API_PATHS.items}/`)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
export async function apiRequest(store, path, options = {}) {
|
2026-04-10 15:43:39 +02:00
|
|
|
const { config, session } = store;
|
2026-04-06 09:24:22 +02:00
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
if (!config.database) {
|
|
|
|
|
throw new Error('Database name is required.');
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
const method = options.method || 'GET';
|
2026-04-06 11:06:07 +02:00
|
|
|
const url = buildUrl({
|
2026-04-06 10:30:37 +02:00
|
|
|
baseUrl: config.baseUrl,
|
|
|
|
|
database: config.database,
|
|
|
|
|
path,
|
|
|
|
|
query: options.query,
|
|
|
|
|
});
|
2026-04-06 09:24:22 +02:00
|
|
|
const headers = new Headers(options.headers || {});
|
|
|
|
|
headers.set('Accept', options.accept || 'application/json');
|
|
|
|
|
|
|
|
|
|
if (options.body && !options.isFormData) {
|
|
|
|
|
headers.set('Content-Type', 'application/json');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session?.applicationKey) {
|
|
|
|
|
headers.set('Authorization', `Bearer ${session.applicationKey}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
let response;
|
|
|
|
|
try {
|
2026-04-06 11:06:07 +02:00
|
|
|
response = await fetch(url, {
|
2026-04-06 10:30:37 +02:00
|
|
|
method,
|
|
|
|
|
headers,
|
|
|
|
|
credentials: options.credentials || 'same-origin',
|
|
|
|
|
body:
|
|
|
|
|
options.body && !options.isFormData
|
|
|
|
|
? JSON.stringify(options.body)
|
|
|
|
|
: options.body || undefined,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const networkError = new Error(
|
2026-04-06 11:06:07 +02:00
|
|
|
`Network request failed for ${method} ${url}. Check the server URL, nginx proxy, and CORS configuration.`,
|
2026-04-06 10:30:37 +02:00
|
|
|
{
|
|
|
|
|
cause: {
|
2026-04-06 11:06:07 +02:00
|
|
|
url,
|
2026-04-06 10:30:37 +02:00
|
|
|
method,
|
|
|
|
|
originalError: error,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
networkError.name = 'ApiNetworkError';
|
2026-04-06 11:06:07 +02:00
|
|
|
networkError.url = url;
|
2026-04-06 10:30:37 +02:00
|
|
|
networkError.method = method;
|
|
|
|
|
logApiFailure('API network request failed', {
|
2026-04-06 11:06:07 +02:00
|
|
|
url,
|
2026-04-06 10:30:37 +02:00
|
|
|
method,
|
|
|
|
|
error,
|
|
|
|
|
});
|
2026-04-06 18:31:31 +02:00
|
|
|
if (shouldInvalidateValidatedSession(store, path, options)) {
|
|
|
|
|
window.__loncApp?.handleAuthFailure?.(networkError);
|
|
|
|
|
}
|
2026-04-06 10:30:37 +02:00
|
|
|
throw networkError;
|
|
|
|
|
}
|
2026-04-06 09:24:22 +02:00
|
|
|
|
|
|
|
|
const payload = await parseResponse(response);
|
|
|
|
|
if (!response.ok) {
|
2026-04-06 11:06:07 +02:00
|
|
|
const apiError = normalizeError(response, payload, { url, method });
|
2026-04-06 10:30:37 +02:00
|
|
|
logApiFailure('API request returned an error response', {
|
2026-04-06 11:06:07 +02:00
|
|
|
url,
|
2026-04-06 10:30:37 +02:00
|
|
|
method,
|
|
|
|
|
status: response.status,
|
|
|
|
|
payload,
|
|
|
|
|
});
|
2026-04-06 18:31:31 +02:00
|
|
|
if (
|
|
|
|
|
isAuthErrorStatus(response.status) &&
|
|
|
|
|
(
|
|
|
|
|
shouldInvalidateValidatedSession(store, path, options) ||
|
|
|
|
|
(store.session?.state === 'connected' && response.status === 403 && isKitchensPath(path))
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
window.__loncApp?.handleAuthFailure?.(apiError);
|
|
|
|
|
}
|
2026-04-06 10:30:37 +02:00
|
|
|
throw apiError;
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getPath(key) {
|
|
|
|
|
return API_PATHS[key];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildKitchenApiUrl(store, path, query = {}) {
|
2026-04-06 11:06:07 +02:00
|
|
|
return buildUrl({
|
2026-04-06 09:24:22 +02:00
|
|
|
baseUrl: store.config.baseUrl,
|
|
|
|
|
database: store.config.database,
|
|
|
|
|
path,
|
|
|
|
|
query,
|
2026-04-06 11:06:07 +02:00
|
|
|
});
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|