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
+7
View File
@@ -0,0 +1,7 @@
NODE_ENV=development
PORT=8765
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=my_app
DB_USER=my_app
DB_PASSWORD=
+3
View File
@@ -48,6 +48,9 @@ nbproject/*
.sass-cache/
# node
/node_modules/*
.env
.env.*
!.env.example
# yarn
yarn-debug.log
yarn-error.log
+6
View File
@@ -0,0 +1,6 @@
const path = require('node:path');
module.exports = {
config: path.resolve('node/config/database.cjs'),
'migrations-path': path.resolve('node/migrations'),
};
+51 -56
View File
@@ -1,73 +1,68 @@
# Test REST API (CakePHP 5)
# REST API: Express, Sequelize i MySQL
Testowy serwis REST API: klienci (`customers`), ich auta (`cars`) i użytkownicy (`users`),
uwierzytelnianie tokenem JWT, dokumentacja Swagger UI.
Serwis REST dla użytkowników, klientów i samochodów. Aplikacja Node.js korzysta z istniejących tabel `users`, `customers` i `cars`; aplikacja nie tworzy ani nie synchronizuje ich automatycznie przy uruchomieniu.
## Uruchomienie
## Wymagania i uruchomienie
- Node.js 20 lub nowszy
- MySQL z tabelami używanymi przez dotychczasową aplikację
```bash
composer install
bin/cake migrations migrate # tworzy tabele users, customers, cars
bin/cake seeds run DatabaseSeed # 2 konta, 100 klientów, 1-5 aut na klienta (czyści tabele!)
bin/cake server -p 8765
npm install
cp .env.example .env
```
Dokumentacja i konsola testowa: **http://localhost:8765/docs** (specyfikacja: `webroot/openapi.yaml`).
Ustaw w `.env` połączenie do tej samej bazy MySQL co dotychczasowa aplikacja. Następnie wykonaj migrację dodającą unikalne pole `login` do tabeli `users` (dotychczasowym użytkownikom ustawi `login = email`):
Konfiguracja bazy i sekretu JWT: `config/app_local.php` (`Datasources.default`, `Jwt.secret`, `Jwt.ttl`).
Sekret można też podać zmienną środowiskową `JWT_SECRET`.
```bash
npm run db:migrate
npm start
```
## Konta testowe
API działa domyślnie na `http://localhost:8765`, dokumentacja Swagger UI na `http://localhost:8765/docs`, a specyfikacja OpenAPI na `http://localhost:8765/openapi.yaml`. Port można zmienić przez `PORT`.
| Rola | E-mail | Hasło |
|-------|-------------------|-----------|
| admin | admin@example.com | Admin123! |
| user | user@example.com | User1234! |
Migrację można cofnąć przez `npx sequelize-cli db:migrate:undo`. Start serwera nigdy nie uruchamia migracji ani `sequelize.sync()`.
* **admin** – wszystko, w tym CRUD użytkowników.
* **user** – logowanie, własny profil, CRUD klientów i aut.
## Uwierzytelnianie i uprawnienia
API używa HTTP Basic Auth: podaj `login` i hasło w nagłówku `Authorization: Basic ...` albo skorzystaj z przycisku **Authorize** w Swagger UI. Loginem jest pole `users.login`; użytkownicy istniejący przed migracją zachowają dotychczasowe dane logowania, bo ich login zostanie ustawiony na adres e-mail.
HTTP Basic przesyła hasło w każdym żądaniu; w środowisku produkcyjnym wystawiaj API wyłącznie przez HTTPS.
Konta dodawane przez dotychczasowy seeder, o ile istnieją w bazie:
| Rola | Login po migracji | Hasło |
|---|---|---|
| admin | `admin@example.com` | `Admin123!` |
| user | `user@example.com` | `User1234!` |
Hasła nadal są przechowywane jako bcrypt. Nieaktywne konta nie mogą się uwierzytelniać. Dostęp do `/api/users` wymaga roli `admin`; klienci i samochody są dostępne dla każdego uwierzytelnionego użytkownika.
```bash
curl -u 'admin@example.com:Admin123!' http://localhost:8765/api/auth/me
curl -u 'user@example.com:User1234!' 'http://localhost:8765/api/customers?q=nowak&with_cars=1'
```
## Endpointy
| Metoda | Ścieżka | Opis |
|----------------------|--------------------------------------|---------------------------------------|
| POST | `/api/auth/login` | logowanie, zwraca token JWT |
| GET / PUT / PATCH | `/api/auth/me` | własny profil |
| GET / POST | `/api/customers` | lista (filtry, paginacja) / dodanie |
| GET / PUT / PATCH / DELETE | `/api/customers/{id}` | klient (GET zwraca też auta) |
| GET / POST | `/api/customers/{id}/cars` | auta klienta / dodanie auta klientowi |
| GET / POST | `/api/cars` | lista aut / dodanie |
| GET / PUT / PATCH / DELETE | `/api/cars/{id}` | auto (GET zwraca też właściciela) |
| GET / POST | `/api/users` | użytkownicy (tylko admin) |
| GET / PUT / PATCH / DELETE | `/api/users/{id}` | użytkownik (tylko admin) |
| Metody | Ścieżka | Opis |
|---|---|---|
| POST | `/api/auth/login` | Potwierdzenie logowania Basic Auth i aktualizacja `last_login` |
| GET / PUT / PATCH | `/api/auth/me` | Odczyt i edycja własnego profilu |
| GET / POST | `/api/users` | Lista i dodawanie użytkowników (admin) |
| GET / PUT / PATCH / DELETE | `/api/users/{id}` | Odczyt, edycja i usuwanie użytkownika (admin) |
| GET / POST | `/api/customers` | Lista klientów i dodawanie |
| GET / PUT / PATCH / DELETE | `/api/customers/{id}` | Szczegóły klienta z samochodami, edycja i usuwanie |
| GET / POST | `/api/customers/{customer_id}/cars` | Samochody klienta i dodawanie samochodu |
| GET / POST | `/api/cars` | Lista samochodów i dodawanie |
| GET / PUT / PATCH / DELETE | `/api/cars/{id}` | Szczegóły samochodu z właścicielem, edycja i usuwanie |
Przykład:
```bash
TOKEN=$(curl -s -X POST http://localhost:8765/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"User1234!"}' | jq -r .data.token)
curl -s "http://localhost:8765/api/customers?q=nowak&with_cars=1" -H "Authorization: Bearer $TOKEN"
```
## Pola
**Klient** – wymagane: `first_name`, `last_name`, `email` (unikalny), `phone`;
opcjonalne: `type` (`individual`/`company`, dla `company` wymagane `company_name`), `company_name`,
`tax_id` (NIP, 10 cyfr), `street`, `city`, `postal_code` (`00-000`), `country` (ISO, domyślnie `PL`),
`birth_date` (`YYYY-MM-DD`), `notes`.
**Auto** – wymagane: `customer_id`, `make`, `model`, `production_year`, `vin` (17 znaków, unikalny),
`registration_number` (unikalny), `fuel_type` (`petrol`/`diesel`/`lpg`/`hybrid`/`electric`);
opcjonalne: `color`, `engine_capacity` (cm³), `power_hp`, `transmission` (`manual`/`automatic`),
`mileage` (km), `notes`.
**Użytkownik** – wymagane: `email` (unikalny), `password` (min. 8 znaków), `first_name`, `last_name`;
opcjonalne: `role` (`admin`/`user`, domyślnie `user`), `active` (nieaktywny nie może się zalogować).
Listy obsługują `page`, `limit` (domyślnie 20, maksymalnie 100), `sort` i `direction` (`asc`/`desc`). Endpointy obsługują wyszukiwanie i filtry opisane w Swagger UI; liczba wyników jest zwracana w `X-Total-Count`.
## Format odpowiedzi
* sukces: `{"success": true, "data": ..., "meta": {...paginacja}}`
* błąd: `{"success": false, "error": {"code": 422, "message": "Validation failed.", "fields": {...}}}`
* kody: `200`, `201` (+ nagłówek `Location`), `204` (DELETE), `400`, `401`, `403`, `404`, `405`, `422`
- Sukces: `{"success": true, "data": ...}`; odpowiedź z listą zawiera także `meta`.
- Błąd walidacji: `{"success": false, "error": {"code": 422, "message": "Validation failed.", "fields": {...}}}`.
- Usunięcie: `204 No Content`.
Migracja nie modyfikuje plików PHP; można je usunąć osobno po przełączeniu i sprawdzeniu wdrożenia Node.js.
+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/);
});
+2354
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "test-rest-api",
"version": "1.0.0",
"private": true,
"description": "REST API for users, customers and cars",
"main": "node/server.js",
"scripts": {
"start": "node node/server.js",
"dev": "node --watch node/server.js",
"db:migrate": "sequelize-cli db:migrate --config node/config/database.cjs --migrations-path node/migrations",
"test": "node --test"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mysql2": "^3.14.0",
"sequelize": "^6.37.6",
"swagger-ui-express": "^5.0.1",
"yamljs": "^0.3.0"
},
"devDependencies": {
"sequelize-cli": "^6.6.2"
},
"overrides": {
"uuid": "^11.1.1"
}
}
+371 -561
View File
File diff suppressed because it is too large Load Diff