Files
lonc/src/api/client.js
T

273 lines
6.3 KiB
JavaScript
Raw Normal View History

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();
}
async function parseJsonResponse(response) {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function parseResponse(response) {
if (response.status === 204) {
return null;
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return parseJsonResponse(response);
}
if (contentType.includes('image/')) {
return response.blob();
}
const text = await response.text();
if (!text) {
return null;
}
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}.`;
}
function normalizeError(response, payload, request) {
const details = flattenErrorObject(payload?.errors || payload?.details) || null;
const message =
extractErrorMessage(response, payload);
const error = new Error(message, {
cause: {
status: response.status,
details,
url: request.url,
method: request.method,
payload,
},
});
error.name = 'ApiRequestError';
error.status = response.status;
error.url = request.url;
error.method = request.method;
error.details = details;
error.payload = payload;
return error;
}
function logApiFailure(message, context) {
console.error(message, context);
}
export async function apiRequest(store, path, options = {}) {
const { config, session, activeKitchen } = store;
if (!config.database) {
throw new Error('Database name is required.');
}
const method = options.method || 'GET';
const url = buildUrl({
baseUrl: config.baseUrl,
database: config.database,
kitchenId: activeKitchen?.id,
path,
query: options.query,
includeKitchen: options.includeKitchen !== false,
});
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}`);
}
let response;
try {
response = await fetch(url, {
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(
`Network request failed for ${method} ${url}. Check the server URL, nginx proxy, and CORS configuration.`,
{
cause: {
url,
method,
originalError: error,
},
},
);
networkError.name = 'ApiNetworkError';
networkError.url = url;
networkError.method = method;
logApiFailure('API network request failed', {
url,
method,
error,
});
throw networkError;
}
const payload = await parseResponse(response);
if (!response.ok) {
const apiError = normalizeError(response, payload, { url, method });
logApiFailure('API request returned an error response', {
url,
method,
status: response.status,
payload,
});
throw apiError;
}
return payload;
}
export function getPath(key) {
return API_PATHS[key];
}
export function buildKitchenApiUrl(store, path, query = {}) {
return buildUrl({
baseUrl: store.config.baseUrl,
database: store.config.database,
kitchenId: store.activeKitchen?.id,
path,
query,
includeKitchen: true,
});
}