From 7321e218712a30c8acb8414869ee62ae2100dba8 Mon Sep 17 00:00:00 2001 From: Denis Shatskiy Date: Mon, 11 Aug 2025 16:54:19 +0700 Subject: [PATCH] feat: Introduce helper functions for S3 metadata handling and improve error responses across ASNs, Domains, and IP Ranges APIs for better consistency and clarity --- backend/server.js | 119 +++++++++++++++++++++++++++++----------------- 1 file changed, 76 insertions(+), 43 deletions(-) diff --git a/backend/server.js b/backend/server.js index 57f5d4e..42b5847 100644 --- a/backend/server.js +++ b/backend/server.js @@ -22,6 +22,35 @@ app.use((req, res, next) => { next(); }); +// Helpers: meta and responses +function toIso(x) { + try { return new Date(x).toISOString(); } catch { return null; } +} + +async function headMeta(key) { + try { + const h = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise(); + return { + etag: h.ETag || null, + lastModified: h.LastModified ? toIso(h.LastModified) : null, + contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null, + }; + } catch (e) { + return { etag: null, lastModified: null, contentLength: null }; + } +} + +function sendOk(res, meta) { + if (meta?.etag) res.set('ETag', String(meta.etag)); + if (meta?.lastModified) res.set('Last-Modified', new Date(meta.lastModified).toUTCString()); + if (typeof meta?.contentLength === 'number') res.set('Content-Length-Source', String(meta.contentLength)); + return res.json({ ok: true, etag: meta?.etag || null, lastModified: meta?.lastModified || null, contentLength: meta?.contentLength ?? null }); +} + +function sendError(res, status, message, code, details) { + return res.status(status).json({ message, code, details }); +} + // Serve static files from the React app app.use(express.static(path.join(__dirname, 'public'))); @@ -274,7 +303,7 @@ app.get('/api/domains', async (req, res) => { res.json([]); // Return empty array if file does not exist } else { console.error(error); - res.status(500).send('Error reading from S3'); + return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) }); } } }); @@ -317,14 +346,21 @@ app.post('/api/domains', async (req, res) => { // Get ASNs from S3 app.get('/api/asns', async (req, res) => { - const { q = '', offset, limit } = req.query || {}; + const { q = '', offset, limit, countOnly } = req.query || {}; const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt', }; try { - if (limit !== undefined) { + if (countOnly === 'true') { + // только количество + const { total } = await streamPaginatedText({ + key: 'bgp_data/asns.txt', q, offset: 0, limit: 0, + mapLine: (line) => ({}) + }); + return res.json({ total }); + } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ key: 'bgp_data/asns.txt', q, @@ -335,7 +371,7 @@ app.get('/api/asns', async (req, res) => { return { domain: parts[0] || '', type: parts[1] || '' }; } }); - if (!validateAsns(items)) return res.status(500).json({ message: 'Invalid data format' }); + if (!validateAsns(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return res.json({ items, total }); } else { const data = await s3.getObject(params).promise(); @@ -346,7 +382,7 @@ app.get('/api/asns', async (req, res) => { const type = parts[1] || ''; return { domain, type }; }); - if (!validateAsns(asns)) return res.status(500).json({ message: 'Invalid data format' }); + if (!validateAsns(asns)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); if (data.ETag) res.set('ETag', String(data.ETag)); if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); @@ -369,12 +405,12 @@ app.post('/api/asns', async (req, res) => { try { if (!validateAsns(asns || [])) { - return res.status(400).json({ message: 'Invalid payload format for asns' }); + return sendError(res, 400, 'Invalid payload format for asns', 'E_SCHEMA'); } let current = null; try { current = await headS3ObjectEtag('bgp_data/asns.txt'); } catch {} if (current && etag && current !== String(etag)) { - return res.status(412).json({ message: 'Precondition Failed: ETag mismatch', currentEtag: current }); + return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current }); } } catch {} @@ -387,16 +423,11 @@ app.post('/api/asns', async (req, res) => { try { const put = await s3.putObject(params).promise(); - if (put.ETag) res.set('ETag', String(put.ETag)); - try { - const head = await s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' }).promise(); - if (head.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - } catch {} - res.json({ ok: true }); + const meta = await headMeta('bgp_data/asns.txt'); + return sendOk(res, meta); } catch (error) { console.error(error); - res.status(500).send('Error writing to S3'); + return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); } }); @@ -404,14 +435,20 @@ app.post('/api/asns', async (req, res) => { // Get domains-new from S3 app.get('/api/domains-new', async (req, res) => { - const { q = '', offset, limit } = req.query || {}; + const { q = '', offset, limit, countOnly } = req.query || {}; const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt', }; try { - if (limit !== undefined) { + if (countOnly === 'true') { + const { total } = await streamPaginatedText({ + key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0, + mapLine: (line) => ({}) + }); + return res.json({ total }); + } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ key: 'bgp_data/domains_community.txt', q, @@ -422,7 +459,7 @@ app.get('/api/domains-new', async (req, res) => { return { domain: parts[0] || '', community: parts[1] || '' }; } }); - if (!validateDomainsNew(items)) return res.status(500).json({ message: 'Invalid data format' }); + if (!validateDomainsNew(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return res.json({ items, total }); } else { const data = await s3.getObject(params).promise(); @@ -433,7 +470,7 @@ app.get('/api/domains-new', async (req, res) => { const community = parts[1] || ''; return { domain, community }; }); - if (!validateDomainsNew(domains)) return res.status(500).json({ message: 'Invalid data format' }); + if (!validateDomainsNew(domains)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); if (data.ETag) res.set('ETag', String(data.ETag)); if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); @@ -456,12 +493,12 @@ app.post('/api/domains-new', async (req, res) => { try { if (!validateDomainsNew(domains || [])) { - return res.status(400).json({ message: 'Invalid payload format for domains-new' }); + return sendError(res, 400, 'Invalid payload format for domains-new', 'E_SCHEMA'); } let current = null; try { current = await headS3ObjectEtag('bgp_data/domains_community.txt'); } catch {} if (current && etag && current !== String(etag)) { - return res.status(412).json({ message: 'Precondition Failed: ETag mismatch', currentEtag: current }); + return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current }); } } catch {} @@ -474,16 +511,11 @@ app.post('/api/domains-new', async (req, res) => { try { const put = await s3.putObject(params).promise(); - if (put.ETag) res.set('ETag', String(put.ETag)); - try { - const head = await s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }).promise(); - if (head.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - } catch {} - res.json({ ok: true }); + const meta = await headMeta('bgp_data/domains_community.txt'); + return sendOk(res, meta); } catch (error) { console.error(error); - res.status(500).send('Error writing to S3'); + return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); } }); @@ -491,14 +523,20 @@ app.post('/api/domains-new', async (req, res) => { // Get IP ranges from S3 app.get('/api/ip-ranges', async (req, res) => { - const { q = '', offset, limit } = req.query || {}; + const { q = '', offset, limit, countOnly } = req.query || {}; const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt', }; try { - if (limit !== undefined) { + if (countOnly === 'true') { + const { total } = await streamPaginatedText({ + key: 'bgp_data/ips.txt', q, offset: 0, limit: 0, + mapLine: (line) => ({}) + }); + return res.json({ total }); + } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ key: 'bgp_data/ips.txt', q, @@ -509,7 +547,7 @@ app.get('/api/ip-ranges', async (req, res) => { return { ipRange: parts[0] || '', community: parts[1] || '' }; } }); - if (!validateIpRanges(items)) return res.status(500).json({ message: 'Invalid data format' }); + if (!validateIpRanges(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return res.json({ items, total }); } else { const data = await s3.getObject(params).promise(); @@ -520,7 +558,7 @@ app.get('/api/ip-ranges', async (req, res) => { const community = parts[1] || ''; return { ipRange, community }; }); - if (!validateIpRanges(ipRanges)) return res.status(500).json({ message: 'Invalid data format' }); + if (!validateIpRanges(ipRanges)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); if (data.ETag) res.set('ETag', String(data.ETag)); if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); @@ -543,12 +581,12 @@ app.post('/api/ip-ranges', async (req, res) => { try { if (!validateIpRanges(ipRanges || [])) { - return res.status(400).json({ message: 'Invalid payload format for ip-ranges' }); + return sendError(res, 400, 'Invalid payload format for ip-ranges', 'E_SCHEMA'); } let current = null; try { current = await headS3ObjectEtag('bgp_data/ips.txt'); } catch {} if (current && etag && current !== String(etag)) { - return res.status(412).json({ message: 'Precondition Failed: ETag mismatch', currentEtag: current }); + return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current }); } } catch {} @@ -561,16 +599,11 @@ app.post('/api/ip-ranges', async (req, res) => { try { const put = await s3.putObject(params).promise(); - if (put.ETag) res.set('ETag', String(put.ETag)); - try { - const head = await s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' }).promise(); - if (head.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - } catch {} - res.json({ ok: true }); + const meta = await headMeta('bgp_data/ips.txt'); + return sendOk(res, meta); } catch (error) { console.error(error); - res.status(500).send('Error writing to S3'); + return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); } });