Release 2.16.1
This commit is contained in:
1 parent
3514d514a3
commit
8ce8228091
25 files changed
+1307
-613
No files matched your search
@@ -50,7 +50,6 @@ module.exports = {
|
||||
fetchTimetableEntriesByGroupId: data.fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload: data.buildTimetableGroupPayload,
|
||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||
fetchApiSourceById: data.fetchApiSourceById,
|
||||
fetchApiSourceResponse: data.fetchApiSourceResponse,
|
||||
buildApiSourcePayload: data.buildApiSourcePayload,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const { API_AUTH_FIELDS, API_REQUEST_SELECT, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||
const { fetchPagedRows, validateMaxLength, parseJsonSafe } = require('#src/data/utils');
|
||||
|
||||
function toFormFields(value) {
|
||||
const fields = Object.assign({}, value);
|
||||
Object.keys(value || {}).forEach(function (key) {
|
||||
fields[key.replace(/[A-Z]/g, function (letter) { return '_' + letter.toLowerCase(); })] = value[key];
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
|
||||
function validationError(message, statusCode) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode || 400;
|
||||
return error;
|
||||
}
|
||||
|
||||
function buildApiGroupPayload(body) {
|
||||
let requests = body.requests_json;
|
||||
if (typeof requests === 'string') {
|
||||
try { requests = JSON.parse(requests); } catch (_error) { throw validationError('Requests must contain valid JSON.'); }
|
||||
}
|
||||
if (!Array.isArray(requests) || requests.length < 1 || requests.length > 50) {
|
||||
throw validationError('Add between 1 and 50 API requests.');
|
||||
}
|
||||
const name = validateMaxLength(body.name, 255, 'API source name');
|
||||
if (!name) throw validationError('API source name is required.');
|
||||
const ids = new Set();
|
||||
const names = new Set();
|
||||
const parsedRequests = requests.map(function (request) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) throw validationError('Invalid API request.');
|
||||
const id = request.id === '' || request.id === null || request.id === undefined ? null : Number(request.id);
|
||||
if (id !== null && (!Number.isSafeInteger(id) || id < 1 || ids.has(id))) throw validationError('Invalid or repeated API request ID.');
|
||||
if (id !== null) ids.add(id);
|
||||
const requestName = validateMaxLength(request.name, 255, 'Request name');
|
||||
if (!requestName || names.has(requestName.toLowerCase())) throw validationError('Each request needs a unique name.');
|
||||
names.add(requestName.toLowerCase());
|
||||
const fields = toFormFields(request);
|
||||
API_AUTH_FIELDS.forEach(function (field) { fields[field] = body[field]; });
|
||||
fields.name = requestName;
|
||||
const payload = buildApiSourcePayload({ body: fields });
|
||||
return Object.assign(payload, { id: id, enabled: request.enabled === false || request.enabled === 0 || request.enabled === '0' ? 0 : 1 });
|
||||
});
|
||||
const auth = toFormFields(parsedRequests[0]);
|
||||
return { name: name, auth: auth, requests: parsedRequests };
|
||||
}
|
||||
|
||||
async function fetchApiGroupById(pool, id) {
|
||||
const [groups] = await pool.query('SELECT * FROM i_api_source_groups WHERE id = ?', [id]);
|
||||
if (!groups.length) return null;
|
||||
const [requests] = await pool.query(API_REQUEST_SELECT + ' WHERE request.auth_source_id = ? ORDER BY request.id', [id]);
|
||||
return Object.assign({}, groups[0], { requests: requests.map(function (request) { return Object.assign({}, request, { name: request.request_name }); }) });
|
||||
}
|
||||
|
||||
async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction) {
|
||||
const data = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT auth.*, (SELECT COUNT(*) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS request_count, (SELECT MAX(last_pulled_at) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS last_pulled_at FROM i_api_source_groups auth ORDER BY auth.modified_at DESC, auth.id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_api_source_groups auth',
|
||||
searchColumns: ['auth.name', 'auth.token_url'], searchTerm: search,
|
||||
sortColumns: { name: 'auth.name', requests: 'request_count', last_pulled: 'last_pulled_at' },
|
||||
sortKey: sort, sortDirection: direction, page: page, pageSize: pageSize
|
||||
});
|
||||
return Object.assign({ apiSources: data.rows }, data);
|
||||
}
|
||||
|
||||
async function getApiRequestUsage(pool) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const ids = new Set();
|
||||
function walk(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (value.source_id) ids.add(Number(value.source_id));
|
||||
Object.values(value).forEach(walk);
|
||||
}
|
||||
slides.forEach(function (slide) { walk(parseJsonSafe(slide.content_json)); });
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function saveApiGroup(pool, id, payload, actorId, canDelete) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
if (id) {
|
||||
const [groups] = await connection.query('SELECT id FROM i_api_source_groups WHERE id = ? FOR UPDATE', [id]);
|
||||
if (!groups.length) throw validationError('API source not found.', 404);
|
||||
}
|
||||
const [duplicates] = await connection.query('SELECT id FROM i_api_source_groups WHERE name = ? AND id <> ?', [payload.name, id || 0]);
|
||||
if (duplicates.length) throw validationError('An API source with that name already exists.');
|
||||
const [existing] = id ? await connection.query('SELECT id FROM i_api_sources WHERE auth_source_id = ? FOR UPDATE', [id]) : [[]];
|
||||
const existingIds = new Set(existing.map(function (request) { return Number(request.id); }));
|
||||
const retained = new Set(payload.requests.map(function (request) { return request.id; }).filter(Boolean));
|
||||
if (Array.from(retained).some(function (requestId) { return !existingIds.has(requestId); })) throw validationError('A request does not belong to this API source.');
|
||||
const removed = Array.from(existingIds).filter(function (requestId) { return !retained.has(requestId); });
|
||||
if (removed.length) {
|
||||
if (!canDelete) throw validationError('You do not have permission to remove API requests.', 403);
|
||||
const usage = await getApiRequestUsage(connection);
|
||||
if (removed.some(function (requestId) { return usage.has(requestId); })) throw validationError('A request being removed is still used by one or more slides.');
|
||||
}
|
||||
const values = [payload.name].concat(API_AUTH_FIELDS.map(function (field) { return payload.auth[field] === undefined ? null : payload.auth[field]; }));
|
||||
if (id) {
|
||||
await connection.query('UPDATE i_api_source_groups SET name = ?, ' + API_AUTH_FIELDS.map(function (field) { return field + ' = ?'; }).join(', ') + ', modified_by = ? WHERE id = ?', values.concat([actorId, id]));
|
||||
} else {
|
||||
const [result] = await connection.query('INSERT INTO i_api_source_groups (name, ' + API_AUTH_FIELDS.join(', ') + ', created_by, modified_by) VALUES (' + Array(values.length + 2).fill('?').join(', ') + ')', values.concat([actorId, actorId]));
|
||||
id = result.insertId;
|
||||
}
|
||||
for (const request of payload.requests) {
|
||||
const requestValues = [request.name, request.apiUrl, request.requestMethod, request.requestBodyJson || null, request.itemsPath || null, request.enabled, request.updateIntervalValue, request.updateIntervalUnit, actorId];
|
||||
if (request.id) {
|
||||
await connection.query('UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, items_path = ?, enabled = ?, update_interval_value = ?, update_interval_unit = ?, modified_by = ? WHERE id = ? AND auth_source_id = ?', requestValues.concat([request.id, id]));
|
||||
} else {
|
||||
const [result] = await connection.query('INSERT INTO i_api_sources (name, api_url, request_method, request_body_json, items_path, enabled, update_interval_value, update_interval_unit, modified_by, auth_source_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', requestValues.concat([id, actorId]));
|
||||
request.id = result.insertId;
|
||||
}
|
||||
}
|
||||
if (removed.length) await connection.query('DELETE FROM i_api_sources WHERE auth_source_id = ? AND id IN (?)', [id, removed]);
|
||||
await connection.commit();
|
||||
return { id: Number(id), removed: removed, requests: payload.requests };
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
if (error.code === 'ER_DUP_ENTRY') throw validationError('An API source with that name already exists.');
|
||||
throw error;
|
||||
} finally { connection.release(); }
|
||||
}
|
||||
|
||||
module.exports = { buildApiGroupPayload, fetchApiGroupById, fetchApiGroupsPage, getApiRequestUsage, saveApiGroup, toFormFields };
|
||||
+196
-37
@@ -2,7 +2,8 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { validateMaxLength } = require('#src/data/utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
@@ -14,6 +15,121 @@ const TOKEN_RESPONSE_PATH_MAX_LENGTH = 255;
|
||||
const TOKEN_HEADER_PREFIX_MAX_LENGTH = 64;
|
||||
const tokenCache = new Map();
|
||||
const tokenRequests = new Map();
|
||||
const diagnosticContext = Symbol('apiSourceDiagnostics');
|
||||
const API_AUTH_FIELDS = ['auth_method', 'auth_username', 'auth_password', 'auth_bearer_token', 'auth_header_name', 'auth_header_value', 'token_url', 'token_request_body_json', 'token_response_path', 'token_refresh_url', 'token_refresh_request_body_json', 'token_refresh_response_path', 'token_header_name', 'token_header_prefix'];
|
||||
const API_REQUEST_SELECT = 'SELECT request.*, request.name AS request_name, auth.name AS group_name, CONCAT(auth.name, \' / \', request.name) AS name, ' + API_AUTH_FIELDS.map(function (field) { return 'auth.' + field; }).join(', ') + ' FROM i_api_sources request JOIN i_api_source_groups auth ON auth.id = request.auth_source_id';
|
||||
|
||||
function createDiagnosticSanitizer(source) {
|
||||
const secrets = [];
|
||||
const sensitiveKey = /password|secret|token|authorization|cookie|api.?key|authHeaderValue|auth_header_value|authUsername|auth_username/i;
|
||||
const oauthMetadataKey = /^(scope|grant_type|token_type|tokenType)$/i;
|
||||
function collect(value, force) {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.keys(value).forEach(function (key) {
|
||||
if (oauthMetadataKey.test(key)) return;
|
||||
const configuration = /url|path|prefix|header.?name|auth.?method/i.test(key);
|
||||
collect(value[key], force || (!configuration && sensitiveKey.test(key)) || /body.?json/i.test(key));
|
||||
});
|
||||
} else if (typeof value === 'string' && value) {
|
||||
if (force) secrets.push(value);
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.username) secrets.push(url.username);
|
||||
if (url.password) secrets.push(url.password);
|
||||
url.searchParams.forEach(function (item) { if (item) secrets.push(item); });
|
||||
} catch (_error) {}
|
||||
try { collect(JSON.parse(value), true); } catch (_error) {}
|
||||
}
|
||||
}
|
||||
collect(source, false);
|
||||
function redactText(value) {
|
||||
let text = String(value || '');
|
||||
secrets.sort(function (left, right) { return right.length - left.length; }).forEach(function (secret) {
|
||||
text = text.split(secret).join('[REDACTED]');
|
||||
});
|
||||
return text.replace(/(Bearer\s+)[^\s"<>]+/gi, '$1[REDACTED]');
|
||||
}
|
||||
function sanitize(value) {
|
||||
if (Array.isArray(value)) return value.map(sanitize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(function ([key, item]) {
|
||||
const sensitive = sensitiveKey.test(key) && !oauthMetadataKey.test(key);
|
||||
return [redactText(key), sensitive && item !== null && item !== '' ? '[REDACTED]' : sanitize(item)];
|
||||
}));
|
||||
}
|
||||
return typeof value === 'string' ? redactText(value) : value;
|
||||
}
|
||||
return {
|
||||
add: function (value) { collect(value, false); },
|
||||
secret: function (value) { if (value) secrets.push(String(value)); },
|
||||
text: redactText,
|
||||
body: function (text) {
|
||||
try { return JSON.stringify(sanitize(JSON.parse(text)), null, 2).slice(0, 12000); }
|
||||
catch (_error) { return '[Non-JSON response omitted]'; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch (_error) { return '[Invalid URL]'; }
|
||||
}
|
||||
|
||||
async function loadUrlText(url, options, source, stage) {
|
||||
const context = source && source[diagnosticContext];
|
||||
const sanitizer = context ? context.sanitizer : createDiagnosticSanitizer(source || {});
|
||||
Object.entries(options.headers || {}).forEach(function ([key, value]) {
|
||||
if (!/^(accept|content-type|user-agent)$/i.test(key)) sanitizer.secret(value);
|
||||
});
|
||||
const step = { stage: stage, method: options.method, url: sanitizer.text(diagnosticUrl(url)) };
|
||||
if (context) context.steps.push(step);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await requestUrlText(url, options);
|
||||
step.status = response.statusCode;
|
||||
step.contentType = response.contentType;
|
||||
try {
|
||||
const parsed = JSON.parse(response.bodyText);
|
||||
sanitizer.add(parsed);
|
||||
if (stage === 'Authentication' || stage === 'Token refresh') {
|
||||
sanitizer.secret(resolveResponsePath(parsed, source.token_response_path || source.tokenResponsePath || 'access_token'));
|
||||
sanitizer.secret(resolveResponsePath(parsed, source.token_refresh_response_path || source.tokenRefreshResponsePath || 'refresh_token'));
|
||||
}
|
||||
} catch (_error) {}
|
||||
step.response = sanitizer.body(response.bodyText);
|
||||
step.ok = response.ok;
|
||||
if (!response.ok) {
|
||||
const hints = {
|
||||
400: ' Check the request body and grant type.',
|
||||
401: ' Check the credentials or token validity.',
|
||||
403: ' Check the token scopes and application permissions.',
|
||||
404: ' Check the endpoint URL and API version.',
|
||||
429: ' The provider is rate limiting requests; reduce the refresh frequency.'
|
||||
};
|
||||
const hint = hints[response.statusCode] || '';
|
||||
const error = new Error(stage + ' failed: HTTP ' + response.statusCode + ' at ' + step.url + '.' + hint + ' ' + step.response.slice(0, 1000));
|
||||
error.apiDiagnostic = true;
|
||||
step.error = error.message;
|
||||
response.diagnosticError = error;
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
step.ok = false;
|
||||
const code = error.cause && error.cause.code || error.code || error.name;
|
||||
const safeCode = /^[A-Z_0-9]+$/.test(String(code)) ? ' (' + code + ')' : '';
|
||||
step.error = error.apiDiagnostic ? error.message : stage + ' request failed (network, TLS, or timeout)' + safeCode + '.';
|
||||
if (error.apiDiagnostic) throw error;
|
||||
throw new Error(step.error);
|
||||
} finally {
|
||||
step.durationMs = Date.now() - started;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -60,46 +176,22 @@ function buildAuthHeaders(source) {
|
||||
|
||||
async function fetchApiSourcesData(pool) {
|
||||
const [apiSources] = await pool.query(
|
||||
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_refresh_url, token_refresh_request_body_json, token_refresh_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
|
||||
"SELECT request.id, request.auth_source_id, request.name AS request_name, auth.name AS group_name, CONCAT(auth.name, ' / ', request.name) AS name, request.items_path, request.enabled, request.update_interval_value, request.update_interval_unit, request.last_pulled_at, request.last_pull_error, request.last_response_status, request.last_response_content_type, request.last_response_json FROM i_api_sources request JOIN i_api_source_groups auth ON auth.id = request.auth_source_id ORDER BY auth.name, request.id"
|
||||
);
|
||||
|
||||
return { apiSources: apiSources };
|
||||
}
|
||||
|
||||
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_refresh_url, token_refresh_request_body_json, token_refresh_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
|
||||
searchColumns: ['name', 'api_url', 'last_pull_error'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'name',
|
||||
url: 'api_url',
|
||||
interval: ['update_interval_value', 'update_interval_unit'],
|
||||
last_pulled: 'last_pulled_at',
|
||||
last_response: 'last_response_status',
|
||||
created: 'created_at',
|
||||
modified: 'modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ apiSources: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchApiSourceById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_refresh_url, token_refresh_request_body_json, token_refresh_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
|
||||
API_REQUEST_SELECT + ' WHERE request.id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function loadUrlText(urlValue, requestOptions) {
|
||||
async function requestUrlText(urlValue, requestOptions) {
|
||||
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
|
||||
const method = String(requestOptions && requestOptions.method || 'GET').toUpperCase();
|
||||
const body = requestOptions && requestOptions.body !== undefined ? requestOptions.body : undefined;
|
||||
@@ -108,7 +200,7 @@ async function loadUrlText(urlValue, requestOptions) {
|
||||
'User-Agent': 'Pulse Signage API Reader'
|
||||
}, extraHeaders);
|
||||
if (typeof fetch === 'function') {
|
||||
const fetchOptions = { method: method, headers: headers };
|
||||
const fetchOptions = { method: method, headers: headers, signal: AbortSignal.timeout(15000) };
|
||||
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
fetchOptions.body = body;
|
||||
}
|
||||
@@ -149,6 +241,7 @@ async function loadUrlText(urlValue, requestOptions) {
|
||||
request.write(body);
|
||||
}
|
||||
request.end();
|
||||
request.setTimeout(15000, function () { request.destroy(new Error('Request timed out')); });
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -181,7 +274,7 @@ function resolveResponsePath(value, responsePath) {
|
||||
|
||||
function getTokenCacheKey(source) {
|
||||
return JSON.stringify([
|
||||
source && source.id || '',
|
||||
source && (source.auth_source_id || source.authSourceId || source.id) || '',
|
||||
source && (source.token_url || source.tokenUrl) || '',
|
||||
source && (source.token_request_body_json || source.tokenRequestBodyJson) || '',
|
||||
source && (source.token_response_path || source.tokenResponsePath) || 'access_token',
|
||||
@@ -252,7 +345,7 @@ function cacheTokenResponse(source, parsed, previousRefreshToken) {
|
||||
const tokenPath = source.token_response_path || source.tokenResponsePath || 'access_token';
|
||||
const token = resolveResponsePath(parsed, tokenPath);
|
||||
if (token === undefined || token === null || String(token).trim() === '') {
|
||||
throw new Error('Token response did not contain a token at the configured path.');
|
||||
throw new Error('Token response did not contain a token at the configured path: ' + createDiagnosticSanitizer(source).text(tokenPath) + '. Check the token response path.');
|
||||
}
|
||||
|
||||
const refreshPath = source.token_refresh_response_path || source.tokenRefreshResponsePath || 'refresh_token';
|
||||
@@ -293,7 +386,7 @@ async function refreshLoginToken(source, cached) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(refreshBody)
|
||||
});
|
||||
}, source, 'Token refresh');
|
||||
return parseTokenResponse(response, source, cached.refreshToken);
|
||||
}
|
||||
|
||||
@@ -318,10 +411,10 @@ async function fetchLoginTokenUncached(source) {
|
||||
method: 'POST',
|
||||
headers: tokenHeaders,
|
||||
body: tokenBody === undefined ? undefined : JSON.stringify(tokenBody)
|
||||
});
|
||||
}, source, 'Authentication');
|
||||
const record = await parseTokenResponse(response, source, cached && cached.refreshToken);
|
||||
if (!record) {
|
||||
throw new Error(`Unable to obtain API token (${response.statusCode}).`);
|
||||
throw response.diagnosticError || new Error(`Unable to obtain API token (${response.statusCode}).`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
@@ -363,7 +456,7 @@ async function fetchApiSourceResponse(apiSource) {
|
||||
method: requestMethod,
|
||||
headers: Object.assign({}, await buildRequestHeaders(source), requestBody === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
body: requestBody === undefined ? undefined : JSON.stringify(requestBody)
|
||||
});
|
||||
}, source, 'Data request');
|
||||
if (response.statusCode === 401 && normalizeAuthMethod(source.auth_method || source.authMethod) === 'token_login' && !tokenRetry) {
|
||||
clearCachedToken(source);
|
||||
tokenRetry = true;
|
||||
@@ -372,7 +465,7 @@ async function fetchApiSourceResponse(apiSource) {
|
||||
}
|
||||
} while (true);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load API response (${response.statusCode}).`);
|
||||
throw response.diagnosticError || new Error(`Unable to load API response (${response.statusCode}).`);
|
||||
}
|
||||
|
||||
const text = String(response.bodyText || '').trim();
|
||||
@@ -394,6 +487,69 @@ async function fetchApiSourceResponse(apiSource) {
|
||||
};
|
||||
}
|
||||
|
||||
async function runApiSourceDiagnostic(source, run) {
|
||||
const context = { steps: [], sanitizer: createDiagnosticSanitizer(source) };
|
||||
const id = 'diagnostic-' + randomUUID();
|
||||
const isolatedSource = Object.assign({}, source, { id: id, auth_source_id: id, authSourceId: null, [diagnosticContext]: context });
|
||||
const report = { ok: false, steps: context.steps };
|
||||
try {
|
||||
await run(isolatedSource, context, report);
|
||||
} catch (error) {
|
||||
report.error = context.sanitizer.text(error.message);
|
||||
const lastStep = context.steps[context.steps.length - 1];
|
||||
if (lastStep && lastStep.stage === 'Data request' && lastStep.ok) {
|
||||
context.steps.push({ stage: 'JSON / items extraction', ok: false, error: report.error });
|
||||
} else if (lastStep) {
|
||||
lastStep.ok = false;
|
||||
lastStep.error = report.error;
|
||||
}
|
||||
} finally {
|
||||
clearCachedToken(isolatedSource);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
function testApiSourceAuthentication(source) {
|
||||
return runApiSourceDiagnostic(source, async function (isolatedSource, context, report) {
|
||||
await buildRequestHeaders(isolatedSource);
|
||||
if (!context.steps.length) context.steps.push({ stage: 'Authentication', ok: true, message: normalizeAuthMethod(source.auth_method || source.authMethod) === 'none' ? 'No authentication required.' : 'Authentication headers configured. Test a request to verify provider acceptance.' });
|
||||
report.ok = true;
|
||||
});
|
||||
}
|
||||
|
||||
function testApiSourceConnection(source) {
|
||||
return runApiSourceDiagnostic(source, async function (isolatedSource, context, report) {
|
||||
if (normalizeAuthMethod(source.auth_method || source.authMethod) !== 'token_login') {
|
||||
context.steps.push({ stage: 'Authentication', ok: true, skipped: true, message: 'No token exchange required.' });
|
||||
}
|
||||
const result = await fetchApiSourceResponse(isolatedSource);
|
||||
const parsed = JSON.parse(result.responseJson);
|
||||
const paths = [];
|
||||
function walk(value, path, depth) {
|
||||
if (depth > 6 || paths.length >= 100 || !value || typeof value !== 'object') return;
|
||||
Object.keys(value).slice(0, Array.isArray(value) ? 1 : 100).forEach(function (key) {
|
||||
if (paths.length >= 100 || /password|secret|token|authorization|cookie|api.?key/i.test(key)) return;
|
||||
const nextPath = path ? path + '.' + key : key;
|
||||
paths.push(context.sanitizer.text(nextPath));
|
||||
walk(value[key], nextPath, depth + 1);
|
||||
});
|
||||
}
|
||||
walk(parsed, '', 0);
|
||||
report.paths = paths;
|
||||
report.preview = context.sanitizer.body(result.responseJson);
|
||||
const itemsPath = String(source.items_path || source.itemsPath || '').trim();
|
||||
const items = itemsPath ? resolveResponsePath(parsed, itemsPath) : parsed;
|
||||
const matched = items !== undefined && items !== null;
|
||||
context.steps.push({
|
||||
stage: 'JSON / items extraction', ok: matched,
|
||||
message: matched
|
||||
? 'Valid JSON. ' + (itemsPath ? 'Items path matched. ' : 'Using response root. ') + (Array.isArray(items) ? items.length + ' items.' : 'Selected value is ' + typeof items + '.')
|
||||
: 'Items path did not match the response: ' + context.sanitizer.text(itemsPath)
|
||||
});
|
||||
report.ok = matched;
|
||||
});
|
||||
}
|
||||
|
||||
function buildApiSourcePayload(req, existingApiSource) {
|
||||
const fallback = existingApiSource || {};
|
||||
const body = req && req.body ? req.body : {};
|
||||
@@ -543,9 +699,12 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
API_AUTH_FIELDS,
|
||||
API_REQUEST_SELECT,
|
||||
fetchApiSourcesData: fetchApiSourcesData,
|
||||
fetchApiSourcesPage: fetchApiSourcesPage,
|
||||
fetchApiSourceById: fetchApiSourceById,
|
||||
fetchApiSourceResponse: fetchApiSourceResponse,
|
||||
testApiSourceConnection: testApiSourceConnection,
|
||||
testApiSourceAuthentication,
|
||||
buildApiSourcePayload: buildApiSourcePayload
|
||||
};
|
||||
+1
-2
@@ -5,7 +5,7 @@ const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANN
|
||||
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('#src/data/announcement-icons');
|
||||
const { fetchPlaylistById } = require('#src/data/playlists');
|
||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('#src/data/timetables');
|
||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||
const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('#src/data/rss-feeds');
|
||||
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('#src/data/weather');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('#src/data/screens');
|
||||
@@ -48,7 +48,6 @@ module.exports = {
|
||||
fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourcesPage,
|
||||
fetchApiSourceById,
|
||||
fetchApiSourceResponse,
|
||||
buildApiSourcePayload,
|
||||
|
||||
+27
-1
@@ -259,9 +259,34 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_api_source_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
auth_username VARCHAR(255) NULL,
|
||||
auth_password MEDIUMTEXT NULL,
|
||||
auth_bearer_token MEDIUMTEXT NULL,
|
||||
auth_header_name VARCHAR(255) NULL,
|
||||
auth_header_value MEDIUMTEXT NULL,
|
||||
token_url VARCHAR(1024) NULL,
|
||||
token_request_body_json MEDIUMTEXT NULL,
|
||||
token_response_path VARCHAR(255) NULL DEFAULT 'access_token',
|
||||
token_refresh_url VARCHAR(1024) NULL,
|
||||
token_refresh_request_body_json MEDIUMTEXT NULL,
|
||||
token_refresh_response_path VARCHAR(255) NULL DEFAULT 'refresh_token',
|
||||
token_header_name VARCHAR(255) NULL DEFAULT 'Authorization',
|
||||
token_header_prefix VARCHAR(64) NULL DEFAULT 'Bearer',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_api_sources (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
auth_source_id INT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
api_url VARCHAR(1024) NOT NULL,
|
||||
request_method VARCHAR(10) NOT NULL DEFAULT 'GET',
|
||||
@@ -292,7 +317,8 @@ async function ensureSchema(pool, options) {
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_api_sources_auth_source FOREIGN KEY (auth_source_id) REFERENCES i_api_source_groups(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
|
||||
@@ -684,6 +684,39 @@ const VERSIONED_MIGRATIONS = [
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'c_canvas_sizes', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'height');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.16.1',
|
||||
label: 'v2.16.1 shared API authentication and request groups',
|
||||
run: async function (pool) {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS i_api_source_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
auth_username VARCHAR(255) NULL,
|
||||
auth_password MEDIUMTEXT NULL,
|
||||
auth_bearer_token MEDIUMTEXT NULL,
|
||||
auth_header_name VARCHAR(255) NULL,
|
||||
auth_header_value MEDIUMTEXT NULL,
|
||||
token_url VARCHAR(1024) NULL,
|
||||
token_request_body_json MEDIUMTEXT NULL,
|
||||
token_response_path VARCHAR(255) NULL DEFAULT 'access_token',
|
||||
token_refresh_url VARCHAR(1024) NULL,
|
||||
token_refresh_request_body_json MEDIUMTEXT NULL,
|
||||
token_refresh_response_path VARCHAR(255) NULL DEFAULT 'refresh_token',
|
||||
token_header_name VARCHAR(255) NULL DEFAULT 'Authorization',
|
||||
token_header_prefix VARCHAR(64) NULL DEFAULT 'Bearer',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
|
||||
await ensureColumn(pool, 'i_api_sources', 'auth_source_id', 'INT NULL', 'id');
|
||||
const fields = 'name, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_refresh_url, token_refresh_request_body_json, token_refresh_response_path, token_header_name, token_header_prefix, created_at, created_by, modified_at, modified_by';
|
||||
await pool.query('INSERT IGNORE INTO i_api_source_groups (id, ' + fields + ') SELECT id, ' + fields + ' FROM i_api_sources WHERE auth_source_id IS NULL');
|
||||
await pool.query('UPDATE i_api_sources SET auth_source_id = id WHERE auth_source_id IS NULL');
|
||||
await ensureForeignKey(pool, 'i_api_sources', 'fk_api_sources_auth_source', 'auth_source_id', 'i_api_source_groups', 'id', 'RESTRICT');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
|
||||
return refreshApiSource(pool, common, apiSource.id, null, options.notifyPlayerScreens);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,188 @@
|
||||
var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]'));
|
||||
var bearerTokenInput = form.querySelector('[data-api-source-bearer-token-input]');
|
||||
var bearerTokenToggle = form.querySelector('[data-api-source-bearer-token-toggle]');
|
||||
var requestMethodSelect = form.querySelector('[data-api-source-request-method]');
|
||||
var requestSection = form.querySelector('[data-api-source-request-section]');
|
||||
var requestBodyInput = form.querySelector('[data-api-source-request-body]');
|
||||
var testButton = form.querySelector('[data-api-auth-test-url]') || form.querySelector('[data-api-source-test-url]');
|
||||
var lastTestButton = testButton;
|
||||
var isTesting = false;
|
||||
var requestList = form.querySelector('[data-api-request-list]');
|
||||
var requestTemplate = form.querySelector('[data-api-request-template]');
|
||||
var addRequestButton = form.querySelector('[data-api-request-add]');
|
||||
var diagnostics = form.querySelector('[data-api-source-diagnostics]');
|
||||
var testStatus = form.querySelector('[data-api-source-test-status]');
|
||||
var testResults = form.querySelector('[data-api-source-test-results]');
|
||||
var copyReportButton = form.querySelector('[data-api-source-copy-report]');
|
||||
var dismissDiagnosticsButton = form.querySelector('[data-api-source-dismiss-diagnostics]');
|
||||
var diagnosticReport = null;
|
||||
|
||||
function addText(parent, tag, text, className) {
|
||||
var element = document.createElement(tag);
|
||||
element.textContent = text;
|
||||
if (className) element.className = className;
|
||||
parent.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function addPreview(parent, title, text) {
|
||||
var details = document.createElement('details');
|
||||
details.className = 'mb-3';
|
||||
addText(details, 'summary', title);
|
||||
var preview = addText(details, 'pre', text, 'small bg-body-tertiary border rounded p-2 mt-2 mb-0');
|
||||
preview.style.cssText = 'white-space:pre-wrap;overflow-wrap:anywhere;max-height:24rem;overflow:auto;';
|
||||
parent.appendChild(details);
|
||||
}
|
||||
|
||||
function showReport(report) {
|
||||
testResults.replaceChildren();
|
||||
testStatus.textContent = report.ok ? 'Connection test passed. No changes saved.' : 'Connection test failed. No changes saved.';
|
||||
testStatus.className = report.ok ? 'mb-2 text-success' : 'mb-2 text-danger';
|
||||
if (report.error) addText(testResults, 'p', report.error, 'text-break');
|
||||
(report.steps || []).forEach(function (step) {
|
||||
var block = document.createElement('div');
|
||||
block.className = 'border-top py-2';
|
||||
addText(block, 'h4', step.stage + ': ' + (step.skipped ? 'Not required' : step.ok ? 'Passed' : 'Failed'), 'h6');
|
||||
var metadata = [step.method, step.url, step.status ? 'HTTP ' + step.status : '', step.contentType, step.durationMs !== undefined ? step.durationMs + ' ms' : ''].filter(Boolean);
|
||||
if (metadata.length) addText(block, 'p', metadata.join(' | '), 'small text-break mb-1');
|
||||
if (step.error || step.message) addText(block, 'p', step.error || step.message, 'text-break mb-1');
|
||||
if (step.response) addPreview(block, 'Sanitised response', step.response);
|
||||
testResults.appendChild(block);
|
||||
});
|
||||
if (report.preview) addPreview(testResults, 'JSON preview (up to 12,000 characters)', report.preview);
|
||||
if (report.paths && report.paths.length) {
|
||||
addPreview(testResults, 'Available field paths (sample)', report.paths.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
function readRequest(card) {
|
||||
var request = { id: card.getAttribute('data-request-id') || null };
|
||||
Array.prototype.forEach.call(card.querySelectorAll('[data-request-field]'), function (input) {
|
||||
request[input.getAttribute('data-request-field')] = input.type === 'checkbox' ? input.checked : input.value;
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
function serializeRequests() {
|
||||
return Array.prototype.map.call(requestList.querySelectorAll('[data-api-request-card]'), readRequest);
|
||||
}
|
||||
|
||||
function updateRequestCard(card) {
|
||||
var fields = readRequest(card);
|
||||
card.querySelector('[data-api-request-title]').textContent = 'Request - ' + (fields.name || 'Unnamed request');
|
||||
card.querySelector('[data-api-request-body-section]').hidden = fields.request_method !== 'POST';
|
||||
}
|
||||
|
||||
function updateRequestFromEvent(event) {
|
||||
var card = event.target.closest('[data-api-request-card]');
|
||||
if (card) updateRequestCard(card);
|
||||
}
|
||||
|
||||
if (requestList) {
|
||||
form.addEventListener('formdata', function (event) {
|
||||
event.formData.set('requests_json', JSON.stringify(serializeRequests()));
|
||||
});
|
||||
requestList.addEventListener('input', updateRequestFromEvent);
|
||||
requestList.addEventListener('change', updateRequestFromEvent);
|
||||
requestList.addEventListener('click', function (event) {
|
||||
var remove = event.target.closest('[data-api-request-remove]');
|
||||
if (remove && !remove.disabled) {
|
||||
var card = remove.closest('[data-api-request-card]');
|
||||
if (requestList.querySelectorAll('[data-api-request-card]').length === 1) return;
|
||||
var requestName = card.querySelector('[data-request-field="name"]').value || 'Unnamed request';
|
||||
if (!window.confirm('Delete request "' + requestName + '"? This takes effect when you save the API source.')) return;
|
||||
if (lastTestButton && lastTestButton.closest && lastTestButton.closest('[data-api-request-card]') === card) {
|
||||
diagnostics.hidden = true;
|
||||
form.appendChild(diagnostics);
|
||||
lastTestButton = testButton;
|
||||
}
|
||||
card.remove();
|
||||
addRequestButton.focus();
|
||||
}
|
||||
var button = event.target.closest('[data-api-source-test-url]');
|
||||
if (button && button !== testButton) testConnection({ currentTarget: button });
|
||||
updateRemoveButtons();
|
||||
});
|
||||
addRequestButton.addEventListener('click', function () {
|
||||
if (requestList.querySelectorAll('[data-api-request-card]').length >= 50) return;
|
||||
requestList.appendChild(requestTemplate.content.cloneNode(true));
|
||||
var cards = requestList.querySelectorAll('[data-api-request-card]');
|
||||
var input = cards[cards.length - 1].querySelector('[data-request-field="name"]');
|
||||
var names = Array.prototype.map.call(cards, function (card) { return card.querySelector('[data-request-field="name"]').value.toLowerCase(); });
|
||||
var suffix = cards.length;
|
||||
while (names.indexOf('request ' + suffix) !== -1) suffix += 1;
|
||||
input.value = 'Request ' + suffix;
|
||||
updateRequestCard(cards[cards.length - 1]);
|
||||
updateRemoveButtons();
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
updateRemoveButtons();
|
||||
}
|
||||
|
||||
function updateRemoveButtons() {
|
||||
if (!requestList) return;
|
||||
var cards = requestList.querySelectorAll('[data-api-request-card]');
|
||||
addRequestButton.disabled = cards.length >= 50;
|
||||
Array.prototype.forEach.call(cards, function (card) {
|
||||
var button = card.querySelector('[data-api-request-remove]');
|
||||
if (!button.hasAttribute('data-request-protected')) {
|
||||
button.disabled = cards.length <= 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function testConnection(event) {
|
||||
if (isTesting) return;
|
||||
isTesting = true;
|
||||
var activeButton = event && event.currentTarget || testButton;
|
||||
lastTestButton = activeButton;
|
||||
var card = activeButton.closest ? activeButton.closest('[data-api-request-card]') : null;
|
||||
var anchor = card || (authCard && !authCard.hidden ? authCard : activeButton.closest && activeButton.closest('.card'));
|
||||
if (anchor) anchor.after(diagnostics);
|
||||
diagnostics.hidden = false;
|
||||
diagnostics.setAttribute('aria-busy', 'true');
|
||||
activeButton.disabled = true;
|
||||
copyReportButton.disabled = true;
|
||||
diagnosticReport = null;
|
||||
testResults.replaceChildren();
|
||||
testStatus.className = 'mb-2';
|
||||
testStatus.textContent = 'Testing connection...';
|
||||
try {
|
||||
var payload = Object.fromEntries(new FormData(form));
|
||||
if (card) Object.assign(payload, readRequest(card));
|
||||
var response = await fetch(activeButton.getAttribute('data-api-auth-test-url') || activeButton.getAttribute('data-api-source-test-url'), {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!(response.headers.get('content-type') || '').includes('application/json')) {
|
||||
throw new Error('Connection test unavailable (HTTP ' + response.status + '). Check your session and permissions.');
|
||||
}
|
||||
diagnosticReport = await response.json();
|
||||
showReport(diagnosticReport);
|
||||
copyReportButton.disabled = false;
|
||||
} catch (error) {
|
||||
testStatus.className = 'mb-2 text-danger';
|
||||
testStatus.textContent = error.message || 'Unable to run connection test.';
|
||||
} finally {
|
||||
activeButton.disabled = false;
|
||||
isTesting = false;
|
||||
diagnostics.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
if (testButton) testButton.addEventListener('click', testConnection);
|
||||
if (dismissDiagnosticsButton) dismissDiagnosticsButton.addEventListener('click', function () {
|
||||
diagnostics.hidden = true;
|
||||
if (lastTestButton && lastTestButton.isConnected !== false) lastTestButton.focus();
|
||||
});
|
||||
if (copyReportButton) copyReportButton.addEventListener('click', async function () {
|
||||
if (!diagnosticReport) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(diagnosticReport, null, 2));
|
||||
testStatus.textContent = 'Diagnostic report copied.';
|
||||
} catch (_error) {
|
||||
testStatus.textContent = 'Clipboard unavailable. Select the diagnostic results to copy them.';
|
||||
}
|
||||
});
|
||||
|
||||
function updateBearerTokenToggle() {
|
||||
if (!bearerTokenInput || !bearerTokenToggle) {
|
||||
@@ -61,30 +240,16 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updateRequestBodyVisibility() {
|
||||
if (!requestMethodSelect || !requestSection || !requestBodyInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isPost = String(requestMethodSelect.value || 'GET').toUpperCase() === 'POST';
|
||||
requestSection.hidden = !isPost;
|
||||
}
|
||||
|
||||
if (methodSelect) {
|
||||
methodSelect.addEventListener('change', function () {
|
||||
updatePanels(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (requestMethodSelect) {
|
||||
requestMethodSelect.addEventListener('change', updateRequestBodyVisibility);
|
||||
}
|
||||
|
||||
if (bearerTokenToggle && bearerTokenInput) {
|
||||
bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility);
|
||||
updateBearerTokenToggle();
|
||||
}
|
||||
|
||||
updatePanels(false);
|
||||
updateRequestBodyVisibility();
|
||||
}());
|
||||
@@ -1,4 +1,5 @@
|
||||
// API source duplication helpers.
|
||||
const { normalizeApiSourceForForm } = require('#src/web/routes/data-sources/api-sources/form-view-model');
|
||||
|
||||
function buildDuplicateApiSourceName(sourceName) {
|
||||
const baseName = 'Copy of ' + String(sourceName || '').trim();
|
||||
@@ -6,33 +7,21 @@ function buildDuplicateApiSourceName(sourceName) {
|
||||
}
|
||||
|
||||
function buildDuplicateApiSource(apiSource, duplicateName) {
|
||||
return Object.assign({}, apiSource, {
|
||||
const duplicate = Object.assign(normalizeApiSourceForForm(apiSource), {
|
||||
id: null,
|
||||
name: duplicateName,
|
||||
apiUrl: apiSource.api_url || '',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: '',
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
requestMethod: apiSource.request_method || 'GET',
|
||||
requestBodyJson: apiSource.request_body_json || '',
|
||||
tokenUrl: apiSource.token_url || '',
|
||||
tokenRequestBodyJson: apiSource.token_request_body_json || '',
|
||||
tokenResponsePath: apiSource.token_response_path || 'access_token',
|
||||
tokenRefreshUrl: apiSource.token_refresh_url || '',
|
||||
tokenRefreshRequestBodyJson: apiSource.token_refresh_request_body_json || '',
|
||||
tokenRefreshResponsePath: apiSource.token_refresh_response_path || 'refresh_token',
|
||||
tokenHeaderName: apiSource.token_header_name || 'Authorization',
|
||||
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
|
||||
itemsPath: apiSource.items_path || ''
|
||||
lastResponseJson: ''
|
||||
});
|
||||
if (Array.isArray(apiSource.requests)) {
|
||||
duplicate.requests = apiSource.requests.map(function (request) {
|
||||
return { name: request.name, api_url: request.api_url, request_method: request.request_method, request_body_json: request.request_body_json, items_path: request.items_path, enabled: request.enabled, update_interval_value: request.update_interval_value, update_interval_unit: request.update_interval_unit };
|
||||
});
|
||||
}
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -4,30 +4,5 @@ const { renderView } = require('#src/web/view');
|
||||
const { buildApiSourceFormViewModel } = require('#src/web/routes/data-sources/api-sources/form-view-model');
|
||||
|
||||
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
lastResponseJson: '',
|
||||
lastPullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(Object.assign({}, apiSource, {
|
||||
lastResponseJson: viewData.lastResponseJson,
|
||||
lastPullError: viewData.lastPullError,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
tokenUrl: apiSource.token_url || '',
|
||||
tokenRequestBodyJson: apiSource.token_request_body_json || '',
|
||||
tokenResponsePath: apiSource.token_response_path || 'access_token',
|
||||
tokenRefreshUrl: apiSource.token_refresh_url || '',
|
||||
tokenRefreshRequestBodyJson: apiSource.token_refresh_request_body_json || '',
|
||||
tokenRefreshResponsePath: apiSource.token_refresh_response_path || 'refresh_token',
|
||||
tokenHeaderName: apiSource.token_header_name || 'Authorization',
|
||||
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
requestMethod: apiSource.request_method || 'GET',
|
||||
requestBodyJson: apiSource.request_body_json || ''
|
||||
}), message, currentUser, viewData, true));
|
||||
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(apiSource, message, currentUser, data, true));
|
||||
};
|
||||
@@ -33,9 +33,28 @@ function buildDefaultApiSource() {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApiSourceForForm(apiSource) {
|
||||
const source = apiSource || {};
|
||||
const defaults = buildDefaultApiSource();
|
||||
const values = Object.assign({}, source);
|
||||
Object.keys(defaults).forEach(function (key) {
|
||||
const field = key.replace(/[A-Z]/g, function (letter) { return '_' + letter.toLowerCase(); });
|
||||
const value = source[key] !== undefined ? source[key] : source[field];
|
||||
values[key] = value === undefined || value === null ? defaults[key] : value;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function buildApiSourceFormViewModel(apiSource, message, currentUser, options, isEdit) {
|
||||
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
|
||||
const viewApiSource = normalizeApiSourceForForm(apiSource);
|
||||
const viewOptions = options || {};
|
||||
function requestModel(request) {
|
||||
return Object.assign({ name: 'Request 1', api_url: '', request_method: 'GET', request_body_json: '', items_path: '', enabled: 1, update_interval_value: viewApiSource.updateIntervalValue, update_interval_unit: viewApiSource.updateIntervalUnit }, request, {
|
||||
lastResponseJson: typeof request.last_response_json === 'object' && request.last_response_json ? JSON.stringify(request.last_response_json, null, 2) : request.last_response_json || '',
|
||||
lastPulledAtValue: request.last_pulled_at ? new Date(request.last_pulled_at).toISOString() : ''
|
||||
});
|
||||
}
|
||||
const requests = (viewApiSource.requests || [{ id: viewApiSource.id, name: 'Request 1', api_url: viewApiSource.apiUrl, request_method: viewApiSource.requestMethod, request_body_json: viewApiSource.requestBodyJson, items_path: viewApiSource.itemsPath }]).map(requestModel);
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit API source' : 'Add API source',
|
||||
@@ -43,6 +62,9 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSource: viewApiSource,
|
||||
apiRequests: requests,
|
||||
newRequest: requestModel({ name: 'New request' }),
|
||||
requestsJson: JSON.stringify(requests),
|
||||
inUse: Boolean(viewApiSource.inUse),
|
||||
lastResponseJson: viewOptions.lastResponseJson || viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewOptions.lastPullError || viewApiSource.lastPullError || '',
|
||||
@@ -51,12 +73,13 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
|
||||
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources',
|
||||
formAttrs: isEdit && viewApiSource.id ? 'data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/' + viewApiSource.id + '/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"' : 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/api-sources/new"',
|
||||
formAttrs: '',
|
||||
cancelUrl: '/data-sources/api-sources',
|
||||
deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildApiSourceFormViewModel: buildApiSourceFormViewModel
|
||||
buildApiSourceFormViewModel: buildApiSourceFormViewModel,
|
||||
normalizeApiSourceForForm: normalizeApiSourceForForm
|
||||
};
|
||||
@@ -1,385 +1,151 @@
|
||||
// API source route registration.
|
||||
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const renderApiSourcesPage = require('#src/web/routes/data-sources/api-sources/list');
|
||||
const renderApiSourceAddPage = require('#src/web/routes/data-sources/api-sources/add');
|
||||
const renderApiSourceEditPage = require('#src/web/routes/data-sources/api-sources/edit');
|
||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('#src/web/routes/data-sources/api-sources/duplicate');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const apiSourceIds = new Set();
|
||||
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
|
||||
const sourceId = Number(value.source_id);
|
||||
if (Number.isFinite(sourceId)) {
|
||||
apiSourceIds.add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return apiSourceIds;
|
||||
}
|
||||
|
||||
async function isApiSourceInUse(pool, common, apiSourceId) {
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
return usageIds.has(Number(apiSourceId));
|
||||
}
|
||||
|
||||
async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = await dataSourceTasks.getTaskStatusById(taskId);
|
||||
const expectedKey = 'api-source-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
const { buildApiSourcePayload, testApiSourceConnection, testApiSourceAuthentication } = require('#src/data/api-sources');
|
||||
const { buildApiGroupPayload, fetchApiGroupById, fetchApiGroupsPage, getApiRequestUsage, saveApiGroup } = require('#src/data/api-source-groups');
|
||||
const { hasPermission } = require('#src/rbac');
|
||||
|
||||
module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const dataSourceTasks = deps.dataSourceTasks;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { pool, common, backgroundTaskQueue, dataSourceTasks, getAuditUserId, redirectAfterSave, requirePermission } = deps;
|
||||
const formatDate = deps.formatDashboardDate || String;
|
||||
const base = '/data-sources/api-sources';
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
function syncSchedules(group, removed, actorId) {
|
||||
(removed || []).forEach(function (id) { dataSourceTasks.removeRecurringRefresh('api-source', id); });
|
||||
group.requests.forEach(function (request) {
|
||||
if (!request.enabled) return dataSourceTasks.removeRecurringRefresh('api-source', request.id);
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', request.id, group.name + ' / ' + request.name, request.update_interval_value || request.updateIntervalValue, request.update_interval_unit || request.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(request.id, actorId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
||||
async function enqueue(request, group, actorId) {
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + request.id, title: 'API request refresh', category: 'data-source', taskType: 'data-source-refresh',
|
||||
payload: { sourceType: 'api-source', sourceId: request.id, sourceName: group.name + ' / ' + request.name, actorId: actorId },
|
||||
metadata: { sourceType: 'api-source', sourceId: request.id, sourceName: group.name + ' / ' + request.name }
|
||||
});
|
||||
}
|
||||
|
||||
async function testConnection(req, res) {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
try {
|
||||
const body = Object.assign({}, req.body, { name: 'Connection test' });
|
||||
const authOnly = String(req.path || '').endsWith('/auth-test');
|
||||
if (authOnly) body.api_url = 'https://connection.test';
|
||||
const payload = buildApiSourcePayload({ body: body });
|
||||
res.json(await (authOnly ? testApiSourceAuthentication(payload) : testApiSourceConnection(payload)));
|
||||
} catch (error) {
|
||||
res.status(error.statusCode === 400 ? 400 : 500).json({ ok: false, error: error.statusCode === 400 ? error.message : 'Unable to test API connection.', steps: [] });
|
||||
}
|
||||
}
|
||||
app.post(base + '/test', requirePermission('api-sources.create'), testConnection);
|
||||
app.post(base + '/auth-test', requirePermission('api-sources.create'), testConnection);
|
||||
app.post(base + '/:id/test', requirePermission('api-sources.update'), testConnection);
|
||||
app.post(base + '/:id/auth-test', requirePermission('api-sources.update'), testConnection);
|
||||
|
||||
app.get(base, requirePermission('api-sources.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const search = common.getSearchQuery(req);
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
tokenUrl: apiSource.token_url || '',
|
||||
tokenResponsePath: apiSource.token_response_path || 'access_token',
|
||||
tokenRefreshUrl: apiSource.token_refresh_url || '',
|
||||
tokenRefreshRequestBodyJson: apiSource.token_refresh_request_body_json || '',
|
||||
tokenRefreshResponsePath: apiSource.token_refresh_response_path || 'refresh_token',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||
: apiSource.update_interval_unit === 'hours'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every hour' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} hours`)
|
||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||
inUse: usageIds.has(Number(apiSource.id))
|
||||
});
|
||||
});
|
||||
|
||||
res.send(renderApiSourcesPage({
|
||||
apiSources: apiSources,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
try {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
res.send(renderApiSourceAddPage({
|
||||
updateIntervalValue: settings['data-sources.api_default_interval_value'],
|
||||
updateIntervalUnit: settings['data-sources.api_default_interval_unit']
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const inUse = (slides || []).some(function (row) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stack = [content];
|
||||
while (stack.length) {
|
||||
const value = stack.pop();
|
||||
if (!value || typeof value !== 'object') {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(function (item) { stack.push(item); });
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'source_id') && Number(value.source_id) === Number(apiSource.id)) {
|
||||
return true;
|
||||
}
|
||||
Object.keys(value).forEach(function (key) {
|
||||
stack.push(value[key]);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
res.send(renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
tokenUrl: apiSource.token_url || '',
|
||||
tokenRequestBodyJson: apiSource.token_request_body_json || '',
|
||||
tokenResponsePath: apiSource.token_response_path || 'access_token',
|
||||
tokenRefreshUrl: apiSource.token_refresh_url || '',
|
||||
tokenRefreshRequestBodyJson: apiSource.token_refresh_request_body_json || '',
|
||||
tokenRefreshResponsePath: apiSource.token_refresh_response_path || 'refresh_token',
|
||||
tokenHeaderName: apiSource.token_header_name || 'Authorization',
|
||||
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
|
||||
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
|
||||
lastPullError: apiSource.last_pull_error || '',
|
||||
lastResponseStatus: apiSource.last_response_status,
|
||||
lastResponseContentType: apiSource.last_response_content_type,
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
inUse: inUse
|
||||
}), {
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
lastPullError: apiSource.last_pull_error || ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/duplicate', requirePermission('api-sources.read'), requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
let duplicateName = buildDuplicateApiSourceName(apiSource.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_api_sources', duplicateName)) {
|
||||
duplicateName = buildDuplicateApiSourceName(apiSource.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(renderApiSourceAddPage(buildDuplicateApiSource(apiSource, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
messageVariant: 'info',
|
||||
showSaveSecondaryActions: true
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildApiSourcePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
|
||||
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_api_sources (name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_refresh_url, token_refresh_request_body_json, token_refresh_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenRefreshUrl || null, payload.tokenRefreshRequestBodyJson || null, payload.tokenRefreshResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + result.insertId,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'API source created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
if (String(req.body && req.body.data_source_action || '').trim() === 'toggle') {
|
||||
const enabled = apiSource.enabled === 0 || apiSource.enabled === false ? 1 : 0;
|
||||
await connection.query('UPDATE i_api_sources SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), apiSource.id]);
|
||||
if (enabled) {
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, apiSource.name, apiSource.update_interval_value, apiSource.update_interval_unit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, getAuditUserId(req));
|
||||
});
|
||||
} else {
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
}
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(enabled ? 'API source enabled.' : 'API source disabled.'));
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, token_url = ?, token_request_body_json = ?, token_response_path = ?, token_refresh_url = ?, token_refresh_request_body_json = ?, token_refresh_response_path = ?, token_header_name = ?, token_header_prefix = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenRefreshUrl || null, payload.tokenRefreshRequestBodyJson || null, payload.tokenRefreshResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
if (apiSource.enabled === 0 || apiSource.enabled === false) {
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
} else {
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit', {
|
||||
closeUrl: '/data-sources/api-sources',
|
||||
newUrl: '/data-sources/api-sources/new',
|
||||
message: 'API source updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id/refresh', requirePermission('api-sources.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) return res.status(404).send('API source not found');
|
||||
if (apiSource.enabled === 0 || apiSource.enabled === false) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('API source is disabled.'));
|
||||
}
|
||||
await backgroundTaskQueue.enqueueTask({ key: 'api-source-refresh:' + apiSource.id, title: 'API source refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'api-source', sourceId: apiSource.id, sourceName: apiSource.name, actorId: getAuditUserId(req) }, metadata: { sourceType: 'api-source', sourceId: apiSource.id, sourceName: apiSource.name } });
|
||||
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('API source refresh queued.'));
|
||||
const data = await fetchApiGroupsPage(pool, Math.max(1, Number(req.query.page) || 1), 25, search, sort, direction);
|
||||
res.send(renderApiSourcesPage({ apiSources: data.apiSources.map(function (group) {
|
||||
return Object.assign({}, group, { lastPullLabel: group.last_pulled_at ? formatDate(group.last_pulled_at) : 'Never', lastPulledAtValue: group.last_pulled_at ? new Date(group.last_pulled_at).toISOString() : '' });
|
||||
}), pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search, sort, direction }, 25, 'API sources', 'API source pages') }, req.query.message || '', req.currentUser, formatDate));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
app.get(base + '/new', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
const settings = await fetchAppSettings(pool);
|
||||
res.send(renderApiSourceAddPage({ updateIntervalValue: settings['data-sources.api_default_interval_value'], updateIntervalUnit: settings['data-sources.api_default_interval_unit'] }, req.query.message || '', req.currentUser));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
if (await isApiSourceInUse(pool, common, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('This API source is still used by one or more slides.'));
|
||||
}
|
||||
app.get(base + '/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
try {
|
||||
const group = await fetchApiGroupById(pool, Number(req.params.id));
|
||||
if (!group) return res.status(404).send('API source not found');
|
||||
const usage = await getApiRequestUsage(pool);
|
||||
group.requests = group.requests.map(function (request) { return Object.assign(request, { inUse: usage.has(Number(request.id)) }); });
|
||||
group.inUse = group.requests.some(function (request) { return request.inUse; });
|
||||
res.send(renderApiSourceEditPage(group, {}, req.query.message || '', req.currentUser));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
app.get(base + '/:id/duplicate', requirePermission('api-sources.read'), requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
try {
|
||||
const group = await fetchApiGroupById(pool, Number(req.params.id));
|
||||
if (!group) return res.status(404).send('API source not found');
|
||||
const initialName = buildDuplicateApiSourceName(group.name);
|
||||
let name = initialName;
|
||||
let suffix = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_api_source_groups', name)) name = initialName + ' (' + suffix++ + ')';
|
||||
const duplicate = buildDuplicateApiSource(group, name);
|
||||
res.send(renderApiSourceAddPage(duplicate, 'Review the copied values and save when ready.', req.currentUser, { messageVariant: 'info' }));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
async function save(req, res, next) {
|
||||
try {
|
||||
const id = req.params.id ? Number(req.params.id) : null;
|
||||
if (req.body.data_source_action === 'toggle') {
|
||||
const group = await fetchApiGroupById(pool, id);
|
||||
if (!group) return res.status(404).send('API source not found');
|
||||
const enabled = group.requests.some(function (request) { return request.enabled; }) ? 0 : 1;
|
||||
await pool.query('UPDATE i_api_sources SET enabled = ?, modified_by = ? WHERE auth_source_id = ?', [enabled, getAuditUserId(req), id]);
|
||||
group.requests.forEach(function (request) { request.enabled = enabled; });
|
||||
syncSchedules(group, [], getAuditUserId(req));
|
||||
return res.redirect(base + '/' + id + '/edit?message=' + encodeURIComponent(enabled ? 'API requests enabled.' : 'API requests disabled.'));
|
||||
}
|
||||
const payload = buildApiGroupPayload(req.body);
|
||||
const result = await saveApiGroup(pool, id, payload, getAuditUserId(req), hasPermission(req.currentUser, 'api-sources.delete'));
|
||||
syncSchedules({ name: payload.name, requests: result.requests }, result.removed, getAuditUserId(req));
|
||||
if (!id) {
|
||||
for (const request of result.requests) { if (request.enabled) await enqueue(request, payload, getAuditUserId(req)); }
|
||||
}
|
||||
redirectAfterSave(req, res, base + '/' + result.id + '/edit', { closeUrl: base, newUrl: base + '/new', message: id ? 'API source updated.' : 'API source created. Initial requests queued.' });
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
app.post(base, requirePermission('api-sources.create'), save);
|
||||
app.post(base + '/:id', requirePermission('api-sources.update'), save);
|
||||
|
||||
async function refresh(req, res, next) {
|
||||
try {
|
||||
const group = await fetchApiGroupById(pool, Number(req.params.id));
|
||||
if (!group) return res.status(404).send('API source not found');
|
||||
const requests = req.params.requestId ? group.requests.filter(function (request) { return Number(request.id) === Number(req.params.requestId); }) : group.requests;
|
||||
if (!requests.length) return res.status(404).send('API request not found');
|
||||
for (const request of requests) { if (request.enabled) await enqueue(request, group, getAuditUserId(req)); }
|
||||
res.redirect(base + '/' + group.id + '/edit?message=' + encodeURIComponent('Enabled API request refreshes queued.'));
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
app.post(base + '/:id/refresh', requirePermission('api-sources.allow'), refresh);
|
||||
app.post(base + '/:id/requests/:requestId/refresh', requirePermission('api-sources.allow'), refresh);
|
||||
|
||||
app.post(base + '/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [groups] = await connection.query('SELECT id FROM i_api_source_groups WHERE id = ? FOR UPDATE', [Number(req.params.id)]);
|
||||
if (!groups.length) { await connection.rollback(); return res.status(404).send('API source not found'); }
|
||||
const [requests] = await connection.query('SELECT id FROM i_api_sources WHERE auth_source_id = ? FOR UPDATE', [Number(req.params.id)]);
|
||||
const usage = await getApiRequestUsage(connection);
|
||||
if (requests.some(function (request) { return usage.has(Number(request.id)); })) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
return res.redirect(base + '?message=' + encodeURIComponent('This API source has requests used by slides.'));
|
||||
}
|
||||
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
await connection.query('DELETE FROM i_api_sources WHERE auth_source_id = ?', [Number(req.params.id)]);
|
||||
await connection.query('DELETE FROM i_api_source_groups WHERE id = ?', [Number(req.params.id)]);
|
||||
await connection.commit();
|
||||
requests.forEach(function (request) { dataSourceTasks.removeRecurringRefresh('api-source', request.id); });
|
||||
res.redirect(base + '?message=' + encodeURIComponent('API source deleted.'));
|
||||
} catch (error) { await connection.rollback(); next(error); }
|
||||
finally { connection.release(); }
|
||||
});
|
||||
};
|
||||
@@ -1,50 +1,67 @@
|
||||
{{#*inline "api-request-card"}}
|
||||
<div class="card card-outline card-secondary mt-3" data-api-request-card data-request-id="{{id}}">
|
||||
<div class="card-header bg-body-tertiary d-flex align-items-center">
|
||||
<h3 class="card-title mb-0 me-2 text-break" data-api-request-title>Request - {{name}}</h3>
|
||||
<div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-api-request-remove {{#if inUse}}data-request-protected disabled title="This request is used by slides"{{else if id}}{{#unless (hasPermission currentUser 'api-sources.delete')}}data-request-protected disabled{{/unless}} title="Delete request"{{else}}title="Delete request"{{/if}} aria-label="Delete request"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-9"><label class="form-label w-100">Request name<input class="form-control" data-request-field="name" value="{{name}}" maxlength="255" required /></label></div>
|
||||
<div class="col-12 col-lg-3 d-flex align-items-end"><label class="form-check form-switch d-flex align-items-center gap-2 ps-0 py-2 mb-2"><input type="checkbox" class="form-check-input float-none ms-0 mt-0 flex-shrink-0" data-request-field="enabled" {{#if enabled}}checked{{/if}} /><span class="form-check-label">Enabled</span></label></div>
|
||||
<div class="col-12 col-lg-9"><label class="form-label w-100">API URL<input type="url" class="form-control" data-request-field="api_url" value="{{api_url}}" maxlength="1024" required /></label></div>
|
||||
<div class="col-12 col-lg-3"><label class="form-label w-100">Request method<select class="form-select" data-request-field="request_method"><option value="GET" {{#if (eq request_method 'GET')}}selected{{/if}}>GET</option><option value="POST" {{#if (eq request_method 'POST')}}selected{{/if}}>POST</option></select></label></div>
|
||||
<div class="col-12" data-api-request-body-section {{#unless (eq request_method 'POST')}}hidden{{/unless}}><label class="form-label w-100">JSON request body<textarea class="form-control font-monospace" rows="4" data-request-field="request_body_json" spellcheck="false">{{request_body_json}}</textarea></label></div>
|
||||
<div class="col-12 col-lg-6"><label class="form-label w-100">Items path<input class="form-control" data-request-field="items_path" value="{{items_path}}" maxlength="255" placeholder="data, items, or results.data" /></label></div>
|
||||
<div class="col-6 col-lg-3"><label class="form-label w-100">Update interval<input type="number" min="1" class="form-control" data-request-field="update_interval_value" value="{{update_interval_value}}" required /></label></div>
|
||||
<div class="col-6 col-lg-3"><label class="form-label w-100">Unit<select class="form-select" data-request-field="update_interval_unit"><option value="seconds" {{#if (eq update_interval_unit 'seconds')}}selected{{/if}}>Seconds</option><option value="minutes" {{#if (eq update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></label></div>
|
||||
</div>
|
||||
{{#if id}}
|
||||
<section class="mt-3 border-top pt-3" data-api-request-response aria-label="Latest response">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<h4 class="h6 mb-0">Latest response{{#if last_response_status}} <span class="badge text-bg-secondary ms-1">HTTP {{last_response_status}}</span>{{/if}}</h4>
|
||||
{{#if lastResponseJson}}
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Response JSON actions">
|
||||
<button type="button" class="btn btn-outline-secondary" data-json-toggle-collapse-all><i class="bi bi-arrows-collapse me-1" aria-hidden="true"></i>Collapse all</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-json-toggle-expand-all><i class="bi bi-arrows-expand me-1" aria-hidden="true"></i>Expand all</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if last_pull_error}}<div class="alert alert-warning text-break">{{last_pull_error}}</div>{{/if}}
|
||||
<dl class="row mb-3"><dt class="col-12 col-md-3">Last pulled</dt><dd class="col-12 col-md-9">{{#if lastPulledAtValue}}<time data-local-datetime datetime="{{lastPulledAtValue}}">{{lastPulledAtValue}}</time>{{else}}Never{{/if}}</dd><dt class="col-12 col-md-3">Content type</dt><dd class="col-12 col-md-9 text-break">{{last_response_content_type}}</dd></dl>
|
||||
{{#if lastResponseJson}}<div data-json-toggle-panel><script type="application/json" data-json-toggle-source>{{json lastResponseJson}}</script><div class="small bg-body-tertiary border rounded p-3 font-monospace" style="white-space:pre-wrap;overflow-wrap:anywhere" data-json-toggle-output>{{lastResponseJson}}</div></div>{{else}}<p class="text-body-secondary mb-0">No JSON response is stored yet.</p>{{/if}}
|
||||
</section>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-outline-primary" data-api-source-test-url="{{#if groupId}}/data-sources/api-sources/{{groupId}}/test{{else}}/data-sources/api-sources/test{{/if}}"><i class="bi bi-plug me-1" aria-hidden="true"></i>Test Connection</button>
|
||||
{{#if id}}{{#if (hasPermission currentUser 'api-sources.allow')}}<button type="button" class="btn btn-outline-secondary" data-manual-refresh-url="/data-sources/api-sources/{{groupId}}/requests/{{id}}/refresh" {{#unless enabled}}disabled{{/unless}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/inline}}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{#if isEdit}}Edit API source - {{apiSource.name}}{{else}}Add API source{{/if}}</h2>
|
||||
<p>{{#if isEdit}}Adjust the endpoint or refresh cadence and review the latest JSON response.{{else}}Adjust the endpoint or refresh cadence for this API source.{{/if}}</p>
|
||||
<p>{{#if isEdit}}Manage shared authentication, request URLs, and latest responses.{{else}}Configure shared authentication and the requests for this API source.{{/if}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if isEdit}}<form id="api-source-toggle-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
<div class="row g-3 align-items-start api-source-layout">
|
||||
<div class="col-12">
|
||||
<form id="api-source-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<input type="hidden" name="requests_json" value="{{requestsJson}}" />
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Connection</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 g-md-3">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="col-12 col-lg-8">
|
||||
<label for="api-source-name" class="form-label">Name</label>
|
||||
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-5">
|
||||
<label for="api-source-url" class="form-label">API URL</label>
|
||||
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-2">
|
||||
<label for="api-source-request-method" class="form-label">Request method</label>
|
||||
<select id="api-source-request-method" name="request_method" class="form-select" data-api-source-request-method>
|
||||
<option value="GET" {{#if (eq apiSource.requestMethod 'GET')}}selected{{/if}}>GET</option>
|
||||
<option value="POST" {{#if (eq apiSource.requestMethod 'POST')}}selected{{/if}}>POST</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 mb-2" data-api-source-request-section>
|
||||
<h4 class="api-source-section-heading">Request</h4>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12">
|
||||
<div class="form-text mb-2">POST requests use JSON with the existing authentication settings.</div>
|
||||
<textarea id="api-source-request-body" name="request_body_json" class="form-control font-monospace" rows="5" data-api-source-request-body placeholder="{ "ids": [1, 2, 3] }" spellcheck="false">{{apiSource.requestBodyJson}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 mb-2">
|
||||
<h4 class="api-source-section-heading">Access and parsing</h4>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="col-12 col-lg-4">
|
||||
<label for="api-source-auth-method" class="form-label">Auth method</label>
|
||||
<select id="api-source-auth-method" name="auth_method" class="form-select" data-api-source-auth-method>
|
||||
<option value="none" {{#if (eq apiSource.authMethod 'none')}}selected{{/if}}>None</option>
|
||||
@@ -54,41 +71,19 @@
|
||||
<option value="token_login" {{#if (eq apiSource.authMethod 'token_login')}}selected{{/if}}>Login then token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<label for="api-source-items-path" class="form-label">Items path</label>
|
||||
<input id="api-source-items-path" name="items_path" class="form-control" value="{{apiSource.itemsPath}}" maxlength="255" data-limit-text-length placeholder="items, results.data, or another array path" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8 offset-md-4">
|
||||
<div class="form-text">Optional. Use dot notation to change the default base for placeholders.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="api-source-section-heading">Refresh</h4>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-interval" class="form-label">Update interval</label>
|
||||
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-interval-unit" class="form-label">Unit</label>
|
||||
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
<option value="hours" {{#if (eq apiSource.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center justify-content-between row-gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 me-2">
|
||||
<button type="button" class="btn btn-outline-primary" data-api-auth-test-url="{{#if isEdit}}/data-sources/api-sources/{{apiSource.id}}/auth-test{{else}}/data-sources/api-sources/auth-test{{/if}}"><i class="bi bi-shield-check me-1" aria-hidden="true"></i>Test Authentication</button>
|
||||
<button type="button" class="btn btn-primary" data-api-request-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add request</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-end flex-wrap ms-auto">
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-outline card-info admin-form-card mt-3 overflow-hidden api-source-auth-card{{#if isEdit}} collapsed-card{{/if}}" data-api-source-auth-card hidden>
|
||||
<div class="card card-outline card-info admin-form-card mt-3 overflow-hidden api-source-auth-card collapsed-card" data-api-source-auth-card hidden>
|
||||
<div class="card-header bg-body-tertiary d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Authentication</h3>
|
||||
<div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">
|
||||
@@ -99,9 +94,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4" data-api-source-auth-details-section>
|
||||
<h4 class="api-source-section-heading">Authentication details</h4>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="basic" hidden>
|
||||
<div data-api-source-auth-details-section>
|
||||
<div class="row g-3" data-api-source-auth-panel="basic" hidden>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="api-source-auth-username" class="form-label">Username</label>
|
||||
<input id="api-source-auth-username" name="auth_username" class="form-control" value="{{apiSource.authUsername}}" maxlength="255" data-limit-text-length autocomplete="username" />
|
||||
@@ -111,7 +105,7 @@
|
||||
<input id="api-source-auth-password" name="auth_password" type="password" class="form-control" value="{{apiSource.authPassword}}" maxlength="255" data-limit-text-length autocomplete="current-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="bearer" hidden>
|
||||
<div class="row g-3" data-api-source-auth-panel="bearer" hidden>
|
||||
<div class="col-12">
|
||||
<label for="api-source-auth-bearer-token" class="form-label">Bearer token</label>
|
||||
<div class="input-group">
|
||||
@@ -122,7 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="api_key_header" hidden>
|
||||
<div class="row g-3" data-api-source-auth-panel="api_key_header" hidden>
|
||||
<div class="col-12 col-md-5">
|
||||
<label for="api-source-auth-header-name" class="form-label">Header name</label>
|
||||
<input id="api-source-auth-header-name" name="auth_header_name" class="form-control" value="{{apiSource.authHeaderName}}" maxlength="255" data-limit-text-length placeholder="X-API-Key" />
|
||||
@@ -132,7 +126,7 @@
|
||||
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="token_login" hidden>
|
||||
<div class="row g-3" data-api-source-auth-panel="token_login" hidden>
|
||||
<div class="col-12 col-md-8">
|
||||
<label for="api-source-token-url" class="form-label">Login / token URL</label>
|
||||
<input id="api-source-token-url" name="token_url" type="url" class="form-control" value="{{apiSource.tokenUrl}}" maxlength="1024" placeholder="https://example.com/login" />
|
||||
@@ -172,56 +166,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-api-request-list>
|
||||
{{#each apiRequests}}{{> api-request-card groupId=../apiSource.id currentUser=../currentUser}}{{/each}}
|
||||
</div>
|
||||
<template data-api-request-template>{{#with newRequest}}{{> api-request-card groupId=../apiSource.id currentUser=../currentUser}}{{/with}}</template>
|
||||
<section class="card card-outline card-info admin-form-card mt-3" data-api-source-diagnostics hidden aria-labelledby="api-source-diagnostics-title" aria-busy="false">
|
||||
<div class="card-header bg-body-tertiary d-flex align-items-center row-gap-2 flex-wrap">
|
||||
<h3 id="api-source-diagnostics-title" class="card-title mb-0 me-2">Connection test</h3>
|
||||
<div class="card-tools d-flex align-items-center gap-2 ms-auto me-0 flex-shrink-0">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-api-source-copy-report disabled><i class="bi bi-clipboard me-1" aria-hidden="true"></i>Copy diagnostic report</button>
|
||||
<button type="button" class="btn btn-tool p-0 m-0" data-api-source-dismiss-diagnostics aria-label="Dismiss connection test" title="Dismiss connection test"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p data-api-source-test-status role="status" class="mb-2"></p>
|
||||
<div data-api-source-test-results></div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card card-outline card-secondary api-source-response-card" id="api-source-response-panel">
|
||||
<div class="card-header bg-body-tertiary d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Latest response</h3>
|
||||
{{#if lastResponseJson}}
|
||||
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions">
|
||||
<button type="button" class="btn btn-outline-secondary" data-json-toggle-collapse-all>
|
||||
<i class="bi bi-arrows-collapse me-1"></i>
|
||||
Collapse all
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-json-toggle-expand-all>
|
||||
<i class="bi bi-arrows-expand me-1"></i>
|
||||
Expand all
|
||||
</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if lastPullError}}
|
||||
<div class="alert alert-warning">{{lastPullError}}</div>
|
||||
{{/if}}
|
||||
<dl class="row">
|
||||
<dt class="col-12 col-md-3">Last pulled</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if apiSource.lastPulledAtValue}}
|
||||
<time data-local-datetime datetime="{{apiSource.lastPulledAtValue}}">{{#if apiSource.lastPulledAtLabel}}{{apiSource.lastPulledAtLabel}}{{else}}{{apiSource.lastPulledAtValue}}{{/if}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Status</dt>
|
||||
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseStatus}}{{apiSource.lastResponseStatus}}{{else}}-{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Content type</dt>
|
||||
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseContentType}}{{apiSource.lastResponseContentType}}{{else}}-{{/if}}</dd>
|
||||
</dl>
|
||||
{{#if lastResponseJson}}
|
||||
<div data-json-toggle-panel>
|
||||
<script type="application/json" data-json-toggle-source>{{json lastResponseJson}}</script>
|
||||
<div class="mb-0 small bg-body-tertiary border rounded p-3 font-monospace" style="white-space: pre-wrap; word-break: break-word;" data-json-toggle-output>{{lastResponseJson}}</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-secondary mb-0">No JSON response is stored yet.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/data-sources/api-source-form.js?v={{appVersion}}"></script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>API sources</h2>
|
||||
<p>Store API endpoints, refresh cadence, and the latest JSON response pulled from each endpoint.</p>
|
||||
<p>Manage API sources, shared authentication, and request refresh schedules.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="url">API URL</th>
|
||||
<th data-table-sort-key="interval">Refresh interval</th>
|
||||
<th>Authentication</th>
|
||||
<th data-table-sort-key="requests">Requests</th>
|
||||
<th data-table-sort-key="last_pulled">Last pulled</th>
|
||||
<th data-table-sort-key="last_response">Last response</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -36,8 +34,8 @@
|
||||
{{#each apiSources}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="API URL" class="text-break">{{api_url}}</td>
|
||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||
<td data-label="Authentication">{{#if (eq auth_method 'none')}}None{{else if (eq auth_method 'token_login')}}Login then token{{else if (eq auth_method 'bearer')}}Bearer token{{else if (eq auth_method 'basic')}}Basic{{else}}API key header{{/if}}</td>
|
||||
<td data-label="Requests">{{request_count}}</td>
|
||||
<td data-label="Last pulled">
|
||||
{{#if lastPulledAtValue}}
|
||||
<time data-local-datetime datetime="{{lastPulledAtValue}}">{{lastPullLabel}}</time>
|
||||
@@ -45,17 +43,6 @@
|
||||
{{lastPullLabel}}
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Last response">
|
||||
{{#if last_pull_error}}
|
||||
<span class="text-danger">Failed</span>
|
||||
{{else if last_response_status}}
|
||||
<span>{{last_response_status}}</span>
|
||||
{{#if last_response_content_type}}<span class="text-muted small ms-2">{{last_response_content_type}}</span>{{/if}}
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'api-sources.create' 'api-sources.update' 'api-sources.delete')}}
|
||||
<div class="actions">
|
||||
@@ -82,7 +69,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="7" class="empty">No API sources yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No API sources yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in new issue
Block a user