From 3c44fcc0627426fefe4a3d47bd50c77ce4eecf56 Mon Sep 17 00:00:00 2001 From: Mike Conrad Date: Tue, 20 May 2025 14:00:44 -0400 Subject: [PATCH] Reduce controller logic scope --- app/Helpers/Replays.ts | 50 +++++++++++ app/Helpers/Sentry.ts | 28 ++++++ app/Helpers/Webhook.ts | 19 ++++ app/controllers/replays_controller.ts | 125 ++++---------------------- 4 files changed, 114 insertions(+), 108 deletions(-) create mode 100644 app/Helpers/Replays.ts create mode 100644 app/Helpers/Sentry.ts create mode 100644 app/Helpers/Webhook.ts diff --git a/app/Helpers/Replays.ts b/app/Helpers/Replays.ts new file mode 100644 index 0000000..8a14e70 --- /dev/null +++ b/app/Helpers/Replays.ts @@ -0,0 +1,50 @@ +import Replay from '#models/replay' +import {parseSentryLinkHeader, SentryPagination} from './Sentry.js' + +import env from '#start/env' +let recordsUpdated = 0 +const SENTRY_TOKEN = env.get('SENTRY_TOKEN') +interface ApiResponse { + data: T; + // optionally, you can define `meta`, `errors`, etc. if your API returns them +} +export async function fetchBatch(url: string) { + const options: RequestInit = { + headers: { + Authorization: `Bearer ${SENTRY_TOKEN}` + } + } + const req = await fetch(url, options) + if (!req.ok) { + throw new Error(`Request failed with status ${req.status}`); + } + + const resp = await req.json() as ApiResponse; + const replays = resp.data; + const headers = req.headers + + const cleanedData = replays.map(record => sanitizeInput(record, Replay.allowedFields)) + + let updated = await Replay.updateOrCreateMany('id', cleanedData) + recordsUpdated = recordsUpdated + updated.length + const linkHeader = headers.get('link') + if (!linkHeader) { + return { error: 'link header missing from Sentry API response' } + } + const pagination: SentryPagination = parseSentryLinkHeader(linkHeader) + + if (pagination.hasNextResults == true) { + console.log('fetching', pagination.next) + await fetchBatch(pagination.next) + } + console.log('no more results') + return { recordsUpdated } + +} + +function sanitizeInput(data: Record, allowedFields: string[]) { + return allowedFields.reduce((acc, key) => { + if (key in data) acc[key] = data[key] + return acc + }, {} as Record) +} diff --git a/app/Helpers/Sentry.ts b/app/Helpers/Sentry.ts new file mode 100644 index 0000000..cab1bda --- /dev/null +++ b/app/Helpers/Sentry.ts @@ -0,0 +1,28 @@ + +export interface SentryPagination { + previous: string; + hasPreviousResults: boolean; + hasNextResults: boolean; + next: string +} +export function parseSentryLinkHeader(header: string): SentryPagination { + const links = header.split(',').map(part => part.trim()) + + let result = {} as SentryPagination + for (const link of links) { + const match = link.match(/<([^>]+)>;\s*rel="([^"]+)";\s*results="([^"]+)";\s*cursor="([^"]+)"/) + if (!match) continue + + const [, url, rel, results] = match + + if (rel === 'previous') { + result.previous = url + result.hasPreviousResults = results === 'true' + } else if (rel === 'next') { + result.next = url + result.hasNextResults = results === 'true' + } + } + + return result +} \ No newline at end of file diff --git a/app/Helpers/Webhook.ts b/app/Helpers/Webhook.ts new file mode 100644 index 0000000..35477cc --- /dev/null +++ b/app/Helpers/Webhook.ts @@ -0,0 +1,19 @@ +import env from '#start/env' + +export async function sendDataToWebhook(responseData:{ version: number, updatedAt: Date, numberOfRecords: number, data: unknown}) { + try { + console.log('syncing to webhook') + await fetch(env.get('WEBHOOK_URL'), + { + headers: + { + 'content-type': 'application/json' + }, + method: 'POST', + body: JSON.stringify(responseData) + } + ) + } catch (e) { + console.error('error sending webhook data', e) + } +} \ No newline at end of file diff --git a/app/controllers/replays_controller.ts b/app/controllers/replays_controller.ts index 79afc17..1a7961f 100644 --- a/app/controllers/replays_controller.ts +++ b/app/controllers/replays_controller.ts @@ -1,63 +1,35 @@ import Replay from '#models/replay' import env from '#start/env' import type { HttpContext } from '@adonisjs/core/http' -import db from '@adonisjs/lucid/services/db' -const SENTRY_TOKEN = env.get('SENTRY_TOKEN') const SENTRY_ORG = env.get('SENTRY_ORG') -let recordsUpdated = 0 import redis from '@adonisjs/redis/services/main' +import { fetchBatch } from '../Helpers/Replays.js' +import { sendDataToWebhook } from '../Helpers/Webhook.js' -interface ApiResponse { - data: T; - // optionally, you can define `meta`, `errors`, etc. if your API returns them -} - -interface SentryPagination { - previous: string; - hasPreviousResults: boolean; - hasNextResults: boolean; - next: string -} export default class ReplaysController { public async stats({ request, response }: HttpContext) { - const {sendToWebhook} = request.qs() + const { sendToWebhook } = request.qs() const latestVersion = await redis.get(`replays:stats:latest_version`) + let results if (!latestVersion) { - // console.log('Cache miss') - const queryResults = await Replay.updateReplayStats() - queryResults.latest_version = 1 - queryResults.updatedAt = Date.now() - await redis.set(`replays:stats:version:1:results`, JSON.stringify(queryResults)) - await redis.set(`replays:stats:latest_version`, 1) - return response.json(queryResults) + results = await Replay.updateReplayStats() } else { console.log('cache hit') - const results = await redis.get(`replays:stats:version:${latestVersion}:results`) - return response.json(JSON.parse(results)) + let data = await redis.get(`replays:stats:version:${latestVersion}:results`) + if (data) { + results = JSON.parse(data) + } } - await redis.set(`replays:stats:version:${latestQueryVersion}:results`, JSON.stringify(results)) - + + let responseData = { version: results.version, updatedAt: results.updatedAt, numberOfRecords: results.rows.length, data: results.rows } if (sendToWebhook) { - try { - console.log('syncing to webhook') - await fetch(env.get('WEBHOOK_URL'), - { - headers: - { - 'content-type': 'application/json' - }, - method: 'POST', - body: JSON.stringify(results.rows) - } - ) - } catch(e) { - console.error('error sending webhook data', e) - } - } + await sendDataToWebhook(responseData) } + return response.json(responseData) + } public async list({ request, inertia }: HttpContext) { const page = request.input('page', 1) @@ -97,7 +69,6 @@ export default class ReplaysController { async index({ request, response }: HttpContext) { const { statsPeriod, start, end } = request.qs() - recordsUpdated = 0 let queryString: string = '?statsPeriod=24h'// Default in case none is provided if (statsPeriod) { @@ -105,78 +76,16 @@ export default class ReplaysController { } else if (start && end) { queryString = `?start=${start}&end=${end}` } - const replays = await fetchBatch(`https://sentry.io/api/0/organizations/${SENTRY_ORG}/replays/${queryString}`) + await fetchBatch(`https://sentry.io/api/0/organizations/${SENTRY_ORG}/replays/${queryString}`) let queryResults = await Replay.updateReplayStats() - - return response.json({version: queryResults.latestVersion, ...queryResults}) + + return response.json({ version: queryResults.latestVersion, ...queryResults }) } } -async function fetchBatch(url: string) { - const options: RequestInit = { - headers: { - Authorization: `Bearer ${SENTRY_TOKEN}` - } - } - const req = await fetch(url, options) - if (!req.ok) { - throw new Error(`Request failed with status ${req.status}`); - } - - const resp = await req.json() as ApiResponse; - const replays = resp.data; - const headers = req.headers - - const cleanedData = replays.map(record => sanitizeInput(record, Replay.allowedFields)) - - let updated = await Replay.updateOrCreateMany('id', cleanedData) - recordsUpdated = recordsUpdated + updated.length - const linkHeader = headers.get('link') - if (!linkHeader) { - return { error: 'link header missing from Sentry API response' } - } - const pagination: SentryPagination = parseSentryLinkHeader(linkHeader) - - if (pagination.hasNextResults == true) { - console.log('fetching', pagination.next) - await fetchBatch(pagination.next) - } - console.log('no more results') - return { recordsUpdated } - -} -function parseSentryLinkHeader(header: string): SentryPagination { - const links = header.split(',').map(part => part.trim()) - - let result = {} as SentryPagination - for (const link of links) { - const match = link.match(/<([^>]+)>;\s*rel="([^"]+)";\s*results="([^"]+)";\s*cursor="([^"]+)"/) - if (!match) continue - - const [, url, rel, results] = match - - if (rel === 'previous') { - result.previous = url - result.hasPreviousResults = results === 'true' - } else if (rel === 'next') { - result.next = url - result.hasNextResults = results === 'true' - } - } - - return result -} - -function sanitizeInput(data: Record, allowedFields: string[]) { - return allowedFields.reduce((acc, key) => { - if (key in data) acc[key] = data[key] - return acc - }, {} as Record) -} - function buildPaginationLinks(meta: { previousPageUrl: string, lastPage: number; currentPage: number; nextPageUrl: string }) { const links = []