551 lines
22 KiB
JavaScript
551 lines
22 KiB
JavaScript
function enc(value) {
|
|
return encodeURIComponent(String(value));
|
|
}
|
|
|
|
function buildUrl(baseUrl, path) {
|
|
return new URL(path, baseUrl || window.location.origin).href;
|
|
}
|
|
|
|
function buildPath(path, query) {
|
|
if (!query) return path;
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (value == null) continue;
|
|
params.set(key, String(value));
|
|
}
|
|
const qs = params.toString();
|
|
return qs ? `${path}?${qs}` : path;
|
|
}
|
|
|
|
/**
|
|
* Thin HTTP client for the dashback API (cookie auth, JSON / blob / empty).
|
|
*/
|
|
export class ApiClient {
|
|
#baseUrl;
|
|
|
|
constructor(baseUrl = window.location.origin) {
|
|
this.#baseUrl = baseUrl;
|
|
|
|
this.auth = {
|
|
/** POST /api/auth/login */
|
|
login: (body) => this.#json('POST', '/api/auth/login', { body }),
|
|
/** POST /api/auth/logout */
|
|
logout: () => this.#json('POST', '/api/auth/logout'),
|
|
/** GET /api/auth/me */
|
|
me: () => this.#json('GET', '/api/auth/me'),
|
|
/** PUT /api/auth/selected-company */
|
|
selectedCompany: (body) => this.#json('PUT', '/api/auth/selected-company', { body }),
|
|
};
|
|
|
|
this.health = {
|
|
/** GET /api/health */
|
|
get: () => this.#json('GET', '/api/health'),
|
|
};
|
|
|
|
this.company = {
|
|
/** GET /api/company */
|
|
list: () => this.#json('GET', '/api/company'),
|
|
/** POST /api/company */
|
|
create: (body) => this.#json('POST', '/api/company', { body }),
|
|
/** PUT /api/company/{id} */
|
|
update: (id, body) => this.#json('PUT', `/api/company/${enc(id)}`, { body }),
|
|
/** DELETE /api/company/{id} */
|
|
delete: (id) => this.#empty('DELETE', `/api/company/${enc(id)}`),
|
|
};
|
|
|
|
this.unit = {
|
|
/** GET /api/unit */
|
|
list: () => this.#json('GET', '/api/unit'),
|
|
/** PUT /api/unit */
|
|
upsert: (body) => this.#json('PUT', '/api/unit', { body }),
|
|
/** DELETE /api/unit?si=… */
|
|
delete: (si) => this.#empty('DELETE', '/api/unit', { query: { si } }),
|
|
};
|
|
|
|
this.brand = {
|
|
/** GET /api/brand */
|
|
list: () => this.#json('GET', '/api/brand'),
|
|
/** PUT /api/brand */
|
|
upsert: (body) => this.#json('PUT', '/api/brand', { body }),
|
|
/** DELETE /api/brand/{id} */
|
|
delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
|
|
};
|
|
|
|
this.supplier = {
|
|
/** GET /api/supplier → { suppliers: Supplier[] } */
|
|
list: () => this.#json('GET', '/api/supplier'),
|
|
/** POST /api/supplier → 201 { supplier } */
|
|
create: (body) => this.#json('POST', '/api/supplier', { body }),
|
|
/** PATCH /api/supplier/{id} → { supplier } */
|
|
update: (id, body) => this.#json('PATCH', `/api/supplier/${enc(id)}`, { body }),
|
|
/** DELETE /api/supplier/{id} → 204 */
|
|
delete: (id) => this.#empty('DELETE', `/api/supplier/${enc(id)}`),
|
|
/** GET /api/supplier/{id}/articles?skus=... → { articles: SupplierArticle[] } */
|
|
getArticles: (id, skus) => {
|
|
let query = '';
|
|
if (skus != null) {
|
|
const list = Array.isArray(skus) ? skus : [skus];
|
|
const filtered = list.map((s) => String(s).trim()).filter(Boolean);
|
|
if (filtered.length > 0) {
|
|
query = `?skus=${encodeURIComponent(filtered.join(','))}`;
|
|
}
|
|
}
|
|
return this.#json('GET', `/api/supplier/${enc(id)}/articles${query}`);
|
|
},
|
|
};
|
|
|
|
this.warehouse = {
|
|
/** GET /api/warehouse → { warehouses: Warehouse[] } */
|
|
list: () => this.#json('GET', '/api/warehouse'),
|
|
/** GET /api/warehouse/{id} → { warehouse: Warehouse } */
|
|
getById: (id) => this.#json('GET', `/api/warehouse/${enc(id)}`),
|
|
/** POST /api/warehouse → 201 { warehouse: Warehouse } */
|
|
create: (body) => this.#json('POST', '/api/warehouse', { body }),
|
|
/** PATCH /api/warehouse/{id} → 200 { warehouse: Warehouse } */
|
|
update: (id, body) => this.#json('PATCH', `/api/warehouse/${enc(id)}`, { body }),
|
|
/** DELETE /api/warehouse/{id} → 204 */
|
|
delete: (id) => this.#empty('DELETE', `/api/warehouse/${enc(id)}`),
|
|
};
|
|
|
|
this.stock = {
|
|
/** POST /api/stock/adjust → 201 { manifest, lines, ledger } */
|
|
adjust: (body) => this.#json('POST', '/api/stock/adjust', { body }),
|
|
};
|
|
|
|
this.delivery = {
|
|
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
|
|
list: () => this.#json('GET', '/api/delivery'),
|
|
/** GET /api/delivery/{id} → { delivery: Delivery } */
|
|
get: (id) => this.#json('GET', `/api/delivery/${enc(id)}`),
|
|
/** POST /api/delivery → 201 { delivery: Delivery } */
|
|
create: (body) => this.#json('POST', '/api/delivery', { body }),
|
|
/** PATCH /api/delivery/{id} → { delivery: Delivery } */
|
|
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
|
|
/** DELETE /api/delivery/{id} → 204 */
|
|
delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`),
|
|
/** GET /api/delivery/{id}/file/{fileId} → Blob (full binary content) */
|
|
getFile: (id, fileId, { download = false } = {}) =>
|
|
this.#blob('GET', `/api/delivery/${enc(id)}/file/${enc(fileId)}`, {
|
|
query: download ? { download: 'true' } : undefined,
|
|
}),
|
|
/**
|
|
* POST /api/delivery/parse — multipart/form-data, field "file"
|
|
* Accepts PDF or text files (max 50 MB), detection via mimetype/filename
|
|
* Returns an async generator that yields SSE event objects:
|
|
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object }
|
|
*/
|
|
parseSse: async function* (file, { signal } = {}) {
|
|
const formData = new FormData();
|
|
formData.append('file', file, file.name);
|
|
const res = await fetch('/api/delivery/parse', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData,
|
|
signal,
|
|
});
|
|
if (!res.ok) {
|
|
let message = res.statusText;
|
|
try {
|
|
const data = await res.json();
|
|
message = data.error || data.message || message;
|
|
} catch { /* ignore */ }
|
|
const err = new Error(message || `Request failed (${res.status})`);
|
|
err.status = res.status;
|
|
throw err;
|
|
}
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
let currentEvent = 'message';
|
|
|
|
const onAbort = () => {
|
|
try {
|
|
reader.cancel();
|
|
} catch { /* ignore */ }
|
|
};
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
}
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop(); // keep incomplete last line
|
|
for (const line of lines) {
|
|
if (line.startsWith('event:')) {
|
|
currentEvent = line.slice(6).trim();
|
|
} else if (line.startsWith('data:')) {
|
|
const raw = line.slice(5).trim();
|
|
let data;
|
|
try { data = JSON.parse(raw); } catch { data = raw; }
|
|
yield { event: currentEvent, data };
|
|
currentEvent = 'message';
|
|
}
|
|
// blank line or comment: reset
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (signal?.aborted || err.name === 'AbortError') {
|
|
return;
|
|
}
|
|
throw err;
|
|
} finally {
|
|
if (signal) {
|
|
signal.removeEventListener('abort', onAbort);
|
|
}
|
|
try {
|
|
reader.releaseLock();
|
|
} catch { /* ignore */ }
|
|
}
|
|
},
|
|
parsePdfSse(file, opts) {
|
|
return this.parseSse(file, opts);
|
|
},
|
|
};
|
|
|
|
this.ledger = {
|
|
/** POST /api/ledger → 201 { ledger: LedgerEntry } */
|
|
add: (body) => this.#json('POST', '/api/ledger', { body }),
|
|
/** GET /api/ledger/manifestarticle/{id} → { ledger: LedgerEntry[] } */
|
|
listByManifestarticleId: (manifestarticleId, transactionType) =>
|
|
this.#json('GET', `/api/ledger/manifestarticle/${enc(manifestarticleId)}`, {
|
|
query: transactionType ? { transaction_type: transactionType } : undefined,
|
|
}),
|
|
/** GET /api/ledger/sku/{sku} → { ledger: LedgerEntry[] } */
|
|
listBySku: (sku, transactionType) =>
|
|
this.#json('GET', `/api/ledger/sku/${enc(sku)}`, {
|
|
query: transactionType ? { transaction_type: transactionType } : undefined,
|
|
}),
|
|
/** GET /api/ledger → { ledger: LedgerEntry[] } */
|
|
list: (query) => this.#json('GET', '/api/ledger', { query }),
|
|
};
|
|
|
|
this.quote = {
|
|
/** GET /api/quote → { quotes } */
|
|
list: () => this.#json('GET', '/api/quote'),
|
|
/** GET /api/quote/{id} → { quote } */
|
|
get: (id) => this.#json('GET', `/api/quote/${enc(id)}`),
|
|
/** POST /api/quote → 201 { quote } */
|
|
create: (body) => this.#json('POST', '/api/quote', { body }),
|
|
/** PATCH /api/quote/{id} → { quote } */
|
|
update: (id, body) => this.#json('PATCH', `/api/quote/${enc(id)}`, { body }),
|
|
/** DELETE /api/quote/{id} → 204 */
|
|
delete: (id) => this.#empty('DELETE', `/api/quote/${enc(id)}`),
|
|
};
|
|
|
|
this.order = {
|
|
/** GET /api/order → { orders } */
|
|
list: () => this.#json('GET', '/api/order'),
|
|
/** GET /api/order/{id} → { order } */
|
|
get: (id) => this.#json('GET', `/api/order/${enc(id)}`),
|
|
/** POST /api/order → 201 { order } */
|
|
create: (body) => this.#json('POST', '/api/order', { body }),
|
|
/** PATCH /api/order/{id} → { order } */
|
|
update: (id, body) => this.#json('PATCH', `/api/order/${enc(id)}`, { body }),
|
|
/** DELETE /api/order/{id} → 204 */
|
|
delete: (id) => this.#empty('DELETE', `/api/order/${enc(id)}`),
|
|
};
|
|
|
|
this.customergroup = {
|
|
/** GET /api/customergroup → { customergroups: { id, name, sort, editable }[] } */
|
|
list: () => this.#json('GET', '/api/customergroup'),
|
|
/** POST /api/customergroup body { name } → 201 { customergroup: { id, name, sort } } */
|
|
create: (body) => this.#json('POST', '/api/customergroup', { body }),
|
|
/** PATCH /api/customergroup/{id} body { name } → { customergroup: { id, name, sort } } */
|
|
update: (id, body) =>
|
|
this.#json('PATCH', `/api/customergroup/${enc(id)}`, { body }),
|
|
/** PUT /api/customergroup/order body { customergroupIds } → { customergroupIds } */
|
|
order: (body) => this.#json('PUT', '/api/customergroup/order', { body }),
|
|
/** DELETE /api/customergroup/{id} → 204 */
|
|
delete: (id) => this.#empty('DELETE', `/api/customergroup/${enc(id)}`),
|
|
};
|
|
|
|
this.entity = {
|
|
/** GET /api/entity → { entities } · optional query customergroupId */
|
|
list: (query) => this.#json('GET', '/api/entity', { query }),
|
|
/** GET /api/entity/{id} → { entity } */
|
|
get: (id) => this.#json('GET', `/api/entity/${enc(id)}`),
|
|
/** POST /api/entity → 201 { entity } */
|
|
create: (body) => this.#json('POST', '/api/entity', { body }),
|
|
/** PATCH /api/entity/{id} → { entity } */
|
|
update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }),
|
|
/** DELETE /api/entity/{id} → 204 */
|
|
delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`),
|
|
/** GET /api/entity/next-number → { number: string } */
|
|
nextNumber: () => this.#json('GET', '/api/entity/next-number'),
|
|
/** GET /api/entity/{entityId}/address → { addresses } */
|
|
listAddresses: (entityId) =>
|
|
this.#json('GET', `/api/entity/${enc(entityId)}/address`),
|
|
/** POST /api/entity/{entityId}/address → 201 { address } */
|
|
createAddress: (entityId, body) =>
|
|
this.#json('POST', `/api/entity/${enc(entityId)}/address`, { body }),
|
|
/** PATCH /api/entity/{entityId}/address/{id} → { address } */
|
|
updateAddress: (entityId, id, body) =>
|
|
this.#json('PATCH', `/api/entity/${enc(entityId)}/address/${enc(id)}`, {
|
|
body,
|
|
}),
|
|
/** DELETE /api/entity/{entityId}/address/{id} → 204 */
|
|
deleteAddress: (entityId, id) =>
|
|
this.#empty('DELETE', `/api/entity/${enc(entityId)}/address/${enc(id)}`),
|
|
/** GET /api/entity/{entityId}/contact → { contacts } */
|
|
listContacts: (entityId) =>
|
|
this.#json('GET', `/api/entity/${enc(entityId)}/contact`),
|
|
/** POST /api/entity/{entityId}/contact → 201 { contact } */
|
|
createContact: (entityId, body) =>
|
|
this.#json('POST', `/api/entity/${enc(entityId)}/contact`, { body }),
|
|
/** PATCH /api/entity/{entityId}/contact/{id} → { contact } */
|
|
updateContact: (entityId, id, body) =>
|
|
this.#json('PATCH', `/api/entity/${enc(entityId)}/contact/${enc(id)}`, {
|
|
body,
|
|
}),
|
|
/** DELETE /api/entity/{entityId}/contact/{id} → 204 */
|
|
deleteContact: (entityId, id) =>
|
|
this.#empty('DELETE', `/api/entity/${enc(entityId)}/contact/${enc(id)}`),
|
|
};
|
|
|
|
this.category = {
|
|
/** GET /api/category/tree */
|
|
tree: () => this.#json('GET', '/api/category/tree'),
|
|
/** GET /api/category/export.zip */
|
|
exportZip: () => this.#blob('GET', '/api/category/export.zip'),
|
|
/** POST /api/category */
|
|
create: (body) => this.#json('POST', '/api/category', { body }),
|
|
/** GET /api/category/{id}/articles */
|
|
articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`),
|
|
/** PATCH /api/category/{id} */
|
|
update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }),
|
|
/** DELETE /api/category/{id} */
|
|
delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`),
|
|
/** PUT /api/category/order */
|
|
order: (body) => this.#json('PUT', '/api/category/order', { body }),
|
|
/** POST /api/category/{id}/image — raw AVIF body */
|
|
addImage: (id, avifBytes) =>
|
|
this.#json('POST', `/api/category/${enc(id)}/image`, {
|
|
body: avifBytes,
|
|
contentType: 'image/avif',
|
|
raw: true,
|
|
}),
|
|
/** DELETE /api/category/{id}/image */
|
|
deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`),
|
|
};
|
|
|
|
this.article = {
|
|
/** GET /api/articleIndex */
|
|
index: () => this.#json('GET', '/api/articleIndex'),
|
|
/** POST /api/article */
|
|
create: (body) => this.#json('POST', '/api/article', { body }),
|
|
/** GET /api/article/export.zip */
|
|
exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }),
|
|
/** GET /api/article/{sku} */
|
|
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
|
|
/** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
|
|
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
|
|
/**
|
|
* Load history and refetch on SSE `article-<sku>`.
|
|
* Paginates in StorageManager until the HTTP API supports skip/offset.
|
|
* `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`.
|
|
*/
|
|
historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory),
|
|
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */
|
|
update: async (sku, body) => {
|
|
const nameChange = body != null && Object.hasOwn(body, 'name');
|
|
if (nameChange) {
|
|
this.setArticleIndexName(sku, body.name);
|
|
this.setArticleName(sku, body.name);
|
|
}
|
|
try {
|
|
const res = await this.#json('PATCH', `/api/article/${enc(sku)}`, { body });
|
|
this.setArticle(res.article);
|
|
return res;
|
|
} catch (err) {
|
|
if (nameChange) {
|
|
this.clearArticleIndex();
|
|
if (this.getArticle(sku)) {
|
|
void this.loadArticle(sku).catch(() => {});
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
/** DELETE /api/article/{sku} */
|
|
delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
|
|
/** PUT /api/article/{sku}/prices */
|
|
setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }),
|
|
/** POST /api/article/{sku}/image — raw AVIF body */
|
|
addImage: (sku, avifBytes) =>
|
|
this.#json('POST', `/api/article/${enc(sku)}/image`, {
|
|
body: avifBytes,
|
|
contentType: 'image/avif',
|
|
raw: true,
|
|
}),
|
|
/** DELETE /api/article/{sku}/image/{fullHash} */
|
|
deleteImage: (sku, fullHash) =>
|
|
this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`),
|
|
/** PUT /api/article/{sku}/images/order */
|
|
orderImages: (sku, body) =>
|
|
this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }),
|
|
/** PUT /api/article/{sku}/eans */
|
|
setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }),
|
|
/** PUT /api/article/{sku}/brand */
|
|
setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }),
|
|
/** PUT /api/article/{sku}/weight */
|
|
setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }),
|
|
/** PUT /api/article/{sku}/categories */
|
|
setCategories: (sku, body) =>
|
|
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
|
|
/** GET /api/article/{sku}/supplier */
|
|
getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`),
|
|
/** POST /api/article/{sku}/supplier */
|
|
addSupplier: (sku, body) =>
|
|
this.#json('POST', `/api/article/${enc(sku)}/supplier`, { body }),
|
|
/** PATCH /api/article/{sku}/supplier/{id} */
|
|
updateSupplier: (sku, id, body) =>
|
|
this.#json('PATCH', `/api/article/${enc(sku)}/supplier/${enc(id)}`, { body }),
|
|
/** PUT /api/article/{sku}/supplier/order */
|
|
orderSuppliers: (sku, body) =>
|
|
this.#json('PUT', `/api/article/${enc(sku)}/supplier/order`, { body }),
|
|
/** DELETE /api/article/{sku}/supplier/{id} */
|
|
deleteSupplier: (sku, id) =>
|
|
this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(id)}`),
|
|
};
|
|
|
|
this.posterminal = {
|
|
/** GET /api/posterminal */
|
|
list: () => this.#json('GET', '/api/posterminal'),
|
|
/** PUT /api/posterminal */
|
|
upsert: (body) => this.#json('PUT', '/api/posterminal', { body }),
|
|
/** DELETE /api/posterminal/{id} */
|
|
delete: (id) => this.#empty('DELETE', `/api/posterminal/${enc(id)}`),
|
|
/** PUT /api/posterminal/{id}/category/{categoryId} */
|
|
setOn: (id, categoryId) =>
|
|
this.#json('PUT', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
|
|
/** DELETE /api/posterminal/{id}/category/{categoryId} */
|
|
setOff: (id, categoryId) =>
|
|
this.#json('DELETE', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
|
|
/** PUT /api/posterminal/{id}/all */
|
|
setOnAll: (id) => this.#json('PUT', `/api/posterminal/${enc(id)}/all`),
|
|
/** DELETE /api/posterminal/{id}/all */
|
|
setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`),
|
|
};
|
|
|
|
this.number = {
|
|
/** GET /api/number → { numbers: [{ id, type, prefix, padding, next }] } */
|
|
list: () => this.#json('GET', '/api/number'),
|
|
/** PATCH /api/number/{id} — body: { prefix?, padding?, next? } (only present keys patched) */
|
|
update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }),
|
|
};
|
|
|
|
this.setting = {
|
|
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
|
|
list: (query) => this.#json('GET', '/api/company/setting', { query }),
|
|
/** GET /api/company/setting/{key} → { setting: { key, value, frontend } } */
|
|
get: (key) => this.#json('GET', `/api/company/setting/${enc(key)}`),
|
|
/** PUT /api/company/setting/{key} body { value, frontend } → { setting: { key, value, frontend } } */
|
|
upsert: (key, body) =>
|
|
this.#json('PUT', `/api/company/setting/${enc(key)}`, { body }),
|
|
};
|
|
|
|
this.image = {
|
|
/** GET /api/image/{fullHash} → AVIF blob */
|
|
get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`),
|
|
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
|
url: (fullHash) => buildUrl(this.#baseUrl, `/api/image/${enc(fullHash)}`),
|
|
};
|
|
|
|
this.thumb = {
|
|
/** GET /api/thumb/{fullHash} → AVIF blob */
|
|
get: (fullHash) => this.#blob('GET', `/api/thumb/${enc(fullHash)}`),
|
|
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
|
url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`),
|
|
};
|
|
}
|
|
|
|
/** Absolute URL for a same-origin API path (e.g. SSE). */
|
|
urlFor(path) {
|
|
return buildUrl(this.#baseUrl, path);
|
|
}
|
|
|
|
async #json(method, path, { body, query, contentType, raw, headers: extraHeaders } = {}) {
|
|
const url = buildPath(path, query);
|
|
const headers = { ...extraHeaders };
|
|
let payload;
|
|
if (body !== undefined) {
|
|
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
payload = body;
|
|
} else if (raw) {
|
|
if (contentType) headers['Content-Type'] = contentType;
|
|
payload = body;
|
|
} else {
|
|
headers['Content-Type'] = contentType || 'application/json';
|
|
payload = JSON.stringify(body);
|
|
}
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers,
|
|
credentials: 'include',
|
|
body: payload,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let message = res.statusText;
|
|
let data;
|
|
try {
|
|
data = await res.json();
|
|
message = data.error || data.message || message;
|
|
} catch {
|
|
message = (await res.text().catch(() => message)) || message;
|
|
}
|
|
const err = new Error(message || `Request failed (${res.status})`);
|
|
err.status = res.status;
|
|
err.path = url;
|
|
err.method = method;
|
|
err.data = data;
|
|
throw err;
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
async #empty(method, path, { query } = {}) {
|
|
await this.#json(method, path, { query });
|
|
return null;
|
|
}
|
|
|
|
async #blob(method, path, { query } = {}) {
|
|
const url = buildPath(path, query);
|
|
const res = await fetch(url, {
|
|
method,
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let message = res.statusText;
|
|
let data;
|
|
try {
|
|
data = await res.json();
|
|
message = data.error || data.message || message;
|
|
} catch {
|
|
message = (await res.text().catch(() => message)) || message;
|
|
}
|
|
const err = new Error(message || `Request failed (${res.status})`);
|
|
err.status = res.status;
|
|
err.path = url;
|
|
err.method = method;
|
|
err.data = data;
|
|
throw err;
|
|
}
|
|
return res.blob();
|
|
}
|
|
}
|
|
|
|
export default ApiClient;
|