diff --git a/.dockerignore b/.dockerignore index 6d68aea..eb11526 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ **/.classpath **/.dockerignore -**/.env **/.git **/.gitignore **/.project diff --git a/.env b/.env index ccd0c0e..5621709 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ PROJECT_NAME=mock-api-framework SERVER_PORT=8000 -USE_API_URL_PREFIX=api \ No newline at end of file +USE_API_URL_PREFIX=api +LOG_REQUESTS=ON +DELETE_LOGS_ON_SERVER_RESTART=ON \ No newline at end of file diff --git a/.gitignore b/.gitignore index 620bf21..71e21d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ node_modules *.log screenshots/ -videos/ \ No newline at end of file +videos/ diff --git a/README.md b/README.md index 20b6a90..7b27460 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The framework is written in TypeScript and can : - Serve persisted mock data to the database - Perform CRUD operations on the local database via a REST endpoint - Mock API error codes/messages for testing frontend error handling logic +- Log and store API requests in JSON format and display information on the localhost:8000/logs route ## Set-up @@ -64,7 +65,7 @@ A list of all endpoints can be viewed on http://localhost:8000/. The project has been set-up with demo endpoints that can be removed or modified as needed. -![alt text](image.png) +![main server page](image.png) ### Useful Commands @@ -297,6 +298,48 @@ this will return a 500 error code and the JSON response below: ``` +## Logging + +API request information and sent data can be logged and stored as JSON in the /src/logs/ folder. + +Logs can be viewed at **localhost:8000/logs**. + +![logging](logs.png) + +### Set-up + +To enable logging set the environment variables below in the .env + +```js +LOG_REQUESTS = ON; +DELETE_LOGS_ON_SERVER_RESTART = ON; +``` + +You can choose to refresh the logfile every time the server restarts or persist the data by setting the DELETE_LOGS_ON_SERVER_RESTART variable. + +To set up logging for a route, add the following to the api.ts file in the relevant handler, adjusting the request type (GET/POST/PUT/DELETE) and passing data to be logged as required: + +```js +import logger from '../../utilities/logger'; + +function handler(pathName: string) { + return [ + http.get(`/${pathName}`, ({ request }) => { + + ... + logger({ + data: { <- extracted request body or query params data here -> }, + pathName, + type: 'GET', + }); + ... + }), + ] +} +``` + +**See the src/api/bikes api.ts file for an example of logging set up.** + ## Customisation ### Changing api url prefix diff --git a/cypress/e2e/log-page-spec.cy.ts b/cypress/e2e/log-page-spec.cy.ts new file mode 100644 index 0000000..bd2a941 --- /dev/null +++ b/cypress/e2e/log-page-spec.cy.ts @@ -0,0 +1,41 @@ +describe('logging works expected information', () => { + it('log page works', () => { + cy.visit('/logs'); + cy.get('h2').contains('API Requests Made'); + + cy.get('h3').contains( + 'File can be viewed in /src/logs folder in container or local machine', + ); + cy.get('h5').contains( + "LOG_REQUESTS env var must be set to 'ON' to log requests", + ); + }); + + it('logging GET request works', () => { + cy.request('/api/bikes?type=ducati'); + cy.visit('/logs'); + cy.get('.json-container').should('exist'); + cy.get('.json-container').contains('api/bikes'); + cy.get('.json-container').contains('ducati'); + cy.get('.json-container').contains('GET'); + }); + + it('logging POST request works', () => { + cy.request('POST', '/api/bikes', { + name: 'kawasaki ninja', + type: 'kawasaki', + year: 2023, + color: 'red', + price: 20000, + }); + cy.visit('/logs'); + cy.get('.json-container').should('exist'); + cy.get('.json-container').contains('api/bikes'); + cy.get('.json-container').contains('kawasaki ninja'); + cy.get('.json-container').contains('kawasaki'); + cy.get('.json-container').contains('2023'); + cy.get('.json-container').contains('red'); + cy.get('.json-container').contains('20000'); + cy.get('.json-container').contains('POST'); + }); +}); diff --git a/logs.png b/logs.png new file mode 100644 index 0000000..ffbdb45 Binary files /dev/null and b/logs.png differ diff --git a/package-lock.json b/package-lock.json index ad49457..d939b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", "msw": "^2.7.4", + "pretty-print-json": "^3.0.4", "tsx": "^4.19.3", "typescript": "^5.8.3", "zod": "^3.24.2" @@ -9761,6 +9762,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-print-json": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pretty-print-json/-/pretty-print-json-3.0.4.tgz", + "integrity": "sha512-sVupP4x7magteGomyHFUiL8trOVVo45BP74gVo6IiRhQFp3qtmlqCdFt/Tjkim1+/Rr+P/wKl4p67d1BQ8h9bw==", + "license": "MIT" + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", diff --git a/package.json b/package.json index 2bf2910..7e8d0ee 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", "msw": "^2.7.4", + "pretty-print-json": "^3.0.4", "tsx": "^4.19.3", "typescript": "^5.8.3", "zod": "^3.24.2" diff --git a/src/api/bikes/api.ts b/src/api/bikes/api.ts index b69a9a7..da53e49 100644 --- a/src/api/bikes/api.ts +++ b/src/api/bikes/api.ts @@ -1,4 +1,5 @@ import { http, HttpResponse } from 'msw'; +import logger from '../../utilities/logger'; // Add any http handler here (get, push , delete etc., and middleware as needed) @@ -9,6 +10,14 @@ function handler(pathName: string) { const type = url.searchParams.get('type'); console.log(`starting ${pathName}`); console.log('Item Type is', type); + + // Log the request passing the request data, pathName and request type to the logger function + logger({ + data: { type }, + pathName, + type: 'GET', + }); + return HttpResponse.json({ response: `this is a GET test response from ${pathName} for bike type: ${type ?? 'none'}`, }); @@ -16,6 +25,14 @@ function handler(pathName: string) { http.post(`/${pathName}`, async ({ request }) => { // Get Body Data using json(), text() or formData() depending on what is sent const bodyData = await request.json(); + + // Log the request passing the request data, pathName and extra information to the logger function + logger({ + data: bodyData, + type: 'POST', + pathName, + }); + return HttpResponse.json({ response: `this is a POST test response from ${pathName} with bodyData ${JSON.stringify(bodyData)}`, }); diff --git a/src/logs/.gitkeep b/src/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/server.ts b/src/server.ts index ba4ed20..0c883f9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,12 +3,24 @@ import { createServer } from '@mswjs/http-middleware'; import * as seeders from './seeders/index.js'; import getApiRoutes from './utilities/file-scan.js'; import serverPage from './utilities/server-page.js'; +import logPage from './utilities/log-page.js'; +import { deleteLogs } from './utilities/logger.js'; import { env } from './utilities/env.js'; const { apiHandlers, apiRoutes } = await getApiRoutes(); -const httpServer = createServer(...apiHandlers, ...serverPage(apiRoutes)); +const httpServer = createServer( + ...apiHandlers, + ...serverPage(apiRoutes), + ...logPage(), +); +// Delete any logs on server start if the DELETE_LOGS_ON_SERVER_RESTART env var is set to 'ON' +if (process.env?.DELETE_LOGS_ON_SERVER_RESTART?.toUpperCase() === 'ON') { + deleteLogs(); +} + +// Set up the server to listen on the specified port httpServer.listen(env.SERVER_PORT); // Execute dB seeder functions diff --git a/src/utilities/log-page.ts b/src/utilities/log-page.ts new file mode 100644 index 0000000..374e2bd --- /dev/null +++ b/src/utilities/log-page.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { http, HttpResponse } from 'msw'; +import { prettyPrintJson } from 'pretty-print-json'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const createHtml = () => { + function readLogs() { + const logFolder = `${__dirname}/../logs`; + const logPath = path.join(logFolder, 'api_request_log.log'); + let logs = ''; + // Append the log entry to the log file + try { + logs = fs.readFileSync(logPath, 'utf8'); + } catch { + logs = + '{"message": "No logs found", "solution": "Set LOG_REQUESTS env var to ON and add a logger function to a route api.ts. Restart the server then retry the request"}'; + } + + return `[${logs}]`; + } + + const htmlString = ` + +
+ API Request Log +
+ +

API Requests Made

+

File can be viewed in /src/logs folder in container or local machine

+
LOG_REQUESTS env var must be set to 'ON' to log requests
+
+${prettyPrintJson.toHtml(JSON.parse(readLogs()), { indent: 4, lineNumbers: true })} +
+ + + + + `; + + return htmlString; +}; + +const logPage = () => { + return [ + http.get(`/logs`, () => { + return new HttpResponse(createHtml(), { + status: 200, + headers: { + 'Content-Type': 'text/html', + }, + }); + }), + ]; +}; + +export default logPage; diff --git a/src/utilities/logger.ts b/src/utilities/logger.ts new file mode 100644 index 0000000..a7e496d --- /dev/null +++ b/src/utilities/logger.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { type DefaultBodyType } from 'msw'; + +type LogData = { + data: DefaultBodyType; + type?: string; + pathName: string; +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Logs API requests to a file into the logs folder + * @param {DefaultBodyType} LogData.data - data sent with the request as an object E.g {id: 101, userId: 1, title: 'title', body: 'body'} + * @param {string} [LogData.type] - type of request (GET, POST, PUT, DELETE) + * @param {string} [LogData.pathName] - path of the request + */ +function logger({ + data = { error: 'no data provided' }, + type = 'GET', + pathName = 'no path provided', +}: LogData) { + const logFolder = `${__dirname}/../logs`; + const logPath = path.join(logFolder, 'api_request_log.log'); + + if (process.env?.LOG_REQUESTS?.toUpperCase() !== 'ON') return; + console.log( + `New API Request:${new Date().toLocaleString()}. Request data viewable in browser 'localhost:${process.env?.SERVER_PORT ?? '8000'}/logs' or in the 'logs/ folder'`, + ); + + // Convert the object to a string + let logEntry = `{"request_information":{"path": "${pathName}","request_type":"${type}", "request_time":"${new Date().toLocaleString()}"},"sent_data":${JSON.stringify( + data, + ) + .trim() + .replaceAll('\\n', '') + .replaceAll(/\s{2,}/g, ' ')}}`; + + if (fs.existsSync(logPath)) { + // Add separating comma if NOT the first entry to make valid JSON + logEntry = `,${logEntry}`; + } + + // Create the logs folder if it doesn't exist otherwise append to the log file + fs.writeFileSync(logPath, logEntry, { flag: 'a+' }); +} + +// Use node fs to delete the log file +export function deleteLogs() { + const logFolder = `${__dirname}/../logs`; + const logPath = path.join(logFolder, 'api_request_log.log'); + if (!fs.existsSync(logPath)) return; + fs.unlinkSync(logPath); +} + +export default logger; diff --git a/src/utilities/server-page.ts b/src/utilities/server-page.ts index 5a3994f..448d7ec 100644 --- a/src/utilities/server-page.ts +++ b/src/utilities/server-page.ts @@ -23,6 +23,8 @@ const homePage = (apiPaths: string[]) => { +

Logs URL: localhost:${process.env?.SERVER_PORT}/logs

+

* Add new api endpoints to the api folder.
For media endpoints include the media name in the url E.g /images/placeholder.png