From c1f19b31a8ebacf7e11525ddb12864b340796b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 15 Jan 2024 17:43:37 +0100 Subject: [PATCH] feat: dummy HTTP API server + Fly deployment (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- Dockerfile | 1 - bin/spark-stats.js | 75 ++++++++++++++++++++++++++++++++++++++++++++ fly.toml | 1 + lib/config.js | 3 ++ lib/handler.js | 56 +++++++++++++++++++++++++++++++++ lib/typings.d.ts | 5 +++ test/handler.test.js | 59 ++++++++++++++++++++++++++++++++++ test/smoke.test.js | 3 -- 8 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 bin/spark-stats.js create mode 100644 lib/config.js create mode 100644 lib/handler.js create mode 100644 lib/typings.d.ts create mode 100644 test/handler.test.js delete mode 100644 test/smoke.test.js diff --git a/Dockerfile b/Dockerfile index 69e4c11..7e1dd91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,6 @@ WORKDIR /app # Set production environment ENV NODE_ENV production ENV SENTRY_ENVIRONMENT production -ENV DOMAIN stats.filspark.com ENV REQUEST_LOGGING false ####################################################################### diff --git a/bin/spark-stats.js b/bin/spark-stats.js new file mode 100644 index 0000000..2935d50 --- /dev/null +++ b/bin/spark-stats.js @@ -0,0 +1,75 @@ +import http from 'node:http' +import { once } from 'node:events' +import pg from 'pg' +import Sentry from '@sentry/node' +import fs from 'node:fs/promises' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { createHandler } from '../lib/handler.js' +import { DATABASE_URL } from '../lib/config.js' + +const { + PORT = 8080, + HOST = '127.0.0.1', + SENTRY_ENVIRONMENT = 'development', + REQUEST_LOGGING = 'true' +} = process.env + +const pkg = JSON.parse( + await fs.readFile( + join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'package.json' + ), + 'utf8' + ) +) + +Sentry.init({ + dsn: 'https://47b65848a6171ecd8bf9f5395a782b3f@o1408530.ingest.sentry.io/4506576125427712', + release: pkg.version, + environment: SENTRY_ENVIRONMENT, + tracesSampleRate: 0.1 +}) + +const pgPool = new pg.Pool({ + connectionString: DATABASE_URL, + // allow the pool to close all connections and become empty + min: 0, + // this values should correlate with service concurrency hard_limit configured in fly.toml + // and must take into account the connection limit of our PG server, see + // https://fly.io/docs/postgres/managing/configuration-tuning/ + max: 100, + // close connections that haven't been used for one second + idleTimeoutMillis: 1000, + // automatically close connections older than 60 seconds + maxLifetimeSeconds: 60 +}) + +pgPool.on('error', err => { + // Prevent crashing the process on idle client errors, the pool will recover + // itself. If all connections are lost, the process will still crash. + // https://github.com/brianc/node-postgres/issues/1324#issuecomment-308778405 + console.error('An idle client has experienced an error', err.stack) +}) + +// Check that we can talk to the database +await pgPool.query('SELECT 1') + +const logger = { + error: console.error, + info: console.info, + request: ['1', 'true'].includes(REQUEST_LOGGING) ? console.info : () => {} +} + +const handler = createHandler({ + pgPool, + logger +}) +const server = http.createServer(handler) +console.log('Starting the http server on host %j port %s', HOST, PORT) +server.listen(PORT, HOST) +await once(server, 'listening') +console.log(`http://${HOST}:${PORT}`) diff --git a/fly.toml b/fly.toml index 8a8234f..85aae7e 100644 --- a/fly.toml +++ b/fly.toml @@ -1,6 +1,7 @@ # fly.toml file generated for spark on 2023-05-16T19:09:01+02:00 app = "spark-stats" +primary_region = "cdg" kill_signal = "SIGINT" kill_timeout = 5 processes = [] diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..a04da5a --- /dev/null +++ b/lib/config.js @@ -0,0 +1,3 @@ +export const { + DATABASE_URL = 'postgres://localhost:5432/spark_stats' +} = process.env diff --git a/lib/handler.js b/lib/handler.js new file mode 100644 index 0000000..b2cf5ad --- /dev/null +++ b/lib/handler.js @@ -0,0 +1,56 @@ +import Sentry from '@sentry/node' + +/** + * + * @param {object} args + * @param {import('pg').Pool} args.pgPool + * @param {import('./typings').Logger} args.logger + * @returns + */ +export const createHandler = ({ + pgPool, + logger +}) => { + return (req, res) => { + const start = new Date() + logger.request(`${req.method} ${req.url} ...`) + handler(req, res, pgPool) + .catch(err => errorHandler(res, err, logger)) + .then(() => { + logger.request(`${req.method} ${req.url} ${res.statusCode} (${new Date() - start}ms)`) + }) + } +} + +/** + * @param {import('node:http').IncomingMessage} req + * @param {import('node:http').ServerResponse} res + * @param {import('pg').Pool} pgPool + */ +const handler = async (req, res, pgPool) => { + // TBD + notFound(res) +} + +const errorHandler = (res, err, logger) => { + if (err instanceof SyntaxError) { + res.statusCode = 400 + res.end('Invalid JSON Body') + } else if (err.statusCode) { + res.statusCode = err.statusCode + res.end(err.message) + } else { + logger.error(err) + res.statusCode = 500 + res.end('Internal Server Error') + } + + if (res.statusCode >= 500) { + Sentry.captureException(err) + } +} + +const notFound = (res) => { + res.statusCode = 404 + res.end('Not Found') +} diff --git a/lib/typings.d.ts b/lib/typings.d.ts new file mode 100644 index 0000000..c87026f --- /dev/null +++ b/lib/typings.d.ts @@ -0,0 +1,5 @@ +export interface Logger { + info: typeof console.info; + error: typeof console.error; + request: typeof console.info; +} diff --git a/test/handler.test.js b/test/handler.test.js new file mode 100644 index 0000000..152414c --- /dev/null +++ b/test/handler.test.js @@ -0,0 +1,59 @@ +import http from 'node:http' +import { once } from 'node:events' +import { AssertionError } from 'node:assert' +import pg from 'pg' +import createDebug from 'debug' + +import { createHandler } from '../lib/handler.js' +import { DATABASE_URL } from '../lib/config.js' + +const debug = createDebug('test') + +describe('HTTP request handler', () => { + /** @type {pg.Pool} */ + let pgPool + /** @type {http.Server} */ + let server + /** @type {string} */ + let baseUrl + + before(async () => { + pgPool = new pg.Pool({ connectionString: DATABASE_URL }) + + const handler = createHandler({ + pgPool, + logger: { + info: debug, + error: console.error, + request: debug + } + }) + + // server = http.createServer((req, res) => { console.log(req.method, req.url); res.end('hello') }) + server = http.createServer(handler) + server.listen() + await once(server, 'listening') + baseUrl = `http://127.0.0.1:${server.address().port}` + }) + + after(async () => { + server.closeAllConnections() + server.close() + await pgPool.end() + }) + + it('returns 404 for unknown routes', async () => { + const res = await fetch(new URL('/unknown-path', baseUrl)) + assertResponseStatus(res, 404) + }) +}) + +const assertResponseStatus = async (res, status) => { + if (res.status !== status) { + throw new AssertionError({ + actual: res.status, + expected: status, + message: await res.text() + }) + } +} diff --git a/test/smoke.test.js b/test/smoke.test.js deleted file mode 100644 index 2b9b906..0000000 --- a/test/smoke.test.js +++ /dev/null @@ -1,3 +0,0 @@ -it('works', () => { - // tbd -})