Refactor API client and stock management logic for improved clarity, error handling, and support for additional stock types.
This commit is contained in:
+197
-44
@@ -1,70 +1,202 @@
|
||||
import { API_PATHS } from '../app/config.js';
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return baseUrl.trim().replace(/\/+$/, '');
|
||||
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 encodedDatabase = encodeURIComponent(database);
|
||||
const encodedPath = path.replace(/^\/+/, '');
|
||||
const kitchenSegment =
|
||||
includeKitchen && kitchenId
|
||||
? `/kitchen/${encodeURIComponent(String(kitchenId))}`
|
||||
: '';
|
||||
|
||||
const url = new URL(
|
||||
`${cleanBaseUrl}/${encodedDatabase}${kitchenSegment}/${encodedPath}`,
|
||||
);
|
||||
const pathname = buildPathname({
|
||||
database,
|
||||
kitchenId,
|
||||
path,
|
||||
includeKitchen,
|
||||
});
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
url.searchParams.set(key, String(value));
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
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 response.json();
|
||||
return parseJsonResponse(response);
|
||||
}
|
||||
|
||||
if (contentType.includes('image/')) {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(response, payload) {
|
||||
const message =
|
||||
payload?.message ||
|
||||
payload?.error ||
|
||||
`Request failed with status ${response.status}.`;
|
||||
function flattenErrorObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Error(message, {
|
||||
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: payload?.errors || payload?.details || null,
|
||||
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.baseUrl || !config.database) {
|
||||
throw new Error('Server URL and database name are required.');
|
||||
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');
|
||||
|
||||
@@ -76,28 +208,49 @@ export async function apiRequest(store, path, options = {}) {
|
||||
headers.set('Authorization', `Bearer ${session.applicationKey}`);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildUrl({
|
||||
baseUrl: config.baseUrl,
|
||||
database: config.database,
|
||||
kitchenId: activeKitchen?.id,
|
||||
path,
|
||||
query: options.query,
|
||||
includeKitchen: options.includeKitchen !== false,
|
||||
}),
|
||||
{
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
body:
|
||||
options.body && !options.isFormData
|
||||
? JSON.stringify(options.body)
|
||||
: options.body || undefined,
|
||||
},
|
||||
);
|
||||
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) {
|
||||
throw normalizeError(response, payload);
|
||||
const apiError = normalizeError(response, payload, { url, method });
|
||||
logApiFailure('API request returned an error response', {
|
||||
url,
|
||||
method,
|
||||
status: response.status,
|
||||
payload,
|
||||
});
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
||||
+17
-14
@@ -1,5 +1,9 @@
|
||||
import { apiRequest, getPath } from './client.js';
|
||||
|
||||
function unwrapEntryPayload(payload) {
|
||||
return payload?.data || payload?.entry || payload?.item || payload;
|
||||
}
|
||||
|
||||
export async function searchItemDefinitions(store, query) {
|
||||
if (query.trim().length <= 2) {
|
||||
return [];
|
||||
@@ -30,8 +34,10 @@ export async function listStockEntries(store, filters = {}) {
|
||||
}
|
||||
|
||||
export async function getStockEntry(store, stockId) {
|
||||
const payload = await apiRequest(store, `${getPath('stockEntries')}/${stockId}`);
|
||||
return payload?.data || payload?.entry || payload;
|
||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, {
|
||||
includeKitchen: false,
|
||||
});
|
||||
return unwrapEntryPayload(payload);
|
||||
}
|
||||
|
||||
export async function createStockEntry(store, body) {
|
||||
@@ -41,7 +47,7 @@ export async function createStockEntry(store, body) {
|
||||
includeKitchen: false,
|
||||
query: { label: 1 },
|
||||
});
|
||||
return payload?.data || payload?.entry || payload;
|
||||
return unwrapEntryPayload(payload);
|
||||
}
|
||||
|
||||
export async function updateStockItem(store, uuidB64, body) {
|
||||
@@ -50,7 +56,7 @@ export async function updateStockItem(store, uuidB64, body) {
|
||||
body,
|
||||
includeKitchen: false,
|
||||
});
|
||||
return payload?.data || payload?.entry || payload;
|
||||
return unwrapEntryPayload(payload);
|
||||
}
|
||||
|
||||
export async function deleteStockItem(store, uuidB64) {
|
||||
@@ -58,17 +64,14 @@ export async function deleteStockItem(store, uuidB64) {
|
||||
method: 'DELETE',
|
||||
includeKitchen: false,
|
||||
});
|
||||
return payload?.data || payload?.entry || payload;
|
||||
return unwrapEntryPayload(payload);
|
||||
}
|
||||
|
||||
export async function adjustStockEntry(store, stockId, body) {
|
||||
const payload = await apiRequest(
|
||||
store,
|
||||
`${getPath('stockEntries')}/${stockId}/adjust`,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
},
|
||||
);
|
||||
return payload?.data || payload?.entry || payload;
|
||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
includeKitchen: false,
|
||||
});
|
||||
return unwrapEntryPayload(payload);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user