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'); 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, common, backgroundTaskQueue, dataSourceTasks, getAuditUserId, redirectAfterSave, requirePermission } = deps; const formatDate = deps.formatDashboardDate || String; const base = '/data-sources/api-sources'; function syncSchedules(group, removed, actorId) { (removed || []).forEach(function (id) { dataSourceTasks.removeRecurringRefresh('api-source', id); }); group.requests.forEach(function (request) { if (group.enabled === 0 || group.enabled === false || !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); }); }); } 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 search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); 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.get(base + '/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 || '', req.currentUser)); } catch (error) { next(error); } }); 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); } }); 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.enabled === 0 || group.enabled === false ? 1 : 0; await pool.query('UPDATE i_api_source_groups SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), id]); group.enabled = enabled; syncSchedules(group, [], getAuditUserId(req)); return res.redirect(base + '/' + id + '/edit?message=' + encodeURIComponent(enabled ? 'API source enabled.' : 'API source 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, enabled: result.enabled, 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'); if (group.enabled === 0 || group.enabled === false) return res.status(409).json({ error: 'API source is disabled.' }); 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'); const tasks = []; for (const request of requests) { if (request.enabled) tasks.push(await enqueue(request, group, getAuditUserId(req))); } if (req.params.requestId && req.get('Accept') === 'application/json') { if (!tasks.length) return res.status(409).json({ error: 'API request is disabled.' }); return res.status(202).json({ taskId: tasks[0].id, stateUrl: base + '/' + group.id + '/requests/' + Number(req.params.requestId) + '/state', message: 'Refreshing saved API request. Unsaved changes are kept.' }); } 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.get(base + '/:id/requests/:requestId/state', requirePermission('api-sources.allow'), async function (req, res, next) { res.set('Cache-Control', 'no-store'); try { const taskId = Number(req.query.refresh_task_id); if (!Number.isSafeInteger(taskId) || taskId <= 0) return res.status(400).json({ error: 'Missing refresh task id.' }); const [requests] = await pool.query('SELECT id FROM i_api_sources WHERE id = ? AND auth_source_id = ?', [Number(req.params.requestId), Number(req.params.id)]); if (!requests.length) return res.status(404).json({ error: 'API request not found.' }); const task = await dataSourceTasks.getTaskStatusById(taskId); if (!task || task.key !== 'api-source-refresh:' + Number(req.params.requestId)) return res.status(404).json({ error: 'Refresh task not found.' }); res.json(task); } catch (error) { next(error); } }); 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(); return res.redirect(base + '?message=' + encodeURIComponent('This API source has requests used by slides or template defaults.')); } 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(); } }); };