diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index cf41b373db..062392ca78 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -113,6 +113,7 @@ To build a Metrics integration test server, you must write an HTTP server that d - Looks for an `README_API_KEY` environment variable, and exits with an exit code of 1 if it does not exist. - Spawns an HTTP server that listens on the `PORT` environment variable, or 4000 if no environment variable exists. - The HTTP server should have a listener on `GET /` that responds with a 200 status code, and a JSON response body of `{ "message": "hello world" }` +- The HTTP server should have a listener on `POST /` that will be provided with a JSON body of `{ "user": { "email": "dom@readme.io" } }` which should be included in the Metrics payload. This should respond with a 200 status code and an empty body. - The HTTP server should have a Metrics SDK installed, that responds with the following identification object for the user making the request: ```json diff --git a/__tests__/integration-metrics.test.js b/__tests__/integration-metrics.test.js index bf9a8fd04c..42d9a730ff 100644 --- a/__tests__/integration-metrics.test.js +++ b/__tests__/integration-metrics.test.js @@ -35,6 +35,16 @@ function isListening(port, attempt = 0) { }); } +async function getBody(response) { + let responseBody = ''; + // eslint-disable-next-line no-restricted-syntax + for await (const chunk of response) { + responseBody += chunk; + } + expect(responseBody).not.toBe(''); + return JSON.parse(responseBody); +} + // https://gist.github.com/krnlde/797e5e0a6f12cc9bd563123756fc101f http.get[promisify.custom] = function getAsync(options) { return new Promise((resolve, reject) => { @@ -49,6 +59,22 @@ http.get[promisify.custom] = function getAsync(options) { }); }; +function post(url, body, options) { + return new Promise((resolve, reject) => { + const request = http + .request(url, { method: 'post', ...options }, response => { + response.end = new Promise(res => { + response.on('end', res); + }); + resolve(response); + }) + .on('error', reject); + + request.write(body); + request.end(); + }); +} + const get = promisify(http.get); const randomApiKey = 'a-random-readme-api-key'; @@ -152,8 +178,6 @@ describe('Metrics SDK Integration Tests', () => { }); }); - // TODO this needs fleshing out more with more assertions and complex - // test cases, along with more servers in different languages too! it('should make a request to a metrics backend with a har file', async () => { await get(`http://localhost:${PORT}`); @@ -161,12 +185,7 @@ describe('Metrics SDK Integration Tests', () => { expect(req.url).toBe('/v1/request'); expect(req.headers.authorization).toBe('Basic YS1yYW5kb20tcmVhZG1lLWFwaS1rZXk6'); - let body = ''; - // eslint-disable-next-line no-restricted-syntax - for await (const chunk of req) { - body += chunk; - } - body = JSON.parse(body); + const body = await getBody(req); const [har] = body; // Check for a uuid @@ -205,11 +224,43 @@ describe('Metrics SDK Integration Tests', () => { // Flask prints a \n character after the JSON response // https://github.com/pallets/flask/issues/4635 expect(response.content.text.replace('\n', '')).toBe(JSON.stringify({ message: 'hello world' })); - // The \n character above means we cannot compare to a fixed number expect(response.content.size).toStrictEqual(response.content.text.length); expect(response.content.mimeType).toMatch(/application\/json(;\s?charset=utf-8)?/); const responseHeaders = caseless(arrayToObject(response.headers)); expect(responseHeaders.get('content-type')).toMatch(/application\/json(;\s?charset=utf-8)?/); }); + + it('should process the http POST body', async () => { + const postData = JSON.stringify({ user: { email: 'dom@readme.io' } }); + await post(`http://localhost:${PORT}/`, postData, { + headers: { + 'content-type': 'application/json', + // Explicit content-length is required for Python/Flask + 'content-length': Buffer.byteLength(postData), + }, + }); + + const [req] = await once(metricsServer, 'request'); + + const body = await getBody(req); + const [har] = body; + + const { request, response } = har.request.log.entries[0]; + expect(request.method).toBe('POST'); + expect(response.status).toBe(200); + expect(request.postData).toStrictEqual( + process.env.EXAMPLE_SERVER.startsWith('php') + ? { + mimeType: 'application/json', + params: [], + } + : { + mimeType: 'application/json', + text: postData, + } + ); + expect(response.content.text).toMatch(''); + expect(response.content.size).toBe(0); + }); }); diff --git a/packages/dotnet/examples/net6.0/Program.cs b/packages/dotnet/examples/net6.0/Program.cs index 42ebfd386d..04491f2ea9 100644 --- a/packages/dotnet/examples/net6.0/Program.cs +++ b/packages/dotnet/examples/net6.0/Program.cs @@ -34,4 +34,10 @@ await context.Response.WriteAsJsonAsync(new { message = "hello world" }); }); +app.MapPost("/", async context => +{ + context.Response.StatusCode = 200; + await context.Response.CompleteAsync(); +}); + app.Run($"http://localhost:{port}"); diff --git a/packages/node/__tests__/index.test.ts b/packages/node/__tests__/index.test.ts index d3dd2756d1..4f588b04b6 100644 --- a/packages/node/__tests__/index.test.ts +++ b/packages/node/__tests__/index.test.ts @@ -1,19 +1,15 @@ -import type { ServerResponse } from 'http'; - import * as crypto from 'crypto'; +import { createServer } from 'http'; import express from 'express'; -import findCacheDir from 'find-cache-dir'; -import flatCache from 'flat-cache'; import FormData from 'form-data'; import { isValidUUIDV4 } from 'is-valid-uuid-v4'; import multer from 'multer'; import nock from 'nock'; -import rimraf from 'rimraf'; import request from 'supertest'; import pkg from '../package.json'; -import { expressMiddleware } from '../src'; +import * as readmeio from '../src'; import config from '../src/config'; const upload = multer(); @@ -31,49 +27,17 @@ const outgoingGroup = { email: 'test@example.com', }; -const baseLogUrl = 'https://docs.example.com'; -const cacheDir = findCacheDir({ name: pkg.name }); - -function getReadMeApiMock(numberOfTimes) { - return nock(config.readmeApiUrl, { - reqheaders: { - 'User-Agent': `${pkg.name}/${pkg.version}`, - }, - }) - .get('/v1/') - .basicAuth({ user: apiKey }) - .times(numberOfTimes) - .reply(200, { baseUrl: baseLogUrl }); -} - -function getCache() { - const encodedApiKey = Buffer.from(`${apiKey}:`).toString('base64'); - const fsSafeApikey = crypto.createHash('md5').update(encodedApiKey).digest('hex'); - const cacheKey = [pkg.name, pkg.version, fsSafeApikey].join('-'); - - return flatCache.load(cacheKey, cacheDir); -} - -function hydrateCache(lastUpdated) { - const cache = getCache(); - - // Postdate the cache to two days ago so it'll bee seen as stale. - cache.setKey('lastUpdated', lastUpdated); - cache.setKey('baseUrl', baseLogUrl); - cache.save(); -} - declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { interface Matchers { - toHaveDocumentationHeader(res?: ServerResponse): R; + toHaveDocumentationHeader(baseLogUrl: string): R; } } } expect.extend({ - toHaveDocumentationHeader(res) { + toHaveDocumentationHeader(res, baseLogUrl) { const { matcherHint, printExpected, printReceived } = this.utils; const message = (pass, actual) => () => { return ( @@ -106,13 +70,51 @@ describe('#metrics', () => { afterEach(() => { nock.cleanAll(); + }); + + it('should throw an error if `apiKey` is missing', () => { + const app = express(); + app.use((req, res, next) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + readmeio.log('', req, res, {}); + return next(); + }); + app.get('/test', (req, res) => res.sendStatus(200)); + + // This silences console.errors from default express errorhandler + app.set('env', 'test'); + + return request(app) + .get('/test') + .expect(500) + .then(res => { + expect(res.text).toMatch(/Error: You must provide your ReadMe API key/); + }); + }); + + it('should throw an error if `group` is missing', () => { + const app = express(); + app.use((req, res, next) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + readmeio.log(apiKey, req, res); + return next(); + }); + app.get('/test', (req, res) => res.sendStatus(200)); - // Clean up the cache dir between tests. - rimraf.sync(cacheDir); + // This silences console.errors from default express errorhandler + app.set('env', 'test'); + + return request(app) + .get('/test') + .expect(500) + .then(res => { + expect(res.text).toMatch(/Error: You must provide a group/); + }); }); it('should send a request to the metrics server', () => { - const apiMock = getReadMeApiMock(1); const mock = nock(config.host, { reqheaders: { 'Content-Type': 'application/json', @@ -128,21 +130,23 @@ describe('#metrics', () => { .reply(200); const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup)); + app.use((req, res, next) => { + const logId = readmeio.log(apiKey, req, res, incomingGroup); + res.setHeader('x-log-id', logId); + return next(); + }); app.get('/test', (req, res) => res.sendStatus(200)); return request(app) .get('/test') .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()) + .expect('x-log-id', /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i) .then(() => { - apiMock.done(); mock.done(); }); }); - it('express should log the full request url with nested express apps', () => { - const apiMock = getReadMeApiMock(1); + it('should set `pageref` correctly based on `req.route`', () => { const mock = nock(config.host, { reqheaders: { 'Content-Type': 'application/json', @@ -150,38 +154,60 @@ describe('#metrics', () => { }, }) .post('/v1/request', ([body]) => { - expect(body.group).toStrictEqual(outgoingGroup); - expect(body.request.log.entries[0].request.url).toContain('/test/nested'); + expect(body.request.log.entries[0].pageref).toBe('http://127.0.0.1/test/:id'); return true; }) .basicAuth({ user: apiKey }) .reply(200); const app = express(); - const appNest = express(); - - appNest.use(expressMiddleware(apiKey, () => incomingGroup)); - appNest.get('/nested', (req, res) => { - // We're asserting `req.url` to be `/nested` here because the way that Express does contextual route loading - // `req.url` won't include the `/test`. The `/test` is only added later internally in Express with `req.originalUrl`. - expect(req.url).toBe('/nested'); - res.sendStatus(200); + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup); + return next(); }); + app.get('/test/:id', (req, res) => res.sendStatus(200)); - app.use('/test', appNest); + return request(app) + .get('/test/hello') + .expect(200) + .then(() => { + mock.done(); + }); + }); + + // There's a slight inconsistency here between express and non-express. + // When not in express, pageref contains the port but in express it does not. + // This is due to us using `req.hostname` to construct the URL vs just + // req.headers.host which has not been parsed. + it('should set `pageref` without express', () => { + const mock = nock(config.host, { + reqheaders: { + 'Content-Type': 'application/json', + 'User-Agent': `${pkg.name}/${pkg.version}`, + }, + }) + .post('/v1/request', ([body]) => { + expect(body.request.log.entries[0].pageref).toMatch(/http:\/\/127.0.0.1:\d.*\/test\/hello/); + return true; + }) + .basicAuth({ user: apiKey }) + .reply(200); + + const app = createServer((req, res) => { + readmeio.log(apiKey, req, res, incomingGroup); + res.statusCode = 200; + res.end(); + }); return request(app) - .get('/test/nested') + .get('/test/hello') .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()) .then(() => { - apiMock.done(); mock.done(); }); }); - it('should have access to group(req,res) objects', () => { - const apiMock = getReadMeApiMock(1); + it('express should log the full request url with nested express apps', () => { const mock = nock(config.host, { reqheaders: { 'Content-Type': 'application/json', @@ -189,30 +215,33 @@ describe('#metrics', () => { }, }) .post('/v1/request', ([body]) => { - expect(body.group.id).toBe('a'); - expect(body.group.label).toBe('b'); - expect(body.group.email).toBe('c'); + expect(body.group).toStrictEqual(outgoingGroup); + expect(body.request.log.entries[0].request.url).toContain('/test/nested'); return true; }) .basicAuth({ user: apiKey }) .reply(200); const app = express(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app.use((req: any, res: any, next) => { - req.a = 'a'; - res.b = 'b'; - res.c = 'c'; - next(); + const appNest = express(); + + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup); + return next(); }); - app.use(expressMiddleware(apiKey, (req, res) => ({ apiKey: req.a, label: res.b, email: res.c }))); - app.get('/test', (req, res) => res.sendStatus(200)); + appNest.get('/nested', (req, res) => { + // We're asserting `req.url` to be `/nested` here because the way that Express does contextual route loading + // `req.url` won't include the `/test`. The `/test` is only added later internally in Express with `req.originalUrl`. + expect(req.url).toBe('/nested'); + res.sendStatus(200); + }); + + app.use('/test', appNest); return request(app) - .get('/test') + .get('/test/nested') .expect(200) .then(() => { - apiMock.done(); mock.done(); }); }); @@ -225,7 +254,7 @@ describe('#metrics', () => { describe('#bufferLength', () => { it('should send requests when number hits `bufferLength` size', async function test() { - const apiMock = getReadMeApiMock(1); + const baseLogUrl = 'https://docs.example.com'; const mock = nock(config.host, { reqheaders: { 'Content-Type': 'application/json', @@ -239,7 +268,10 @@ describe('#metrics', () => { .reply(200); const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup, { bufferLength: 3 })); + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup, { bufferLength: 3, baseLogUrl }); + return next(); + }); app.get('/test', (req, res) => res.sendStatus(200)); // We need to make sure that the logId isn't being preserved between buffered requests. @@ -249,20 +281,21 @@ describe('#metrics', () => { .get('/test') .expect(200) .expect(res => { - expect(res).toHaveDocumentationHeader(); + expect(res).toHaveDocumentationHeader(baseLogUrl); logUrl = res.headers['x-documentation-url']; + expect(logUrl).toBeDefined(); }); - expect(apiMock.isDone()).toBe(true); expect(mock.isDone()).toBe(false); await request(app) .get('/test') .expect(200) .expect(res => { - expect(res).toHaveDocumentationHeader(); + expect(res).toHaveDocumentationHeader(baseLogUrl); expect(res.headers['x-documentation-url']).not.toBe(logUrl); logUrl = res.headers['x-documentation-url']; + expect(logUrl).toBeDefined(); }); expect(mock.isDone()).toBe(false); @@ -271,19 +304,17 @@ describe('#metrics', () => { .get('/test') .expect(200) .expect(res => { - expect(res).toHaveDocumentationHeader(); + expect(res).toHaveDocumentationHeader(baseLogUrl); expect(res.headers['x-documentation-url']).not.toBe(logUrl); + logUrl = res.headers['x-documentation-url']; + expect(logUrl).toBeDefined(); }); expect(mock.isDone()).toBe(true); - apiMock.done(); mock.done(); }); it('should clear out the queue when sent', () => { - // Hydrate the cache so we don't need to mess with mocking out the API. - hydrateCache(Math.round(Date.now() / 1000)); - const numberOfLogs = 20; const numberOfMocks = 4; const bufferLength = numberOfLogs / numberOfMocks; @@ -317,7 +348,10 @@ describe('#metrics', () => { ); const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup, { bufferLength })); + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup, { bufferLength }); + return next(); + }); app.get('/test', (req, res) => res.sendStatus(200)); return Promise.all( @@ -331,7 +365,9 @@ describe('#metrics', () => { }); describe('#baseLogUrl', () => { - it('should not call the API if the baseLogUrl supplied as a middleware option', async () => { + it('should set x-documentation-url if `baseLogUrl` is passed', async () => { + const baseLogUrl = 'https://docs.example.com'; + const mock = nock(config.host, { reqheaders: { 'Content-Type': 'application/json', @@ -343,156 +379,22 @@ describe('#metrics', () => { .reply(200); const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup, { baseLogUrl })); + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup, { baseLogUrl }); + return next(); + }); app.get('/test', (req, res) => res.sendStatus(200)); await request(app) .get('/test') .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()); + .expect(res => expect(res).toHaveDocumentationHeader(baseLogUrl)); mock.done(); }); - - it('should not call the API for project data if the cache is fresh', async () => { - const apiMock = getReadMeApiMock(1); - const metricsMock = nock(config.host, { - reqheaders: { - 'Content-Type': 'application/json', - 'User-Agent': `${pkg.name}/${pkg.version}`, - }, - }) - .post('/v1/request') - .basicAuth({ user: apiKey }) - .reply(200); - - const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup)); - app.get('/test', (req, res) => res.sendStatus(200)); - - // Cache will be populated with this call as the cache doesn't exist yet. - await request(app) - .get('/test') - .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()); - - // Spin up a new app so we're forced to look for the baseUrl in the cache instead of what's saved in-memory - // within the middleware. - const app2 = express(); - app2.use(expressMiddleware(apiKey, () => incomingGroup)); - app2.get('/test', (req, res) => res.sendStatus(200)); - - // Cache will be hit with this request and shouldn't make another call to the API for data it already has. - await request(app2) - .get('/test') - .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()); - - apiMock.done(); - metricsMock.done(); - }); - - it('should populate the cache if not present', async () => { - const apiMock = getReadMeApiMock(1); - const metricsMock = nock(config.host, { - reqheaders: { - 'Content-Type': 'application/json', - 'User-Agent': `${pkg.name}/${pkg.version}`, - }, - }) - .post('/v1/request') - .basicAuth({ user: apiKey }) - .reply(200); - - const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup)); - app.get('/test', (req, res) => res.sendStatus(200)); - - await request(app) - .get('/test') - .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()); - - apiMock.done(); - metricsMock.done(); - }); - - it('should refresh the cache if stale', async () => { - // Hydate and postdate the cache to two days ago so it'll bee seen as stale. - hydrateCache(Math.round(Date.now() / 1000 - 86400 * 2)); - - const apiMock = getReadMeApiMock(1); - const metricsMock = nock(config.host, { - reqheaders: { - 'Content-Type': 'application/json', - 'User-Agent': `${pkg.name}/${pkg.version}`, - }, - }) - .post('/v1/request') - .basicAuth({ user: apiKey }) - .reply(200); - - const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup)); - app.get('/test', (req, res) => res.sendStatus(200)); - - await request(app) - .get('/test') - .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()); - - apiMock.done(); - metricsMock.done(); - }); - - it('should temporarily set baseUrl to null if the call to the ReadMe API fails for whatever reason', async () => { - const apiMock = nock(config.readmeApiUrl, { - reqheaders: { - 'User-Agent': `${pkg.name}/${pkg.version}`, - }, - }) - .get('/v1/') - .basicAuth({ user: apiKey }) - .reply(401, { - error: 'APIKEY_NOTFOUNDD', - message: "We couldn't find your API key", - suggestion: - "The API key you passed in (moc··········Key) doesn't match any keys we have in our system. API keys must be passed in as the username part of basic auth. You can get your API key in Configuration > API Key, or in the docs.", - docs: 'https://docs.readme.com/developers/logs/fake-uuid', - help: "If you need help, email support@readme.io and mention log 'fake-uuid'.", - }); - - const metricsMock = nock(config.host, { - reqheaders: { - 'Content-Type': 'application/json', - 'User-Agent': `${pkg.name}/${pkg.version}`, - }, - }) - .post('/v1/request') - .basicAuth({ user: apiKey }) - .reply(200); - - const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup)); - app.get('/test', (req, res) => res.sendStatus(200)); - - await request(app) - .get('/test') - .expect(200) - .expect(res => { - expect(getCache().getKey('baseUrl')).toBeNull(); - - // `x-documentation-url` header should not be present since we couldn't get the base URL! - expect(Object.keys(res.headers)).not.toContain('x-documentation-url'); - }); - - apiMock.done(); - metricsMock.done(); - }); }); describe('`res._body`', () => { - let apiMock; const responseBody = { a: 1, b: 2, c: 3 }; function createMock() { return nock(config.host, { @@ -508,18 +410,13 @@ describe('#metrics', () => { .reply(200); } - beforeEach(() => { - apiMock = getReadMeApiMock(1); - }); - - afterEach(() => { - apiMock.done(); - }); - it('should buffer up res.write() calls', async () => { const mock = createMock(); const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup)); + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup); + return next(); + }); app.get('/test', (req, res) => { res.write('{"a":1,'); res.write('"b":2,'); @@ -535,13 +432,13 @@ describe('#metrics', () => { it('should buffer up res.end() calls', async () => { const mock = createMock(); const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup)); + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup); + return next(); + }); app.get('/test', (req, res) => res.end(JSON.stringify(responseBody))); - await request(app) - .get('/test') - .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()); + await request(app).get('/test').expect(200); mock.done(); }); @@ -549,21 +446,19 @@ describe('#metrics', () => { it('should work for res.send() calls', async () => { const mock = createMock(); const app = express(); - app.use(expressMiddleware(apiKey, () => incomingGroup)); + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup); + return next(); + }); app.get('/test', (req, res) => res.send(responseBody)); - await request(app) - .get('/test') - .expect(200) - .expect(res => expect(res).toHaveDocumentationHeader()); + await request(app).get('/test').expect(200); mock.done(); }); }); describe('`req.body`', () => { - let apiMock; - function createMock(checkLocation: 'text' | 'params', requestBody: unknown) { return nock(config.host, { reqheaders: { @@ -578,14 +473,6 @@ describe('#metrics', () => { .reply(200); } - beforeEach(() => { - apiMock = getReadMeApiMock(1); - }); - - afterEach(() => { - apiMock.done(); - }); - it('should accept multipart/form-data', async () => { const form = new FormData(); form.append('password', '123456'); @@ -597,7 +484,10 @@ describe('#metrics', () => { const mock = createMock('text', JSON.stringify({ password: '123456', apiKey: 'abc', another: 'Hello world' })); const app = express(); app.use(upload.none()); - app.use(expressMiddleware(apiKey, () => incomingGroup)); + app.use((req, res, next) => { + readmeio.log(apiKey, req, res, incomingGroup); + return next(); + }); app.post('/test', (req, res) => { res.status(200).end(); }); diff --git a/packages/node/__tests__/lib/construct-payload.test.ts b/packages/node/__tests__/lib/construct-payload.test.ts index 52ed2a142b..303df95ad3 100644 --- a/packages/node/__tests__/lib/construct-payload.test.ts +++ b/packages/node/__tests__/lib/construct-payload.test.ts @@ -1,6 +1,7 @@ import type { LogOptions, PayloadData } from '../../src/lib/construct-payload'; +import type { IncomingMessage, ServerResponse } from 'node:http'; -import * as http from 'http'; +import { createServer } from 'http'; import os from 'os'; import * as qs from 'querystring'; @@ -11,7 +12,7 @@ import pkg from '../../package.json'; import { constructPayload } from '../../src/lib/construct-payload'; function createApp(options?: LogOptions, payloadData?: PayloadData) { - const requestListener = function (req: http.IncomingMessage, res: http.ServerResponse) { + const requestListener = function (req: IncomingMessage, res: ServerResponse) { let body = ''; let parsedBody: Record | undefined; @@ -35,7 +36,7 @@ function createApp(options?: LogOptions, payloadData?: PayloadData) { }); }; - return http.createServer(requestListener); + return createServer(requestListener); } describe('constructPayload()', () => { diff --git a/packages/node/__tests__/lib/process-request.test.ts b/packages/node/__tests__/lib/process-request.test.ts index 366e0d4b69..e540f0718f 100644 --- a/packages/node/__tests__/lib/process-request.test.ts +++ b/packages/node/__tests__/lib/process-request.test.ts @@ -1,6 +1,7 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; import type { LogOptions } from 'src/lib/construct-payload'; -import * as http from 'http'; +import { createServer } from 'http'; import FormData from 'form-data'; import request from 'supertest'; @@ -8,7 +9,7 @@ import request from 'supertest'; import processRequest from '../../src/lib/process-request'; function createApp(reqOptions?: LogOptions, shouldPreParse = false, bodyOverride?) { - const requestListener = function (req: http.IncomingMessage, res: http.ServerResponse) { + const requestListener = function (req: IncomingMessage, res: ServerResponse) { let body = ''; req.on('readable', function () { @@ -28,7 +29,7 @@ function createApp(reqOptions?: LogOptions, shouldPreParse = false, bodyOverride }); }; - return http.createServer(requestListener); + return createServer(requestListener); } test('should create expected json response when preparsed', () => { diff --git a/packages/node/examples/express/index.js b/packages/node/examples/express/index.js index 9976578817..52469e7037 100644 --- a/packages/node/examples/express/index.js +++ b/packages/node/examples/express/index.js @@ -1,5 +1,4 @@ #!/usr/bin/env node - import express from 'express'; import readmeio from 'readmeio'; @@ -12,22 +11,27 @@ if (!process.env.README_API_KEY) { const app = express(); const port = process.env.PORT || 8000; -app.use( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - readmeio.metrics(process.env.README_API_KEY, req => ({ +app.use((req, res, next) => { + readmeio.log(process.env.README_API_KEY, req, res, { // User's API Key apiKey: 'owlbert-api-key', // Username to show in the dashboard label: 'Owlbert', // User's email address email: 'owlbert@example.com', - })) -); + }); + + return next(); +}); app.get('/', (req, res) => { res.json({ message: 'hello world' }); }); +app.post('/', express.json(), (req, res) => { + res.status(200).send(); +}); + const server = app.listen(port, 'localhost', function () { // eslint-disable-next-line no-console console.log('Example app listening at http://%s:%s', server.address().address, port); diff --git a/packages/node/examples/fastify/index.js b/packages/node/examples/fastify/index.js index 82c3480db6..607f5d6460 100644 --- a/packages/node/examples/fastify/index.js +++ b/packages/node/examples/fastify/index.js @@ -12,18 +12,9 @@ const fastify = Fastify({ }); const port = process.env.PORT || 8000; -fastify.decorateRequest('readmeStartTime', null); -fastify.decorateReply('payload', null); -fastify.addHook('onRequest', async request => { - request.readmeStartTime = new Date(); -}); - fastify.addHook('onSend', async (request, reply, payload) => { - // eslint-disable-next-line no-param-reassign - reply.payload = payload; -}); - -fastify.addHook('onResponse', async (request, reply) => { + const { raw: req } = request; + const { raw: res } = reply; const payloadData = { // User's API Key apiKey: 'owlbert-api-key', @@ -31,19 +22,26 @@ fastify.addHook('onResponse', async (request, reply) => { label: 'Owlbert', // User's email address email: 'owlbert@example.com', - - startedDateTime: new Date(request.readmeStartTime), - responseEndDateTime: new Date(), - responseBody: reply.payload, }; - - readmeio.log(process.env.README_API_KEY, request.raw, reply, payloadData, { fireAndForget: true }); + // We have to patch the req/res objects with the params required for the sdk + req.body = request.body; + // Modified approach taken from here: + // https://github.com/fastify/fastify-nextjs/pull/112 + // Fastify uses `writeHead` for performance reasons, which means those header values + // are not accessible via `reply.raw` + Object.entries(reply.getHeaders()).forEach(([name, val]) => reply.raw.setHeader(name, val)); + readmeio.log(process.env.README_API_KEY, req, res, payloadData); + return payload; }); fastify.get('/', (request, reply) => { reply.send({ message: 'hello world' }); }); +fastify.post('/', (request, reply) => { + reply.code(200).send(); +}); + fastify.listen({ port }, err => { if (err) throw err; }); diff --git a/packages/node/examples/hapi/index.js b/packages/node/examples/hapi/index.js index 5ebb8e5f43..68fdd8f1d5 100644 --- a/packages/node/examples/hapi/index.js +++ b/packages/node/examples/hapi/index.js @@ -12,9 +12,8 @@ const port = process.env.PORT || 8000; const init = async () => { const server = Hapi.server({ host: 'localhost', port }); - server.ext('onPostResponse', function (request, h) { - const { response, raw, info } = request; - const { req, res } = raw; + server.ext('onPreResponse', function (request, h) { + const { req, res } = request.raw; const payloadData = { // User's API Key @@ -23,12 +22,10 @@ const init = async () => { label: 'Owlbert', // User's email address email: 'owlbert@example.com', - startedDateTime: new Date(info?.received), - responseEndDateTime: new Date(info?.responded), - responseBody: response?.source, }; - readmeio.log(process.env.README_API_KEY, req, res, payloadData, { fireAndForget: true }); + req.body = request.payload; + readmeio.log(process.env.README_API_KEY, req, res, payloadData); return h.continue; }); @@ -41,6 +38,14 @@ const init = async () => { }, }); + server.route({ + method: 'POST', + path: '/', + handler: (request, h) => { + return h.response().code(200); + }, + }); + await server.start(); // eslint-disable-next-line no-console console.log('Server listening on %s', server.info.uri); diff --git a/packages/node/package-lock.json b/packages/node/package-lock.json index 46be223431..e2c12712be 100644 --- a/packages/node/package-lock.json +++ b/packages/node/package-lock.json @@ -12,8 +12,6 @@ "@types/har-format": "^1.2.7", "@types/node-fetch": "^2.6.2", "content-type": "^1.0.4", - "find-cache-dir": "^3.3.1", - "flat-cache": "^3.0.4", "lodash": "^4.17.15", "node-fetch": "^2.6.7", "timeout-signal": "^1.1.0", @@ -2399,7 +2397,8 @@ "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true }, "node_modules/body-parser": { "version": "1.20.0", @@ -2429,6 +2428,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2729,11 +2729,6 @@ "node": ">= 12.0.0" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" - }, "node_modules/component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -2743,7 +2738,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "node_modules/concat-stream": { "version": "1.6.2", @@ -4426,22 +4422,6 @@ "node": ">= 0.8" } }, - "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, "node_modules/find-up": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", @@ -4458,6 +4438,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, "dependencies": { "flatted": "^3.1.0", "rimraf": "^3.0.2" @@ -4469,7 +4450,8 @@ "node_modules/flatted": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", - "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==" + "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", + "dev": true }, "node_modules/form-data": { "version": "4.0.0", @@ -4533,7 +4515,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "node_modules/fsevents": { "version": "2.3.2", @@ -4670,6 +4653,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -4949,6 +4933,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -4957,7 +4942,8 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/internal-slot": { "version": "1.0.3", @@ -6708,6 +6694,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "dependencies": { "semver": "^6.0.0" }, @@ -6722,6 +6709,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -6846,6 +6834,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7162,6 +7151,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "dependencies": { "wrappy": "1" } @@ -7300,6 +7290,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -7365,6 +7356,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, "dependencies": { "find-up": "^4.0.0" }, @@ -7376,6 +7368,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -7388,6 +7381,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -7399,6 +7393,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "dependencies": { "p-try": "^2.0.0" }, @@ -7413,6 +7408,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -7424,6 +7420,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, "engines": { "node": ">=6" } @@ -7432,6 +7429,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "engines": { "node": ">=8" } @@ -7911,6 +7909,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -8900,7 +8899,8 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "node_modules/write-file-atomic": { "version": "4.0.1", @@ -10794,7 +10794,8 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true }, "body-parser": { "version": "1.20.0", @@ -10820,6 +10821,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11043,11 +11045,6 @@ "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==", "dev": true }, - "commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" - }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -11057,7 +11054,8 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "concat-stream": { "version": "1.6.2", @@ -12288,16 +12286,6 @@ "unpipe": "~1.0.0" } }, - "find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "requires": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - } - }, "find-up": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", @@ -12311,6 +12299,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, "requires": { "flatted": "^3.1.0", "rimraf": "^3.0.2" @@ -12319,7 +12308,8 @@ "flatted": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", - "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==" + "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", + "dev": true }, "form-data": { "version": "4.0.0", @@ -12367,7 +12357,8 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "fsevents": { "version": "2.3.2", @@ -12461,6 +12452,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12659,6 +12651,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -12667,7 +12660,8 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "internal-slot": { "version": "1.0.3", @@ -13980,6 +13974,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "requires": { "semver": "^6.0.0" }, @@ -13987,7 +13982,8 @@ "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true } } }, @@ -14081,6 +14077,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -14324,6 +14321,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "requires": { "wrappy": "1" } @@ -14422,7 +14420,8 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true }, "path-key": { "version": "3.1.1", @@ -14470,6 +14469,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, "requires": { "find-up": "^4.0.0" }, @@ -14478,6 +14478,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "requires": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -14487,6 +14488,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "requires": { "p-locate": "^4.1.0" } @@ -14495,6 +14497,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "requires": { "p-try": "^2.0.0" } @@ -14503,6 +14506,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "requires": { "p-limit": "^2.2.0" } @@ -14510,12 +14514,14 @@ "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true } } }, @@ -14857,6 +14863,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "requires": { "glob": "^7.1.3" } @@ -15594,7 +15601,8 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "write-file-atomic": { "version": "4.0.1", diff --git a/packages/node/package.json b/packages/node/package.json index 9b4f7dbfdb..539087a00d 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -28,8 +28,6 @@ "@types/har-format": "^1.2.7", "@types/node-fetch": "^2.6.2", "content-type": "^1.0.4", - "find-cache-dir": "^3.3.1", - "flat-cache": "^3.0.4", "lodash": "^4.17.15", "node-fetch": "^2.6.7", "timeout-signal": "^1.1.0", diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 0811e58972..a4dcaa322c 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,9 +1,4 @@ -/** - * @deprecated use expressMiddleware instead - */ +import { log } from './lib/log'; import verifyWebhook from './lib/verify-webhook'; -export { expressMiddleware as metrics } from './lib/express-middleware'; -export { expressMiddleware } from './lib/express-middleware'; -export { log } from './lib/metrics-log'; -export { verifyWebhook }; +export { log, verifyWebhook }; diff --git a/packages/node/src/lib/construct-payload.ts b/packages/node/src/lib/construct-payload.ts index c23223d700..215f92e744 100644 --- a/packages/node/src/lib/construct-payload.ts +++ b/packages/node/src/lib/construct-payload.ts @@ -1,5 +1,5 @@ import type { OutgoingLogBody } from './metrics-log'; -import type { ServerResponse, IncomingMessage } from 'http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import type { TLSSocket } from 'tls'; import os from 'os'; @@ -125,7 +125,7 @@ export function constructPayload( entries: [ { pageref: payloadData.routePath - ? new URL(payloadData.routePath, `${getProto(req)}://${req.headers.host}`).toString() + ? payloadData.routePath : new URL(req.url, `${getProto(req)}://${req.headers.host}`).toString(), startedDateTime: payloadData.startedDateTime.toISOString(), time: serverTime, diff --git a/packages/node/src/lib/express-middleware.ts b/packages/node/src/lib/express-middleware.ts deleted file mode 100644 index 344e27409f..0000000000 --- a/packages/node/src/lib/express-middleware.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { LogOptions } from './construct-payload'; -import type { GroupingObject, OutgoingLogBody } from './metrics-log'; - -import * as url from 'url'; - -import clamp from 'lodash/clamp'; -import { v4 as uuidv4 } from 'uuid'; - -import config from '../config'; - -import { constructPayload } from './construct-payload'; -import { getProjectBaseUrl } from './get-project-base-url'; -import { metricsAPICall } from './metrics-log'; - -// Make sure we flush the queue if the process is exited -let doSend = () => {}; // eslint-disable-line @typescript-eslint/no-empty-function -process.on('exit', doSend); - -export interface GroupingFunction { - (req, res): GroupingObject; -} - -// We're doing this to buffer up the response body -// so we can send it off to the metrics server -// It's unfortunate that this isn't accessible -// natively. This may take up lots of memory on -// big responses, we can make it configurable in future -function patchResponse(res) { - const { write, end } = res; - - res._body = ''; - - res.write = (chunk, encoding, cb) => { - res._body += chunk; - write.call(res, chunk, encoding, cb); - }; - - res.end = (chunk, encoding, cb) => { - // Chunk is optional in res.end - // http://nodejs.org/dist/latest/docs/api/http.html#http_response_end_data_encoding_callback - if (chunk) res._body += chunk; - end.call(res, chunk, encoding, cb); - }; -} - -export interface Options extends LogOptions { - bufferLength?: number; - baseLogUrl?: string; -} - -/** - * This middleware will set up Express to automatically log all API requests to ReadMe Metrics. - * - * @param apiKey The API key for your ReadMe project. This ensures your requests end up in your dashboard. You can read more about the API key in [our docs](https://docs.readme.com/reference/authentication). - * @param group A function that helps translate incoming request data to our metrics grouping data. You can read more under [Grouping Function](#grouping-function). - * @param options Additional options. See the documentation for more details. - * @returns Your Express middleware - */ -export function expressMiddleware(readmeApiKey: string, group: GroupingFunction, options: Options = {}) { - if (!readmeApiKey) throw new Error('You must provide your ReadMe API key'); - if (!group) throw new Error('You must provide a grouping function'); - - // Ensures the buffer length is between 1 and 30 - const bufferLength = clamp(options.bufferLength || config.bufferLength, 1, 30); - const requestTimeout = config.timeout; - const encodedApiKey = Buffer.from(`${readmeApiKey}:`).toString('base64'); - let baseLogUrl = options.baseLogUrl || undefined; - let queue: OutgoingLogBody[] = []; - - return async (req, res, next) => { - if (baseLogUrl === undefined) { - baseLogUrl = await getProjectBaseUrl(encodedApiKey, requestTimeout); - } - - const startedDateTime = new Date(); - const logId = uuidv4(); - - if (baseLogUrl !== undefined && typeof baseLogUrl === 'string') { - res.setHeader('x-documentation-url', `${baseLogUrl}/logs/${logId}`); - } - - patchResponse(res); - - doSend = () => { - // Copy the queue so we can send all the requests in one batch - const json = queue.slice(); - // Clear out the queue so we don't resend any data in the future - queue = []; - - // Make the log call - metricsAPICall(readmeApiKey, json).catch(e => { - // Silently discard errors and timeouts. - if (options.development) throw e; - }); - }; - - function startSend() { - // This should in future become more sophisticated, - // with flush timeouts and more error checking but - // this is fine for now - const groupData = group(req, res); - - const payload = constructPayload( - { - ...req, - - // Shallow copying `req` destroys `req.headers` on Node 16 so we're re-adding it. - headers: req.headers, - - // If you're using route nesting with `express.use()` then `req.url` is contextual to that route. So say - // you have an `/api` route that loads `/v1/upload`, `req.url` within the `/v1/upload` controller will be - // `/v1/upload`. Calling `req.originalUrl` ensures that we also capture the `/api` prefix. - url: req.originalUrl, - }, - res, - { - ...groupData, - logId, - startedDateTime, - responseEndDateTime: new Date(), - routePath: req.route - ? url.format({ - protocol: req.protocol, - host: req.hostname, - pathname: `${req.baseUrl}${req.route.path}`, - }) - : '', - responseBody: res._body, - requestBody: req.body, - }, - options - ); - queue.push(payload); - if (queue.length >= bufferLength) doSend(); - - cleanup(); // eslint-disable-line @typescript-eslint/no-use-before-define - } - - function cleanup() { - res.removeListener('finish', startSend); - res.removeListener('error', cleanup); - res.removeListener('close', cleanup); - } - - // Add response listeners - res.once('finish', startSend); - res.once('error', cleanup); - res.once('close', cleanup); - - return next(); - }; -} diff --git a/packages/node/src/lib/get-project-base-url.ts b/packages/node/src/lib/get-project-base-url.ts deleted file mode 100644 index 36fcb60351..0000000000 --- a/packages/node/src/lib/get-project-base-url.ts +++ /dev/null @@ -1,69 +0,0 @@ -import crypto from 'crypto'; - -import findCacheDir from 'find-cache-dir'; -import flatCache from 'flat-cache'; -import fetch from 'node-fetch'; -import timeoutSignal from 'timeout-signal'; - -import pkg from '../../package.json'; -import config from '../config'; - -export async function getProjectBaseUrl(encodedApiKey, requestTimeout) { - const cacheDir = findCacheDir({ name: pkg.name, create: true }); - const fsSafeApikey = crypto.createHash('md5').update(encodedApiKey).digest('hex'); - - // Since we might have differences of cache management, set the package version into the cache key so all caches will - // automatically get refreshed when the package is updated/installed. - const cacheKey = `${pkg.name}-${pkg.version}-${fsSafeApikey}`; - - const cache = flatCache.load(cacheKey, cacheDir); - - // Does the cache exist? If it doesn't, let's fill it. If it does, let's see if it's stale. Caches should have a TTL - // of 1 day. - const lastUpdated = cache.getKey('lastUpdated'); - - if ( - lastUpdated === undefined || - (lastUpdated !== undefined && Math.abs(lastUpdated - Math.round(Date.now() / 1000)) >= 86400) - ) { - const signal = timeoutSignal(requestTimeout); - - let baseUrl; - await fetch(`${config.readmeApiUrl}/v1/`, { - method: 'get', - headers: { - Authorization: `Basic ${encodedApiKey}`, - 'User-Agent': `${pkg.name}/${pkg.version}`, - }, - signal, - }) - .then(res => { - if (res.status >= 400 && res.status <= 599) { - throw res; - } - - return res.json(); - }) - .then(project => { - baseUrl = project.baseUrl; - - cache.setKey('baseUrl', project.baseUrl); - cache.setKey('lastUpdated', Math.round(Date.now() / 1000)); - }) - .catch(() => { - // If unable to access the ReadMe API for whatever reason, let's set the last updated time to two minutes from - // now yesterday so that in 2 minutes we'll automatically make another attempt. - cache.setKey('baseUrl', null); - cache.setKey('lastUpdated', Math.round(Date.now() / 1000) - 86400 + 120); - }) - .finally(() => { - timeoutSignal.clear(signal); - }); - - cache.save(); - - return baseUrl; - } - - return cache.getKey('baseUrl'); -} diff --git a/packages/node/src/lib/log.ts b/packages/node/src/lib/log.ts new file mode 100644 index 0000000000..21594375ce --- /dev/null +++ b/packages/node/src/lib/log.ts @@ -0,0 +1,156 @@ +import type { LogOptions } from './construct-payload'; +import type { GroupingObject, OutgoingLogBody } from './metrics-log'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import * as url from 'url'; + +import clamp from 'lodash/clamp'; +import { v4 as uuidv4 } from 'uuid'; + +import config from '../config'; + +import { constructPayload } from './construct-payload'; +import { metricsAPICall } from './metrics-log'; + +let queue: OutgoingLogBody[] = []; +function doSend(readmeApiKey, options) { + // Copy the queue so we can send all the requests in one batch + const json = [...queue]; + // Clear out the queue so we don't resend any data in the future + queue = []; + + // Make the log call + metricsAPICall(readmeApiKey, json).catch(e => { + // Silently discard errors and timeouts. + if (options.development) throw e; + }); +} +// Make sure we flush the queue if the process is exited +process.on('exit', doSend); + +export interface ExtendedIncomingMessage extends IncomingMessage { + /* + * This is where most body-parsers put the parsed HTTP body + * but it is not part of Node's builtin. We expect the body + * to be parsed by the time it gets passed to us + */ + body?: Record; + // These are all express additions to Node's builtin type + route?: { + path: string; + }; + protocol?: string; + baseUrl?: string; + hostname?: string; + originalUrl?: string; +} + +interface ExtendedResponse extends ServerResponse { + _body?: string; +} + +// We're doing this to buffer up the response body +// so we can send it off to the metrics server +// It's unfortunate that this isn't accessible +// natively. This may take up lots of memory on +// big responses, we can make it configurable in future +function patchResponse(res) { + const { write, end } = res; + + res._body = ''; + + res.write = (chunk, encoding, cb) => { + res._body += chunk; + write.call(res, chunk, encoding, cb); + }; + + res.end = (chunk, encoding, cb) => { + // Chunk is optional in res.end + // http://nodejs.org/dist/latest/docs/api/http.html#http_response_end_data_encoding_callback + if (chunk) res._body += chunk; + end.call(res, chunk, encoding, cb); + }; +} + +export interface Options extends LogOptions { + bufferLength?: number; + baseLogUrl?: string; +} + +/** + * This middleware will set up Express to automatically log all API requests to ReadMe Metrics. + * + * @param apiKey The API key for your ReadMe project. This ensures your requests end up in your dashboard. You can read more about the API key in [our docs](https://docs.readme.com/reference/authentication). + * @param group A function that helps translate incoming request data to our metrics grouping data. You can read more under [Grouping Function](#grouping-function). + * @param options Additional options. See the documentation for more details. + * @returns Your Express middleware + */ +export function log( + readmeApiKey: string, + req: ExtendedIncomingMessage, + res: ExtendedResponse, + group: GroupingObject, + options: Options = {} +) { + if (!readmeApiKey) throw new Error('You must provide your ReadMe API key'); + if (!group) throw new Error('You must provide a group'); + + // Ensures the buffer length is between 1 and 30 + const bufferLength = clamp(options.bufferLength || config.bufferLength, 1, 30); + + const baseLogUrl = options.baseLogUrl || undefined; + + const startedDateTime = new Date(); + const logId = uuidv4(); + + patchResponse(res); + + // @todo we should remove this and put this in the code samples + if (baseLogUrl !== undefined && typeof baseLogUrl === 'string') { + res.setHeader('x-documentation-url', `${baseLogUrl}/logs/${logId}`); + } + + /* + * This should in future become more sophisticated, with flush timeouts and more error checking but + * this is fine for now + */ + function startSend() { + const payload = constructPayload( + req, + res, + { + ...group, + logId, + startedDateTime, + responseEndDateTime: new Date(), + routePath: req.route + ? url.format({ + protocol: req.protocol, + host: req.hostname, + pathname: `${req.baseUrl}${req.route.path}`, + }) + : '', + responseBody: res._body, + requestBody: req.body, + }, + options + ); + + queue.push(payload); + if (queue.length >= bufferLength) doSend(readmeApiKey, options); + + cleanup(); // eslint-disable-line @typescript-eslint/no-use-before-define + } + + function cleanup() { + res.removeListener('finish', startSend); + res.removeListener('error', cleanup); + res.removeListener('close', cleanup); + } + + // Add response listeners + res.once('finish', startSend); + res.once('error', cleanup); + res.once('close', cleanup); + return logId; +} diff --git a/packages/node/src/lib/metrics-log.ts b/packages/node/src/lib/metrics-log.ts index 938ed6eb09..31269b849b 100644 --- a/packages/node/src/lib/metrics-log.ts +++ b/packages/node/src/lib/metrics-log.ts @@ -1,6 +1,4 @@ -import type { PayloadData, LogOptions } from './construct-payload'; import type { Har } from 'har-format'; -import type { ServerResponse, IncomingMessage } from 'http'; import type { Response } from 'node-fetch'; import fetch from 'node-fetch'; @@ -9,8 +7,6 @@ import timeoutSignal from 'timeout-signal'; import pkg from '../../package.json'; import config from '../config'; -import { constructPayload } from './construct-payload'; - export interface GroupingObject { /** * API Key used to make the request. Note that this is different from the `readmeAPIKey` described above and should be a value from your API that is unique to each of your users. @@ -89,27 +85,3 @@ export function metricsAPICall( }; }); } - -/** - * Log a request to the API Metrics Dashboard with the standard Node.js server data. - * - * @param readmeAPIKey The API key for your ReadMe project. This ensures your requests end up in your dashboard. You can read more about the API key in [our docs](https://docs.readme.com/reference/authentication). - * @param req A Node.js `IncomingMessage` object, usually found in your server's request handler. - * @param res A Node.js `ServerResponse` object, usually found in your server's request handler. - * @param payloadData A collection of information that will be logged alongside this request. See [Payload Data](#payload-data) for more details. - * @param logOptions Additional options. Check the documentation for more details. - * - * @returns A promise that resolves to an object containing your log ids and the server response - */ -export function log( - readmeAPIKey: string, - req: IncomingMessage, - res: ServerResponse, - payloadData: PayloadData, - logOptions: LogOptions -) { - if (!readmeAPIKey) throw new Error('You must provide your ReadMe API key'); - - const payload = constructPayload(req, res, payloadData, logOptions); - return metricsAPICall(readmeAPIKey, Array.isArray(payload) ? payload : [payload], logOptions.fireAndForget); -} diff --git a/packages/node/src/lib/process-request.ts b/packages/node/src/lib/process-request.ts index 1b54f2c1ae..8bef6a03e9 100644 --- a/packages/node/src/lib/process-request.ts +++ b/packages/node/src/lib/process-request.ts @@ -1,6 +1,6 @@ import type { LogOptions } from './construct-payload'; +import type { ExtendedIncomingMessage } from './log'; import type { Entry } from 'har-format'; -import type { IncomingMessage } from 'http'; import * as qs from 'querystring'; import url, { URL } from 'url'; @@ -122,7 +122,7 @@ function parseRequestBody(body: string, mimeType: string): Record | string, options?: LogOptions ): Entry['request'] { @@ -180,7 +180,8 @@ export default function processRequest( const host = fixHeader(req.headers['x-forwarded-host']) || req.headers.host; // We use a fake host here because we rely on the host header which could be redacted. // We only ever use this reqUrl with the fake hostname for the pathname and querystring. - const reqUrl = new URL(req.url, 'https://readme.io'); + // req.originalUrl is express specific, req.url is node.js + const reqUrl = new URL(req.originalUrl || req.url, 'https://readme.io'); const requestData = { method: req.method, diff --git a/packages/php/examples/laravel/routes/web.php b/packages/php/examples/laravel/routes/web.php index 61fcbca7f4..6a282035c5 100644 --- a/packages/php/examples/laravel/routes/web.php +++ b/packages/php/examples/laravel/routes/web.php @@ -22,6 +22,10 @@ ]); }); +Route::post('/', function () { + return response(null, 200); +}); + Route::post('/webhook', function (\Illuminate\Http\Request $request) use ($secret) { // Verify the request is legitimate and came from ReadMe. $signature = $request->headers->get('readme-signature', ''); diff --git a/packages/python/examples/flask/app.py b/packages/python/examples/flask/app.py index 950106b26e..f3433e7448 100644 --- a/packages/python/examples/flask/app.py +++ b/packages/python/examples/flask/app.py @@ -43,5 +43,13 @@ def hello_world(): ) +@app.post("/") +def post(): + return ( + "", + 200, + ) + + if __name__ == "__main__": app.run(debug=False, host="127.0.0.1", port=os.getenv("PORT", "8000"))