Agent Host changes for agents/nodejs-express-rest-api-orm-swagger

This commit is contained in:
2026-10-27 19:13:51 +01:00
parent 1456606e39
commit cac3b0c27f
17 changed files with 3548 additions and 626 deletions
+35
View File
@@ -0,0 +1,35 @@
require('dotenv').config();
const fs = require('node:fs');
const path = require('node:path');
const express = require('express');
const swaggerUi = require('swagger-ui-express');
const YAML = require('yamljs');
const apiRouter = require('./routes/api');
const app = express();
const openApiPath = path.resolve(__dirname, '../webroot/openapi.yaml');
const openApiDocument = YAML.parse(fs.readFileSync(openApiPath, 'utf8'));
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.get('/openapi.yaml', (req, res) => res.sendFile(openApiPath));
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));
app.get('/', (req, res) => res.redirect('/docs'));
app.use('/api', apiRouter);
app.use((req, res) => res.status(404).json({
success: false,
error: { code: 404, message: 'Not found.' },
}));
app.use((error, req, res, next) => {
if (res.headersSent) return next(error);
if (error instanceof SyntaxError && error.status === 400 && 'body' in error) {
return res.status(400).json({ success: false, error: { code: 400, message: 'Invalid JSON body.' } });
}
console.error(error);
return res.status(500).json({ success: false, error: { code: 500, message: 'Internal server error.' } });
});
module.exports = app;
+17
View File
@@ -0,0 +1,17 @@
require('dotenv').config();
const config = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
dialect: 'mysql',
logging: false,
};
module.exports = {
development: config,
test: config,
production: config,
};
+16
View File
@@ -0,0 +1,16 @@
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize(
process.env.DB_NAME,
process.env.DB_USER,
process.env.DB_PASSWORD,
{
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
dialect: 'mysql',
logging: false,
timezone: '+00:00',
},
);
module.exports = sequelize;
+172
View File
@@ -0,0 +1,172 @@
const { Op, ValidationError, UniqueConstraintError, ForeignKeyConstraintError } = require('sequelize');
const USER_FIELDS = ['login', 'email', 'password', 'first_name', 'last_name', 'role', 'active'];
const PROFILE_FIELDS = ['email', 'password', 'first_name', 'last_name'];
const CUSTOMER_FIELDS = [
'type', 'first_name', 'last_name', 'email', 'phone', 'company_name', 'tax_id',
'street', 'city', 'postal_code', 'country', 'birth_date', 'notes',
];
const CAR_FIELDS = [
'customer_id', 'make', 'model', 'production_year', 'vin', 'registration_number',
'fuel_type', 'color', 'engine_capacity', 'power_hp', 'transmission', 'mileage', 'notes',
];
function pick(source, fields) {
source ||= {};
return Object.fromEntries(fields.filter((field) => source[field] !== undefined)
.map((field) => [field, source[field]]));
}
function cleanUser(user) {
const data = user?.toJSON ? user.toJSON() : { ...user };
delete data.password;
return data;
}
function response(res, data, status = 200, extra = {}) {
if (status === 204) return res.status(status).end();
return res.status(status).json({ success: status < 400, data, ...extra });
}
function validationResponse(res, fields) {
return res.status(422).json({
success: false,
error: { code: 422, message: 'Validation failed.', fields },
});
}
function validationFields(error) {
if (error instanceof ValidationError || error instanceof UniqueConstraintError) {
return Object.fromEntries(error.errors.map((item) => [item.path, item.message]));
}
if (error instanceof ForeignKeyConstraintError) {
return { customer_id: 'The referenced record does not exist.' };
}
return null;
}
function fail(res, error) {
const fields = validationFields(error);
if (fields) return validationResponse(res, fields);
throw error;
}
function requireFields(data, names) {
const errors = {};
for (const name of names) {
if (data[name] === undefined || data[name] === null || data[name] === '') {
errors[name] = 'This field is required.';
}
}
return errors;
}
function validateUser(data, creating) {
const errors = requireFields(data, creating ? ['login', 'email', 'password', 'first_name', 'last_name'] : []);
if (data.login !== undefined && (typeof data.login !== 'string' || !data.login.trim() || data.login.length > 255)) errors.login = 'Login is required and must be at most 255 characters.';
if (data.email !== undefined && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) || data.email.length > 255)) errors.email = 'Enter a valid email address.';
if (data.password !== undefined && (String(data.password).length < 8 || String(data.password).length > 72)) errors.password = 'Password must be between 8 and 72 characters.';
for (const field of ['first_name', 'last_name']) {
if (data[field] !== undefined && (typeof data[field] !== 'string' || !data[field].trim() || data[field].length > 100)) errors[field] = 'This field is required and must be at most 100 characters.';
}
if (data.role !== undefined && !['admin', 'user'].includes(data.role)) errors.role = 'Role must be admin or user.';
if (data.active !== undefined && typeof data.active !== 'boolean') errors.active = 'Active must be a boolean.';
return errors;
}
function validateCustomer(data, creating) {
const errors = requireFields(data, creating ? ['first_name', 'last_name', 'email', 'phone'] : []);
if (data.type !== undefined && !['individual', 'company'].includes(data.type)) errors.type = 'Type must be individual or company.';
if (data.email !== undefined && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) || data.email.length > 255)) errors.email = 'Enter a valid email address.';
for (const [field, max] of Object.entries({ first_name: 100, last_name: 100, phone: 30, company_name: 255, street: 255, city: 100, notes: 5000 })) {
const required = ['first_name', 'last_name', 'phone'].includes(field);
if (data[field] !== undefined && ((data[field] === null && required) || (data[field] !== null && (typeof data[field] !== 'string' || data[field].length > max)))) {
errors[field] = `Must be a string of at most ${max} characters.`;
}
}
if (data.phone !== undefined && !/^\+?[0-9 ()-]{6,30}$/.test(data.phone)) errors.phone = 'Invalid phone number.';
if (data.type === 'company' && (creating || data.company_name !== undefined)
&& (typeof data.company_name !== 'string' || !data.company_name.trim())) {
errors.company_name = 'Company name is required for company customers.';
}
if (data.tax_id != null && (typeof data.tax_id !== 'string' || !/^[0-9]{10}$/.test(data.tax_id))) errors.tax_id = 'Tax ID (NIP) must consist of 10 digits.';
if (data.postal_code != null && !/^[0-9]{2}-[0-9]{3}$/.test(data.postal_code)) errors.postal_code = 'Postal code must be in format 00-000.';
if (data.country !== undefined && !/^[A-Z]{2}$/.test(data.country)) errors.country = 'Country must be a two-letter uppercase code.';
if (data.birth_date != null) {
const date = typeof data.birth_date === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(data.birth_date);
const parsedDate = date
? new Date(Date.UTC(Number(date[1]), Number(date[2]) - 1, Number(date[3])))
: null;
if (!parsedDate || parsedDate.toISOString().slice(0, 10) !== data.birth_date) {
errors.birth_date = 'Birth date must be a valid date in YYYY-MM-DD format.';
}
}
return errors;
}
function validateCar(data, creating) {
const errors = requireFields(data, creating
? ['customer_id', 'make', 'model', 'production_year', 'vin', 'registration_number', 'fuel_type']
: []);
for (const field of ['make', 'model']) {
if (data[field] !== undefined && (typeof data[field] !== 'string' || !data[field].trim() || data[field].length > 50)) errors[field] = 'This field is required and must be at most 50 characters.';
}
for (const field of ['customer_id', 'production_year', 'engine_capacity', 'power_hp', 'mileage']) {
if (data[field] !== undefined && data[field] !== null && !Number.isInteger(Number(data[field]))) errors[field] = 'Must be an integer.';
}
if (data.production_year !== undefined && (Number(data.production_year) < 1900 || Number(data.production_year) > new Date().getFullYear() + 1)) errors.production_year = 'Production year is out of range.';
if (data.customer_id !== undefined && Number(data.customer_id) < 1) errors.customer_id = 'Customer ID must be positive.';
if (data.vin !== undefined && !/^[A-HJ-NPR-Z0-9]{17}$/i.test(data.vin)) errors.vin = 'VIN must have 17 characters (letters I, O, Q are not allowed).';
if (data.registration_number !== undefined && !/^[A-Z0-9 ]{4,10}$/i.test(data.registration_number)) errors.registration_number = 'Registration number must have 4-10 letters/digits.';
if (data.fuel_type !== undefined && !['petrol', 'diesel', 'lpg', 'hybrid', 'electric'].includes(data.fuel_type)) errors.fuel_type = 'Invalid fuel type.';
if (data.transmission != null && !['manual', 'automatic'].includes(data.transmission)) errors.transmission = 'Transmission must be manual or automatic.';
if (data.engine_capacity != null && (Number(data.engine_capacity) < 0 || Number(data.engine_capacity) > 10000)) errors.engine_capacity = 'Engine capacity must be between 0 and 10000.';
if (data.power_hp != null && (Number(data.power_hp) < 1 || Number(data.power_hp) > 2000)) errors.power_hp = 'Power must be between 1 and 2000.';
if (data.mileage != null && Number(data.mileage) < 0) errors.mileage = 'Mileage must not be negative.';
if (data.color != null && (typeof data.color !== 'string' || data.color.length > 30)) errors.color = 'Color must be at most 30 characters.';
if (data.notes != null && (typeof data.notes !== 'string' || data.notes.length > 5000)) errors.notes = 'Notes must be at most 5000 characters.';
if (typeof data.vin === 'string') data.vin = data.vin.toUpperCase().trim();
if (typeof data.registration_number === 'string') data.registration_number = data.registration_number.toUpperCase().replaceAll(' ', '');
return errors;
}
function pageOptions(query, sortable, defaultSort = 'id') {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, Number.parseInt(query.limit, 10) || 20));
const sort = sortable.includes(query.sort) ? query.sort : defaultSort;
const direction = String(query.direction).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
return { page, limit, sort, direction, offset: (page - 1) * limit };
}
async function list(res, model, options, include) {
const { page, limit, sort, direction, offset, ...query } = options;
const result = await model.findAndCountAll({
...query, include, distinct: true, limit, offset, order: [[sort, direction]],
});
const total = result.count;
res.set('X-Total-Count', String(total));
return response(res, result.rows, 200, {
meta: {
page, limit, total, pages: Math.ceil(total / limit),
has_next_page: page * limit < total, has_prev_page: page > 1,
sort, direction: direction.toLowerCase(),
},
});
}
function notFound(res, resource) {
return res.status(404).json({
success: false,
error: { code: 404, message: `${resource} not found.` },
});
}
function asyncRoute(handler) {
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
}
module.exports = {
USER_FIELDS, PROFILE_FIELDS, CUSTOMER_FIELDS, CAR_FIELDS, pick, cleanUser,
response, validationResponse, validationFields, fail, requireFields,
validateUser, validateCustomer, validateCar, pageOptions, list, notFound, asyncRoute, Op,
};
+46
View File
@@ -0,0 +1,46 @@
const bcrypt = require('bcryptjs');
const { User } = require('../models');
function unauthorized(res) {
res.set('WWW-Authenticate', 'Basic realm="REST API", charset="UTF-8"');
return res.status(401).json({
success: false,
error: { code: 401, message: 'Invalid login or password.' },
});
}
async function authenticate(req, res, next) {
const header = req.get('authorization') || '';
const match = /^Basic\s+([A-Za-z0-9+/]+={0,2})$/i.exec(header);
if (!match) return unauthorized(res);
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator < 1) return unauthorized(res);
const login = decoded.slice(0, separator);
const password = decoded.slice(separator + 1);
const user = await User.unscoped().findOne({
attributes: ['id', 'login', 'password', 'role', 'active', 'email', 'first_name', 'last_name', 'last_login', 'created', 'modified'],
where: { login },
});
if (!user || !user.active) return unauthorized(res);
const compatibleHash = user.password.replace(/^\$2y\$/, '$2b$');
if (!(await bcrypt.compare(password, compatibleHash))) return unauthorized(res);
req.user = user;
next();
}
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
error: { code: 403, message: 'This action requires the admin role.' },
});
}
next();
}
module.exports = { authenticate, requireAdmin };
@@ -0,0 +1,38 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
const columns = await queryInterface.describeTable('users');
if (!columns.login) {
await queryInterface.addColumn('users', 'login', {
type: Sequelize.STRING(255),
allowNull: true,
});
}
await queryInterface.sequelize.query(
"UPDATE `users` SET `login` = `email` WHERE `login` IS NULL OR `login` = ''",
);
await queryInterface.changeColumn('users', 'login', {
type: Sequelize.STRING(255),
allowNull: false,
});
const indexes = await queryInterface.showIndex('users');
if (!indexes.some((index) => index.name === 'users_login_unique')) {
await queryInterface.addIndex('users', ['login'], {
name: 'users_login_unique',
unique: true,
});
}
},
async down(queryInterface) {
const indexes = await queryInterface.showIndex('users');
if (indexes.some((index) => index.name === 'users_login_unique')) {
await queryInterface.removeIndex('users', 'users_login_unique');
}
const columns = await queryInterface.describeTable('users');
if (columns.login) await queryInterface.removeColumn('users', 'login');
},
};
+75
View File
@@ -0,0 +1,75 @@
const bcrypt = require('bcryptjs');
const { DataTypes } = require('sequelize');
const sequelize = require('../config/sequelize');
const timestamps = {
timestamps: true,
createdAt: 'created',
updatedAt: 'modified',
};
const User = sequelize.define('User', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
login: { type: DataTypes.STRING(255), allowNull: false, unique: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, validate: { isEmail: true } },
password: { type: DataTypes.STRING(255), allowNull: false },
first_name: { type: DataTypes.STRING(100), allowNull: false },
last_name: { type: DataTypes.STRING(100), allowNull: false },
role: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'user', validate: { isIn: [['admin', 'user']] } },
active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
last_login: { type: DataTypes.DATE, allowNull: true },
}, {
...timestamps,
tableName: 'users',
defaultScope: { attributes: { exclude: ['password'] } },
scopes: { withPassword: { attributes: { include: ['password'] } } },
hooks: {
beforeSave: async (user) => {
if (user.changed('password')) {
user.password = await bcrypt.hash(user.password, 10);
}
},
},
});
const Customer = sequelize.define('Customer', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
type: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'individual', validate: { isIn: [['individual', 'company']] } },
first_name: { type: DataTypes.STRING(100), allowNull: false },
last_name: { type: DataTypes.STRING(100), allowNull: false },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, validate: { isEmail: true } },
phone: { type: DataTypes.STRING(30), allowNull: false },
company_name: { type: DataTypes.STRING(255), allowNull: true },
tax_id: { type: DataTypes.STRING(20), allowNull: true },
street: { type: DataTypes.STRING(255), allowNull: true },
city: { type: DataTypes.STRING(100), allowNull: true },
postal_code: { type: DataTypes.STRING(10), allowNull: true },
country: { type: DataTypes.STRING(2), allowNull: false, defaultValue: 'PL' },
birth_date: { type: DataTypes.DATEONLY, allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
created_by: { type: DataTypes.INTEGER, allowNull: true },
}, { ...timestamps, tableName: 'customers' });
const Car = sequelize.define('Car', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
customer_id: { type: DataTypes.INTEGER, allowNull: false },
make: { type: DataTypes.STRING(50), allowNull: false },
model: { type: DataTypes.STRING(50), allowNull: false },
production_year: { type: DataTypes.INTEGER, allowNull: false },
vin: { type: DataTypes.STRING(17), allowNull: false, unique: true },
registration_number: { type: DataTypes.STRING(10), allowNull: false, unique: true },
fuel_type: { type: DataTypes.STRING(20), allowNull: false, validate: { isIn: [['petrol', 'diesel', 'lpg', 'hybrid', 'electric']] } },
color: { type: DataTypes.STRING(30), allowNull: true },
engine_capacity: { type: DataTypes.INTEGER, allowNull: true },
power_hp: { type: DataTypes.INTEGER, allowNull: true },
transmission: { type: DataTypes.STRING(20), allowNull: true, validate: { isIn: [['manual', 'automatic']] } },
mileage: { type: DataTypes.INTEGER, allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
}, { ...timestamps, tableName: 'cars' });
User.hasMany(Customer, { foreignKey: 'created_by', as: 'createdCustomers' });
Customer.belongsTo(User, { foreignKey: 'created_by', as: 'creator' });
Customer.hasMany(Car, { foreignKey: 'customer_id', as: 'cars', onDelete: 'CASCADE' });
Car.belongsTo(Customer, { foreignKey: 'customer_id', as: 'customer' });
module.exports = { sequelize, User, Customer, Car };
+202
View File
@@ -0,0 +1,202 @@
const express = require('express');
const { Op } = require('sequelize');
const { User, Customer, Car } = require('../models');
const { authenticate, requireAdmin } = require('../middleware/auth');
const api = require('../lib/api');
const router = express.Router();
const route = api.asyncRoute;
router.use(route(authenticate));
router.post('/auth/login', route(async (req, res) => {
await req.user.update({ last_login: new Date() });
return api.response(res, api.cleanUser(req.user));
}));
router.route('/auth/me')
.get((req, res) => api.response(res, api.cleanUser(req.user)))
.put(route((req, res) => updateProfile(req, res)))
.patch(route((req, res) => updateProfile(req, res)));
async function updateProfile(req, res) {
const data = api.pick(req.body, api.PROFILE_FIELDS);
const errors = api.validateUser(data, false);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
await req.user.update(data);
return api.response(res, api.cleanUser(req.user));
}
router.route('/users')
.all(requireAdmin)
.get(route(async (req, res) => {
const options = api.pageOptions(req.query, ['id', ...api.USER_FIELDS.filter((field) => field !== 'password'), 'last_login', 'created', 'modified']);
const where = {};
if (req.query.q) where[Op.or] = ['login', 'email', 'first_name', 'last_name'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
if (req.query.role) where.role = req.query.role;
if (req.query.active !== undefined) where.active = req.query.active === 'true' || req.query.active === '1';
const result = await User.findAndCountAll({ where, limit: options.limit, offset: options.offset, order: [[options.sort, options.direction]] });
const total = result.count;
res.set('X-Total-Count', String(total));
return api.response(res, result.rows.map(api.cleanUser), 200, {
meta: { page: options.page, limit: options.limit, total, pages: Math.ceil(total / options.limit), has_next_page: options.page * options.limit < total, has_prev_page: options.page > 1, sort: options.sort, direction: options.direction.toLowerCase() },
});
}))
.post(route(async (req, res) => {
const data = api.pick(req.body, api.USER_FIELDS);
if (data.active === undefined) data.active = true;
if (data.role === undefined) data.role = 'user';
const errors = api.validateUser(data, true);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
const user = await User.create(data);
res.location(`/api/users/${user.id}`);
return api.response(res, api.cleanUser(user), 201);
} catch (error) { return api.fail(res, error); }
}));
router.route('/users/:id')
.all(requireAdmin)
.get(route(async (req, res) => {
const user = await User.findByPk(req.params.id);
return user ? api.response(res, api.cleanUser(user)) : api.notFound(res, 'User');
}))
.put(route((req, res) => updateUser(req, res)))
.patch(route((req, res) => updateUser(req, res)))
.delete(route(async (req, res) => {
const user = await User.findByPk(req.params.id);
if (!user) return api.notFound(res, 'User');
if (user.id === req.user.id) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot delete your own account.' } });
await user.destroy();
return api.response(res, null, 204);
}));
async function updateUser(req, res) {
const user = await User.findByPk(req.params.id);
if (!user) return api.notFound(res, 'User');
const data = api.pick(req.body, api.USER_FIELDS);
if (user.id === req.user.id) {
if (data.role !== undefined && data.role !== user.role) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot change your own role.' } });
if (data.active === false) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot deactivate your own account.' } });
}
const errors = api.validateUser(data, false);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
await user.update(data);
return api.response(res, api.cleanUser(user));
} catch (error) { return api.fail(res, error); }
}
router.route('/customers')
.get(route(async (req, res) => {
const options = api.pageOptions(req.query, ['id', 'type', 'first_name', 'last_name', 'email', 'company_name', 'city', 'country', 'created', 'modified']);
const where = {};
if (req.query.q) where[Op.or] = ['first_name', 'last_name', 'email', 'phone', 'company_name', 'city', 'tax_id'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
for (const field of ['type', 'city', 'country']) if (req.query[field]) where[field] = req.query[field];
const include = String(req.query.with_cars).toLowerCase() === 'true' || req.query.with_cars === '1' ? [{ model: Car, as: 'cars' }] : undefined;
return api.list(res, Customer, { ...options, where }, include);
}))
.post(route(async (req, res) => {
const data = api.pick(req.body, api.CUSTOMER_FIELDS);
if (data.type === undefined) data.type = 'individual';
if (data.country === undefined) data.country = 'PL';
const errors = api.validateCustomer(data, true);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
const customer = await Customer.create({ ...data, created_by: req.user.id });
res.location(`/api/customers/${customer.id}`);
return api.response(res, customer, 201);
} catch (error) { return api.fail(res, error); }
}));
router.route('/customers/:id')
.get(route(async (req, res) => {
const customer = await Customer.findByPk(req.params.id, { include: [{ model: Car, as: 'cars' }] });
return customer ? api.response(res, customer) : api.notFound(res, 'Customer');
}))
.put(route((req, res) => updateCustomer(req, res)))
.patch(route((req, res) => updateCustomer(req, res)))
.delete(route(async (req, res) => {
const customer = await Customer.findByPk(req.params.id);
if (!customer) return api.notFound(res, 'Customer');
await customer.destroy();
return api.response(res, null, 204);
}));
async function updateCustomer(req, res) {
const customer = await Customer.findByPk(req.params.id);
if (!customer) return api.notFound(res, 'Customer');
const data = api.pick(req.body, api.CUSTOMER_FIELDS);
const errors = api.validateCustomer(data, false);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
await customer.update(data);
return api.response(res, customer);
} catch (error) { return api.fail(res, error); }
}
router.route('/customers/:customer_id/cars')
.get(route(async (req, res) => {
const customer = await Customer.findByPk(req.params.customer_id);
if (!customer) return api.notFound(res, 'Customer');
const options = api.pageOptions(req.query, ['id', 'customer_id', 'make', 'model', 'production_year', 'registration_number', 'fuel_type', 'mileage', 'power_hp', 'created', 'modified']);
return api.list(res, Car, { ...options, where: { customer_id: customer.id } });
}))
.post(route(async (req, res) => {
const customer = await Customer.findByPk(req.params.customer_id);
if (!customer) return api.notFound(res, 'Customer');
return createCar(req, res, customer.id);
}));
router.route('/cars')
.get(route(async (req, res) => {
const options = api.pageOptions(req.query, ['id', 'customer_id', 'make', 'model', 'production_year', 'registration_number', 'fuel_type', 'mileage', 'power_hp', 'created', 'modified']);
const where = {};
if (req.query.customer_id) where.customer_id = req.query.customer_id;
if (req.query.q) where[Op.or] = ['make', 'model', 'vin', 'registration_number'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
for (const field of ['make', 'fuel_type', 'transmission']) if (req.query[field]) where[field] = req.query[field];
if (req.query.year_from) where.production_year = { ...(where.production_year || {}), [Op.gte]: Number(req.query.year_from) };
if (req.query.year_to) where.production_year = { ...(where.production_year || {}), [Op.lte]: Number(req.query.year_to) };
const include = String(req.query.with_customer).toLowerCase() === 'true' || req.query.with_customer === '1' ? [{ model: Customer, as: 'customer' }] : undefined;
return api.list(res, Car, { ...options, where }, include);
}))
.post(route((req, res) => createCar(req, res)));
async function createCar(req, res, customerId) {
const data = api.pick(req.body, api.CAR_FIELDS);
if (customerId !== undefined) data.customer_id = customerId;
const errors = api.validateCar(data, true);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
const car = await Car.create(data);
res.location(`/api/cars/${car.id}`);
return api.response(res, car, 201);
} catch (error) { return api.fail(res, error); }
}
router.route('/cars/:id')
.get(route(async (req, res) => {
const car = await Car.findByPk(req.params.id, { include: [{ model: Customer, as: 'customer' }] });
return car ? api.response(res, car) : api.notFound(res, 'Car');
}))
.put(route((req, res) => updateCar(req, res)))
.patch(route((req, res) => updateCar(req, res)))
.delete(route(async (req, res) => {
const car = await Car.findByPk(req.params.id);
if (!car) return api.notFound(res, 'Car');
await car.destroy();
return api.response(res, null, 204);
}));
async function updateCar(req, res) {
const car = await Car.findByPk(req.params.id);
if (!car) return api.notFound(res, 'Car');
const data = api.pick(req.body, api.CAR_FIELDS);
const errors = api.validateCar(data, false);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
await car.update(data);
return api.response(res, car);
} catch (error) { return api.fail(res, error); }
}
module.exports = router;
+27
View File
@@ -0,0 +1,27 @@
require('dotenv').config();
const app = require('./app');
const { sequelize } = require('./models');
const port = Number(process.env.PORT || 8765);
async function start() {
await sequelize.authenticate();
const server = app.listen(port, () => {
console.log(`REST API listening on http://localhost:${port}`);
});
const shutdown = async () => {
server.close(async () => {
await sequelize.close();
process.exit(0);
});
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
start().catch((error) => {
console.error('Unable to connect to the database:', error.message);
process.exitCode = 1;
});
+91
View File
@@ -0,0 +1,91 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const loginMigration = require('../migrations/20261027000000-add-login-to-users');
const {
validateUser,
validateCustomer,
validateCar,
} = require('../lib/api');
const app = require('../app');
test('user validation requires a login and an eight-character password', () => {
assert.deepEqual(validateUser({
login: 'jan',
email: 'jan@example.com',
password: 'Haslo123!',
first_name: 'Jan',
last_name: 'Kowalski',
}, true), {});
assert.equal(validateUser({ password: 'short' }, false).password, 'Password must be between 8 and 72 characters.');
});
test('customer validation enforces company name, phone and Polish postal code formats', () => {
const errors = validateCustomer({
type: 'company',
first_name: 'Jan',
last_name: 'Kowalski',
email: 'jan@example.com',
phone: '123',
postal_code: '12345',
birth_date: '2024-99-99',
}, true);
assert.ok(errors.company_name);
assert.ok(errors.phone);
assert.ok(errors.postal_code);
assert.ok(errors.birth_date);
assert.equal(validateCustomer({ first_name: null }, false).first_name, 'Must be a string of at most 100 characters.');
});
test('car validation normalizes identifiers and rejects invalid VINs', () => {
const data = {
customer_id: '1',
make: 'Toyota',
model: 'Corolla',
production_year: 2020,
vin: '1HGCM82633A004352',
registration_number: 'wa 12345',
fuel_type: 'petrol',
};
assert.deepEqual(validateCar(data, true), {});
assert.equal(data.vin, '1HGCM82633A004352');
assert.equal(data.registration_number, 'WA12345');
assert.ok(validateCar({ vin: 'INVALID' }, false).vin);
});
test('login migration backfills existing users and adds a unique login index', async () => {
const calls = [];
const queryInterface = {
describeTable: async () => ({ email: {} }),
addColumn: async (...args) => calls.push(['addColumn', ...args]),
sequelize: { query: async (...args) => calls.push(['query', ...args]) },
changeColumn: async (...args) => calls.push(['changeColumn', ...args]),
showIndex: async () => [],
addIndex: async (...args) => calls.push(['addIndex', ...args]),
};
await loginMigration.up(queryInterface, { STRING: (length) => `STRING(${length})` });
assert.deepEqual(calls.map(([name]) => name), ['addColumn', 'query', 'changeColumn', 'addIndex']);
assert.match(calls[1][1], /SET `login` = `email`/);
assert.equal(calls[3][3].unique, true);
});
test('HTTP app exposes OpenAPI docs and challenges unauthenticated API requests', async (t) => {
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
}));
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const docs = await fetch(`${baseUrl}/docs`);
assert.equal(docs.status, 200);
assert.match(await docs.text(), /swagger-ui/);
const spec = await fetch(`${baseUrl}/openapi.yaml`);
assert.equal(spec.status, 200);
assert.match(await spec.text(), /scheme: basic/);
const protectedResponse = await fetch(`${baseUrl}/api/auth/me`);
assert.equal(protectedResponse.status, 401);
assert.match(protectedResponse.headers.get('www-authenticate'), /^Basic/);
});