Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
13 changes: 13 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -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;
97 changes: 97 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
107 changes: 107 additions & 0 deletions src/validation.js
Original file line number Diff line number Diff line change
@@ -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,
};
76 changes: 76 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
@@ -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;
});