263 lines
10 KiB
JavaScript
263 lines
10 KiB
JavaScript
// API source data access, pagination, and remote fetch helpers.
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
|
|
|
const NAME_MAX_LENGTH = 255;
|
|
const URL_MAX_LENGTH = 1024;
|
|
const AUTH_MAX_LENGTH = 255;
|
|
const ITEMS_PATH_MAX_LENGTH = 255;
|
|
|
|
function normalizeUpdateIntervalUnit(value) {
|
|
const unit = String(value || '').trim().toLowerCase();
|
|
return unit === 'seconds' ? 'seconds' : 'minutes';
|
|
}
|
|
|
|
function normalizeAuthMethod(value) {
|
|
const method = String(value || '').trim().toLowerCase();
|
|
return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none';
|
|
}
|
|
|
|
function getItemsPath(source) {
|
|
return String(source && (source.items_path || source.itemsPath) || '').trim();
|
|
}
|
|
|
|
function buildAuthHeaders(source) {
|
|
const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod));
|
|
const headers = {};
|
|
|
|
if (method === 'basic') {
|
|
const username = String(source && (source.auth_username || source.authUsername) || '').trim();
|
|
const password = String(source && (source.auth_password || source.authPassword) || '');
|
|
if (username || password) {
|
|
headers.Authorization = 'Basic ' + Buffer.from(username + ':' + password, 'utf8').toString('base64');
|
|
}
|
|
} else if (method === 'bearer') {
|
|
const token = String(source && (source.auth_bearer_token || source.authBearerToken) || '').trim();
|
|
if (token) {
|
|
headers.Authorization = 'Bearer ' + token;
|
|
}
|
|
} else if (method === 'api_key_header') {
|
|
const headerName = String(source && (source.auth_header_name || source.authHeaderName) || 'X-API-Key').trim() || 'X-API-Key';
|
|
const headerValue = String(source && (source.auth_header_value || source.authHeaderValue) || '').trim();
|
|
if (headerValue) {
|
|
headers[headerName] = headerValue;
|
|
}
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
async function fetchApiSourcesData(pool) {
|
|
const [apiSources] = await pool.query(
|
|
'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, 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_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
|
|
);
|
|
|
|
return { apiSources: apiSources };
|
|
}
|
|
|
|
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
|
const paged = await fetchPagedRows(pool, {
|
|
selectSql: 'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, 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_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, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, 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_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
|
|
[id]
|
|
);
|
|
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function loadUrlText(urlValue, requestOptions) {
|
|
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
|
|
if (typeof fetch === 'function') {
|
|
const response = await fetch(urlValue, {
|
|
headers: Object.assign({
|
|
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
|
'User-Agent': 'Pulse Signage API Reader'
|
|
}, extraHeaders)
|
|
});
|
|
|
|
return {
|
|
statusCode: response.status,
|
|
contentType: response.headers.get('content-type') || '',
|
|
bodyText: await response.text(),
|
|
ok: response.ok
|
|
};
|
|
}
|
|
|
|
return await new Promise(function (resolve, reject) {
|
|
const url = new URL(urlValue);
|
|
const transport = url.protocol === 'https:' ? https : http;
|
|
const requestHeaders = Object.assign({
|
|
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
|
'User-Agent': 'Pulse Signage API Reader'
|
|
}, extraHeaders);
|
|
const request = transport.get(url, Object.assign({}, requestOptions || {}, {
|
|
headers: requestHeaders
|
|
}), function (response) {
|
|
response.setEncoding('utf8');
|
|
let body = '';
|
|
response.on('data', function (chunk) {
|
|
body += chunk;
|
|
});
|
|
response.on('end', function () {
|
|
resolve({
|
|
statusCode: response.statusCode || 0,
|
|
contentType: String(response.headers['content-type'] || ''),
|
|
bodyText: body,
|
|
ok: !response.statusCode || response.statusCode < 400
|
|
});
|
|
});
|
|
response.on('error', reject);
|
|
});
|
|
|
|
request.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function fetchApiSourceResponse(apiSource) {
|
|
const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource };
|
|
const response = await loadUrlText(source.api_url, {
|
|
headers: buildAuthHeaders(source)
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Unable to load API response (${response.statusCode}).`);
|
|
}
|
|
|
|
const text = String(response.bodyText || '').trim();
|
|
if (!text) {
|
|
throw new Error('API response did not return JSON.');
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch (_error) {
|
|
throw new Error('API response was not valid JSON.');
|
|
}
|
|
|
|
return {
|
|
responseJson: JSON.stringify(parsed, null, 2),
|
|
responseStatus: response.statusCode,
|
|
responseContentType: response.contentType
|
|
};
|
|
}
|
|
|
|
function buildApiSourcePayload(req, existingApiSource) {
|
|
const fallback = existingApiSource || {};
|
|
const body = req && req.body ? req.body : {};
|
|
function readBodyValue(fieldName, fallbackValue) {
|
|
if (Object.prototype.hasOwnProperty.call(body, fieldName)) {
|
|
return body[fieldName];
|
|
}
|
|
return fallbackValue;
|
|
}
|
|
|
|
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name');
|
|
const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL');
|
|
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
|
|
const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username');
|
|
const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password');
|
|
const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token');
|
|
const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key';
|
|
const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value');
|
|
const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path');
|
|
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
|
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
|
|
|
if (!name) {
|
|
const error = new Error('API source name is required.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (!apiUrl) {
|
|
const error = new Error('API source URL is required.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
let parsedUrl;
|
|
try {
|
|
parsedUrl = new URL(apiUrl);
|
|
} catch (_error) {
|
|
const error = new Error('Enter a valid API URL.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
const error = new Error('API URL must start with http or https.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (!Number.isFinite(updateIntervalValue)) {
|
|
const error = new Error('Update interval must be a number.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (authMethod === 'basic' && !authUsername && !authPassword) {
|
|
const error = new Error('Basic auth requires a username or password.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (authMethod === 'bearer' && !authBearerToken) {
|
|
const error = new Error('Bearer auth requires a token.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (authMethod === 'api_key_header' && !authHeaderValue) {
|
|
const error = new Error('API key auth requires a header value.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
name: name,
|
|
apiUrl: parsedUrl.toString(),
|
|
authMethod: authMethod,
|
|
authUsername: authUsername,
|
|
authPassword: authPassword,
|
|
authBearerToken: authBearerToken,
|
|
authHeaderName: authHeaderName,
|
|
authHeaderValue: authHeaderValue,
|
|
itemsPath: itemsPath,
|
|
updateIntervalValue: Math.floor(updateIntervalValue),
|
|
updateIntervalUnit: updateIntervalUnit
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
fetchApiSourcesData: fetchApiSourcesData,
|
|
fetchApiSourcesPage: fetchApiSourcesPage,
|
|
fetchApiSourceById: fetchApiSourceById,
|
|
fetchApiSourceResponse: fetchApiSourceResponse,
|
|
buildApiSourcePayload: buildApiSourcePayload
|
|
}; |