diff --git a/package.json b/package.json new file mode 100644 index 0000000..4332be2 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "veillend-backend", + "version": "1.0.0", + "description": "A lightweight backend with global validation and consistent response envelopes", + "main": "server.js", + "scripts": { + "start": "node server.js", + "test": "node test.js" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..f2920ed --- /dev/null +++ b/server.js @@ -0,0 +1,13 @@ +const http = require('http'); +const { createApp } = require('./src/app'); + +const port = Number(process.env.PORT || 3000); +const app = createApp(); + +const server = http.createServer((req, res) => app(req, res)); + +server.listen(port, '127.0.0.1', () => { + console.log(`Backend listening on http://127.0.0.1:${port}`); +}); + +module.exports = server; diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..429d757 --- /dev/null +++ b/src/app.js @@ -0,0 +1,97 @@ +const http = require('http'); +const { buildErrorEnvelope, buildSuccessEnvelope, validatePayload } = require('./validation'); + +function createApp() { + return async function handleRequest(req, res) { + const url = new URL(req.url, 'http://127.0.0.1'); + const method = req.method.toUpperCase(); + const pathname = url.pathname; + + if (method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }); + res.end(); + return; + } + + let body = {}; + + if (['POST', 'PUT', 'PATCH'].includes(method)) { + try { + body = await readJsonBody(req); + } catch (error) { + sendJson(res, 400, buildErrorEnvelope('Request body must be valid JSON.', [{ field: 'body', message: 'Invalid JSON payload.' }], 'INVALID_JSON')); + return; + } + + const issues = validatePayload(pathname, method, body); + if (issues.length > 0) { + sendJson(res, 400, buildErrorEnvelope('Request payload is invalid.', issues, 'VALIDATION_ERROR')); + return; + } + } + + const routeData = { + method, + path: pathname, + query: Object.fromEntries(url.searchParams.entries()), + received: body, + }; + + if (pathname === '/health' || pathname === '/api/health') { + sendJson(res, 200, buildSuccessEnvelope({ status: 'ok' }, { route: pathname, method })); + return; + } + + if (pathname === '/users' || pathname === '/api/users') { + sendJson(res, 200, buildSuccessEnvelope({ users: [] }, { route: pathname, method })); + return; + } + + sendJson(res, 200, buildSuccessEnvelope(routeData, { route: pathname, method })); + }; +} + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(payload)); +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('end', () => { + if (!data) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(data)); + } catch (error) { + reject(error); + } + }); + + req.on('error', reject); + }); +} + +module.exports = { + createApp, + createServer: (port = process.env.PORT || 3000) => { + const app = createApp(); + return http.createServer((req, res) => app(req, res)).listen(port); + }, +}; diff --git a/src/validation.js b/src/validation.js new file mode 100644 index 0000000..f6b2187 --- /dev/null +++ b/src/validation.js @@ -0,0 +1,107 @@ +function normalizeBody(body) { + if (body === undefined || body === null) { + return {}; + } + + if (typeof body !== 'object' || Array.isArray(body)) { + return null; + } + + return body; +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isValidEmail(value) { + return typeof value === 'string' && /.+@.+\..+/.test(value); +} + +function routeKey(route) { + return route.replace(/^\/+|\/+$/g, '').replace(/^api\//i, ''); +} + +function validatePayload(route, method, payload) { + const body = normalizeBody(payload); + + if (body === null) { + return [{ field: 'body', message: 'Request body must be a JSON object.' }]; + } + + const key = routeKey(route); + const issues = []; + + if (method !== 'GET' && method !== 'DELETE') { + if (Object.keys(body).length === 0) { + issues.push({ field: 'body', message: 'Payload must include at least one field.' }); + } + } + + if (key === 'users' || key.endsWith('/users')) { + if (!isNonEmptyString(body.name)) { + issues.push({ field: 'name', message: 'Name is required.' }); + } + + if (!isValidEmail(body.email)) { + issues.push({ field: 'email', message: 'A valid email is required.' }); + } + } + + if (key === 'auth/login' || key.endsWith('/auth/login')) { + if (!isValidEmail(body.email)) { + issues.push({ field: 'email', message: 'A valid email is required.' }); + } + + if (typeof body.password !== 'string' || body.password.length < 8) { + issues.push({ field: 'password', message: 'Password must be at least 8 characters long.' }); + } + } + + if (typeof body.email === 'string' && body.email.length > 0 && !isValidEmail(body.email)) { + issues.push({ field: 'email', message: 'Email must be a valid address.' }); + } + + if (typeof body.password === 'string' && body.password.length > 0 && body.password.length < 8) { + issues.push({ field: 'password', message: 'Password must be at least 8 characters long.' }); + } + + if (typeof body.name === 'string' && body.name.trim().length === 0) { + issues.push({ field: 'name', message: 'Name cannot be empty.' }); + } + + if (typeof body.title === 'string' && body.title.trim().length === 0) { + issues.push({ field: 'title', message: 'Title cannot be empty.' }); + } + + if (typeof body.age === 'number' && !Number.isInteger(body.age)) { + issues.push({ field: 'age', message: 'Age must be an integer.' }); + } + + return issues; +} + +function buildSuccessEnvelope(data, meta = {}) { + return { + success: true, + data, + meta, + }; +} + +function buildErrorEnvelope(message, details = [], code = 'VALIDATION_ERROR') { + return { + success: false, + error: { + message, + code, + details, + }, + }; +} + +module.exports = { + buildErrorEnvelope, + buildSuccessEnvelope, + validatePayload, +}; diff --git a/test.js b/test.js new file mode 100644 index 0000000..5b1b565 --- /dev/null +++ b/test.js @@ -0,0 +1,76 @@ +const http = require('http'); +const { spawn } = require('child_process'); + +function request(method, path, payload) { + return new Promise((resolve, reject) => { + const body = payload ? JSON.stringify(payload) : undefined; + const req = http.request({ + hostname: '127.0.0.1', + port: 3000, + path, + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + }, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve({ statusCode: res.statusCode, body: JSON.parse(data) }); + } catch (error) { + resolve({ statusCode: res.statusCode, body: data }); + } + }); + }); + + req.on('error', reject); + if (body) { + req.write(body); + } + req.end(); + }); +} + +async function waitForServer(retries = 10) { + for (let attempt = 0; attempt < retries; attempt += 1) { + try { + const response = await request('GET', '/health'); + if (response.statusCode === 200) { + return response; + } + } catch (error) { + // Retry until the server is ready. + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error('Server did not become ready in time.'); +} + +async function main() { + const server = spawn(process.execPath, ['server.js'], { cwd: process.cwd(), stdio: 'ignore' }); + + try { + await waitForServer(); + + const valid = await request('POST', '/users', { name: 'Ada', email: 'ada@example.com' }); + const invalid = await request('POST', '/users', { name: '', email: 'not-an-email' }); + const health = await request('GET', '/health'); + + console.log(JSON.stringify({ valid, invalid, health }, null, 2)); + + const invalidEnvelope = invalid.body && invalid.body.success === false && invalid.body.error && Array.isArray(invalid.body.error.details); + if (valid.statusCode !== 200 || !valid.body.success || invalid.statusCode !== 400 || !invalidEnvelope || health.statusCode !== 200 || !health.body.success) { + process.exitCode = 1; + } + } finally { + server.kill(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});