diff --git a/CHANGELOG.md b/CHANGELOG.md index f67bb59..420e07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.8.7 - 2026-08-22 + +### Added + +- Added configurable JSON POST requests and two-step login-then-token authentication for API sources. + ## 2.8.6 - 2026-08-18 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index b61fcd6..e072d83 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.8.6", + "version": "2.8.7", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 6f71dbe..83e403a 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.8.6", + "version": "2.8.7", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 1c4a690..e548262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.8.6", + "version": "2.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.8.6", + "version": "2.8.7", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index f187e1c..e8f58d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.8.6", + "version": "2.8.7", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/api-sources.js b/src/data/api-sources.js index 0262c82..8a3ef68 100644 --- a/src/data/api-sources.js +++ b/src/data/api-sources.js @@ -8,6 +8,11 @@ const NAME_MAX_LENGTH = 255; const URL_MAX_LENGTH = 1024; const AUTH_MAX_LENGTH = 255; const ITEMS_PATH_MAX_LENGTH = 255; +const REQUEST_BODY_MAX_LENGTH = 1000000; +const TOKEN_URL_MAX_LENGTH = 1024; +const TOKEN_RESPONSE_PATH_MAX_LENGTH = 255; +const TOKEN_HEADER_PREFIX_MAX_LENGTH = 64; +const tokenCache = new Map(); function normalizeUpdateIntervalUnit(value) { const unit = String(value || '').trim().toLowerCase(); @@ -19,7 +24,11 @@ function normalizeUpdateIntervalUnit(value) { function normalizeAuthMethod(value) { const method = String(value || '').trim().toLowerCase(); - return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none'; + return ['basic', 'bearer', 'api_key_header', 'token_login'].includes(method) ? method : 'none'; +} + +function normalizeRequestMethod(value) { + return String(value || '').trim().toUpperCase() === 'POST' ? 'POST' : 'GET'; } function buildAuthHeaders(source) { @@ -50,7 +59,7 @@ function buildAuthHeaders(source) { 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' + '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_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_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC' ); return { apiSources: apiSources }; @@ -58,7 +67,7 @@ async function fetchApiSourcesData(pool) { 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', + 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_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_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, @@ -82,7 +91,7 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so 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 = ?', + '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_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_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?', [id] ); @@ -91,13 +100,18 @@ async function fetchApiSourceById(pool, id) { async function loadUrlText(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; + const headers = Object.assign({ + Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', + 'User-Agent': 'Pulse Signage API Reader' + }, extraHeaders); 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) - }); + const fetchOptions = { method: method, headers: headers }; + if (body !== undefined && method !== 'GET' && method !== 'HEAD') { + fetchOptions.body = body; + } + const response = await fetch(urlValue, fetchOptions); return { statusCode: response.status, @@ -110,12 +124,9 @@ async function loadUrlText(urlValue, requestOptions) { 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 + const request = transport.request(url, Object.assign({}, requestOptions || {}, { + method: method, + headers: headers }), function (response) { response.setEncoding('utf8'); let body = ''; @@ -133,15 +144,126 @@ async function loadUrlText(urlValue, requestOptions) { response.on('error', reject); }); + if (body !== undefined && method !== 'GET' && method !== 'HEAD') { + request.write(body); + } + request.end(); request.on('error', reject); }); } +function parseJsonRequestBody(value, fieldName) { + const text = String(value || '').trim(); + if (!text) { + return undefined; + } + try { + return JSON.parse(text); + } catch (_error) { + const error = new Error(fieldName + ' must contain valid JSON.'); + error.statusCode = 400; + throw error; + } +} + +function resolveResponsePath(value, responsePath) { + let current = value; + String(responsePath || '').split('.').forEach(function (segment) { + if (current === undefined || current === null) { + current = undefined; + return; + } + current = current[segment]; + }); + return current; +} + +function getTokenCacheKey(source) { + return JSON.stringify([ + source && 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', + source && (source.token_header_name || source.tokenHeaderName) || 'Authorization', + source && (source.token_header_prefix || source.tokenHeaderPrefix) || 'Bearer' + ]); +} + +function clearCachedToken(source) { + tokenCache.delete(getTokenCacheKey(source)); +} + +async function fetchLoginToken(source) { + const cacheKey = getTokenCacheKey(source); + const cached = tokenCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const tokenUrl = source.token_url || source.tokenUrl; + const tokenBody = parseJsonRequestBody(source.token_request_body_json || source.tokenRequestBodyJson, 'Login request body'); + const tokenHeaders = { 'Content-Type': 'application/json' }; + const response = await loadUrlText(tokenUrl, { + method: 'POST', + headers: tokenHeaders, + body: tokenBody === undefined ? undefined : JSON.stringify(tokenBody) + }); + if (!response.ok) { + throw new Error(`Unable to obtain API token (${response.statusCode}).`); + } + + let parsed; + try { + parsed = JSON.parse(String(response.bodyText || '').trim()); + } catch (_error) { + throw new Error('Token response was not valid JSON.'); + } + + 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.'); + } + + const expiresIn = Number(parsed && (parsed.expires_in || parsed.expiresIn)); + const lifetimeMs = Number.isFinite(expiresIn) && expiresIn > 0 + ? Math.max(30000, expiresIn * 1000 - 60000) + : 300000; + tokenCache.set(cacheKey, { value: String(token), expiresAt: Date.now() + lifetimeMs }); + return String(token); +} + +async function buildRequestHeaders(source) { + const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod)); + if (method !== 'token_login') { + return buildAuthHeaders(source); + } + + const token = await fetchLoginToken(source); + const headerName = String(source.token_header_name || source.tokenHeaderName || 'Authorization').trim() || 'Authorization'; + const prefix = String(source.token_header_prefix || source.tokenHeaderPrefix || 'Bearer').trim(); + return { [headerName]: prefix ? prefix + ' ' + token : token }; +} + async function fetchApiSourceResponse(apiSource) { const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource }; - const response = await loadUrlText(source.api_url, { - headers: buildAuthHeaders(source) - }); + const requestMethod = normalizeRequestMethod(source.request_method || source.requestMethod); + const requestBody = parseJsonRequestBody(source.request_body_json || source.requestBodyJson, 'API request body'); + let response; + let tokenRetry = false; + do { + response = await loadUrlText(source.api_url || source.apiUrl, { + method: requestMethod, + headers: Object.assign({}, await buildRequestHeaders(source), requestBody === undefined ? {} : { 'Content-Type': 'application/json' }), + body: requestBody === undefined ? undefined : JSON.stringify(requestBody) + }); + if (response.statusCode === 401 && normalizeAuthMethod(source.auth_method || source.authMethod) === 'token_login' && !tokenRetry) { + clearCachedToken(source); + tokenRetry = true; + } else { + break; + } + } while (true); if (!response.ok) { throw new Error(`Unable to load API response (${response.statusCode}).`); } @@ -178,11 +300,18 @@ function buildApiSourcePayload(req, existingApiSource) { 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 requestMethod = normalizeRequestMethod(readBodyValue('request_method', readBodyValue('requestMethod', fallback.request_method || 'GET'))); + const requestBodyJson = validateMaxLength(readBodyValue('request_body_json', readBodyValue('requestBodyJson', fallback.request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'API request body'); 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 tokenUrl = validateMaxLength(readBodyValue('token_url', readBodyValue('tokenUrl', fallback.token_url || '')) || '', TOKEN_URL_MAX_LENGTH, 'API token URL'); + const tokenRequestBodyJson = validateMaxLength(readBodyValue('token_request_body_json', readBodyValue('tokenRequestBodyJson', fallback.token_request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'Login request body'); + const tokenResponsePath = validateMaxLength(readBodyValue('token_response_path', readBodyValue('tokenResponsePath', fallback.token_response_path || 'access_token')) || 'access_token', TOKEN_RESPONSE_PATH_MAX_LENGTH, 'Token response path'); + const tokenHeaderName = validateMaxLength(readBodyValue('token_header_name', readBodyValue('tokenHeaderName', fallback.token_header_name || 'Authorization')) || 'Authorization', AUTH_MAX_LENGTH, 'Token header name'); + const tokenHeaderPrefix = validateMaxLength(readBodyValue('token_header_prefix', readBodyValue('tokenHeaderPrefix', fallback.token_header_prefix || 'Bearer')) || '', TOKEN_HEADER_PREFIX_MAX_LENGTH, 'Token prefix'); 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'); @@ -238,15 +367,48 @@ function buildApiSourcePayload(req, existingApiSource) { throw error; } + parseJsonRequestBody(requestBodyJson, 'API request body'); + parseJsonRequestBody(tokenRequestBodyJson, 'Login request body'); + + if (authMethod === 'token_login') { + if (!tokenUrl) { + const error = new Error('Token login requires a login URL.'); + error.statusCode = 400; + throw error; + } + try { + const tokenParsedUrl = new URL(tokenUrl); + if (tokenParsedUrl.protocol !== 'http:' && tokenParsedUrl.protocol !== 'https:') { + throw new Error('invalid protocol'); + } + } catch (_error) { + const error = new Error('Enter a valid API token URL.'); + error.statusCode = 400; + throw error; + } + if (!tokenRequestBodyJson) { + const error = new Error('Token login requires a JSON request body.'); + error.statusCode = 400; + throw error; + } + } + return { name: name, apiUrl: parsedUrl.toString(), + requestMethod: requestMethod, + requestBodyJson: requestBodyJson, authMethod: authMethod, authUsername: authUsername, authPassword: authPassword, authBearerToken: authBearerToken, authHeaderName: authHeaderName, authHeaderValue: authHeaderValue, + tokenUrl: tokenUrl, + tokenRequestBodyJson: tokenRequestBodyJson, + tokenResponsePath: tokenResponsePath, + tokenHeaderName: tokenHeaderName, + tokenHeaderPrefix: tokenHeaderPrefix, itemsPath: itemsPath, updateIntervalValue: Math.floor(updateIntervalValue), updateIntervalUnit: updateIntervalUnit diff --git a/src/db/index.js b/src/db/index.js index 357cd35..7cd4c1a 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -233,6 +233,20 @@ async function ensureSchema(pool, options) { id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, api_url VARCHAR(1024) NOT NULL, + request_method VARCHAR(10) NOT NULL DEFAULT 'GET', + request_body_json MEDIUMTEXT NULL, + 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_header_name VARCHAR(255) NULL DEFAULT 'Authorization', + token_header_prefix VARCHAR(64) NULL DEFAULT 'Bearer', + items_path VARCHAR(255) NULL, update_interval_value INT NOT NULL DEFAULT 60, update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes', last_pulled_at TIMESTAMP NULL, diff --git a/src/db/migrations.js b/src/db/migrations.js index 297df75..2ee709f 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -492,6 +492,19 @@ const VERSIONED_MIGRATIONS = [ AND permissions.permission_key = 'audit-log.allow'` ); } + }, + { + version: '2.8.7', + label: 'v2.8.7 API request and token authentication schema', + run: async function (pool) { + await ensureColumn(pool, 'i_api_sources', 'request_method', "VARCHAR(10) NOT NULL DEFAULT 'GET'", 'api_url'); + await ensureColumn(pool, 'i_api_sources', 'request_body_json', 'MEDIUMTEXT NULL', 'request_method'); + await ensureColumn(pool, 'i_api_sources', 'token_url', 'VARCHAR(1024) NULL', 'auth_header_value'); + await ensureColumn(pool, 'i_api_sources', 'token_request_body_json', 'MEDIUMTEXT NULL', 'token_url'); + await ensureColumn(pool, 'i_api_sources', 'token_response_path', "VARCHAR(255) NULL DEFAULT 'access_token'", 'token_request_body_json'); + await ensureColumn(pool, 'i_api_sources', 'token_header_name', "VARCHAR(255) NULL DEFAULT 'Authorization'", 'token_response_path'); + await ensureColumn(pool, 'i_api_sources', 'token_header_prefix', "VARCHAR(64) NULL DEFAULT 'Bearer'", 'token_header_name'); + } } ]; diff --git a/src/web/public/js/data-sources/api-source-form.js b/src/web/public/js/data-sources/api-source-form.js index 396449e..b3b496e 100644 --- a/src/web/public/js/data-sources/api-source-form.js +++ b/src/web/public/js/data-sources/api-source-form.js @@ -11,6 +11,9 @@ 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]'); function updateBearerTokenToggle() { if (!bearerTokenInput || !bearerTokenToggle) { @@ -47,14 +50,28 @@ }); } + function updateRequestBodyVisibility() { + if (!requestMethodSelect || !requestSection || !requestBodyInput) { + return; + } + + var isPost = String(requestMethodSelect.value || 'GET').toUpperCase() === 'POST'; + requestSection.hidden = !isPost; + } + if (methodSelect) { methodSelect.addEventListener('change', updatePanels); } + if (requestMethodSelect) { + requestMethodSelect.addEventListener('change', updateRequestBodyVisibility); + } + if (bearerTokenToggle && bearerTokenInput) { bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility); updateBearerTokenToggle(); } updatePanels(); + updateRequestBodyVisibility(); }()); \ No newline at end of file diff --git a/src/web/routes/data-sources/api-sources/duplicate.js b/src/web/routes/data-sources/api-sources/duplicate.js index ddc86e2..43e397c 100644 --- a/src/web/routes/data-sources/api-sources/duplicate.js +++ b/src/web/routes/data-sources/api-sources/duplicate.js @@ -21,6 +21,13 @@ function buildDuplicateApiSource(apiSource, duplicateName) { 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', + tokenHeaderName: apiSource.token_header_name || 'Authorization', + tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer', itemsPath: apiSource.items_path || '' }); } diff --git a/src/web/routes/data-sources/api-sources/edit.js b/src/web/routes/data-sources/api-sources/edit.js index 0e8cd0a..00bbda7 100644 --- a/src/web/routes/data-sources/api-sources/edit.js +++ b/src/web/routes/data-sources/api-sources/edit.js @@ -18,6 +18,13 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr authBearerToken: apiSource.auth_bearer_token || '', authHeaderName: apiSource.auth_header_name || 'X-API-Key', authHeaderValue: apiSource.auth_header_value || '', - itemsPath: apiSource.items_path || '' + tokenUrl: apiSource.token_url || '', + tokenRequestBodyJson: apiSource.token_request_body_json || '', + tokenResponsePath: apiSource.token_response_path || 'access_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)); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/api-sources/form-view-model.js b/src/web/routes/data-sources/api-sources/form-view-model.js index 82859db..b9d9703 100644 --- a/src/web/routes/data-sources/api-sources/form-view-model.js +++ b/src/web/routes/data-sources/api-sources/form-view-model.js @@ -5,12 +5,19 @@ function buildDefaultApiSource() { id: null, name: '', apiUrl: '', + requestMethod: 'GET', + requestBodyJson: '', authMethod: 'none', authUsername: '', authPassword: '', authBearerToken: '', authHeaderName: 'X-API-Key', authHeaderValue: '', + tokenUrl: '', + tokenRequestBodyJson: '', + tokenResponsePath: 'access_token', + tokenHeaderName: 'Authorization', + tokenHeaderPrefix: 'Bearer', itemsPath: '', updateIntervalValue: 60, updateIntervalUnit: 'minutes', diff --git a/src/web/routes/data-sources/api-sources/routes.js b/src/web/routes/data-sources/api-sources/routes.js index ad586a9..b182cd6 100644 --- a/src/web/routes/data-sources/api-sources/routes.js +++ b/src/web/routes/data-sources/api-sources/routes.js @@ -103,6 +103,8 @@ module.exports = function registerApiSourceRoutes(app, deps) { 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', 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`) @@ -178,6 +180,11 @@ module.exports = function registerApiSourceRoutes(app, deps) { 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', + 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', @@ -235,8 +242,8 @@ module.exports = function registerApiSourceRoutes(app, deps) { const actorId = getAuditUserId(req); await connection.beginTransaction(); const [result] = await connection.query( - 'INSERT INTO i_api_sources (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_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId] + '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_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.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 () { @@ -288,8 +295,8 @@ module.exports = function registerApiSourceRoutes(app, deps) { const actorId = getAuditUserId(req); await connection.beginTransaction(); await connection.query( - 'UPDATE i_api_sources SET 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 = ?, modified_by = ? WHERE id = ?', - [payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id] + '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_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 = ?, 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.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id] ); await connection.commit(); dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { diff --git a/src/web/views/data-sources/api-sources/form.hbs b/src/web/views/data-sources/api-sources/form.hbs index 8546b95..7be1980 100644 --- a/src/web/views/data-sources/api-sources/form.hbs +++ b/src/web/views/data-sources/api-sources/form.hbs @@ -12,14 +12,30 @@
-
+
-
+
+
+ + +
+
+
+

Request

+
+
+
POST requests use JSON with the existing authentication settings.
+ +
+

Access and parsing

@@ -31,6 +47,7 @@ +
@@ -40,7 +57,7 @@
-
Optional. Use dot notation to point at the array this source should iterate through.
+
Optional. Use dot notation to change the default base for placeholders.
@@ -77,6 +94,30 @@
+

Refresh

diff --git a/test/api-source-request.test.js b/test/api-source-request.test.js new file mode 100644 index 0000000..64f5bcf --- /dev/null +++ b/test/api-source-request.test.js @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { fetchApiSourceResponse, buildApiSourcePayload } = require('../src/data/api-sources'); + +function jsonResponse(status, body) { + return { + status: status, + ok: status >= 200 && status < 300, + headers: { get: () => 'application/json' }, + text: async () => JSON.stringify(body) + }; +} + +test('API source payload accepts POST request and token-login settings', () => { + const payload = buildApiSourcePayload({ + body: { + name: 'Protected report', + api_url: 'https://example.com/report', + request_method: 'POST', + request_body_json: '{"site_id":42}', + auth_method: 'token_login', + token_url: 'https://example.com/login', + token_request_body_json: '{"username":"demo","password":"secret"}', + token_response_path: 'data.accessToken', + token_header_name: 'Authorization', + token_header_prefix: 'Bearer', + update_interval_value: '5', + update_interval_unit: 'minutes' + } + }); + + assert.equal(payload.requestMethod, 'POST'); + assert.equal(payload.requestBodyJson, '{"site_id":42}'); + assert.equal(payload.authMethod, 'token_login'); + assert.equal(payload.tokenUrl, 'https://example.com/login'); + assert.equal(payload.tokenResponsePath, 'data.accessToken'); +}); + +test('API source sends login POST, uses token, and refreshes once after 401', async () => { + const originalFetch = global.fetch; + const calls = []; + let loginCount = 0; + let apiCount = 0; + global.fetch = async function (url, options) { + calls.push({ url, options }); + if (url === 'https://example.com/login') { + loginCount += 1; + return jsonResponse(200, { accessToken: 'token-' + loginCount, expires_in: 300 }); + } + apiCount += 1; + return apiCount === 1 + ? jsonResponse(401, { error: 'expired' }) + : jsonResponse(200, { items: [{ id: 1 }] }); + }; + + try { + const result = await fetchApiSourceResponse({ + id: 987654, + api_url: 'https://example.com/report', + request_method: 'POST', + request_body_json: JSON.stringify({ site_id: 42 }), + auth_method: 'token_login', + token_url: 'https://example.com/login', + token_request_body_json: JSON.stringify({ username: 'demo', password: 'secret' }), + token_response_path: 'accessToken', + token_header_name: 'Authorization', + token_header_prefix: 'Bearer' + }); + + assert.deepEqual(JSON.parse(result.responseJson), { items: [{ id: 1 }] }); + assert.equal(loginCount, 2); + assert.equal(apiCount, 2); + assert.equal(calls[0].options.method, 'POST'); + assert.equal(calls[0].options.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(calls[0].options.body), { username: 'demo', password: 'secret' }); + assert.equal(calls[1].options.headers.Authorization, 'Bearer token-1'); + assert.equal(calls[3].options.headers.Authorization, 'Bearer token-2'); + assert.deepEqual(JSON.parse(calls[3].options.body), { site_id: 42 }); + } finally { + global.fetch = originalFetch; + } +}); diff --git a/test/schema-update-log.test.js b/test/schema-update-log.test.js index c172c2f..e4e110f 100644 --- a/test/schema-update-log.test.js +++ b/test/schema-update-log.test.js @@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi } ]); - const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.0' }); + const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.7' }); assert.equal(pendingMigrations.length, 0); }); @@ -74,7 +74,7 @@ test('pending migrations are reported when an older schema still needs scripts', const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' }); assert.ok(pendingMigrations.length > 0); - assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 1); + assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 2); assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0'); });