ActiveModel: пример для Node.js-сервера
Все предыдущие примеры завязаны на Vue/Nuxt. Здесь — минимальный пример без какого-либо фронтенд-фреймворка: HTTP API на чистом node:http, без Express и без каких-либо дополнительных зависимостей.
Задача: маленький REST-эндпоинт для задач (Task), где id и createdAt генерируются на сервере и клиент не может их подделать, даже если пришлёт их в теле запроса.
Модель
ts
// models/Task.ts
import { randomUUID } from 'node:crypto'
import { ActiveModel, ActiveField } from '@alt-point/active-models'
export class Task extends ActiveModel {
// readonly один сам по себе разрешает установить значение ОДИН РАЗ из data
// (через create()/конструктор) — то есть клиент мог бы передать id в body
// и "занять" эту единственную попытку записи. Для по-настоящему
// server-only поля добавляем fillable: false — тогда единственный
// источник значения — инициализатор поля класса ниже, data полностью
// игнорируется. Подробности — в active-model-advanced.md, раздел `readonly`.
@ActiveField({ readonly: true, fillable: false })
id: string = randomUUID()
@ActiveField({
validator (model, prop, value) {
if (typeof value !== 'string' || !value.trim()) {
throw new TypeError('"title" is required and must be a non-empty string')
}
}
})
title: string = ''
@ActiveField()
done: boolean = false
@ActiveField({ readonly: true, fillable: false })
createdAt: string = new Date().toISOString()
}HTTP-сервер
ts
// server.ts
import { createServer, type IncomingMessage } from 'node:http'
import { Task } from './models/Task'
const tasks = new Map<string, Task>()
function readJsonBody (req: IncomingMessage): Promise<any> {
return new Promise((resolve, reject) => {
let raw = ''
req.on('data', (chunk) => { raw += chunk })
req.on('end', () => {
try {
resolve(raw ? JSON.parse(raw) : {})
} catch (e) {
reject(e)
}
})
req.on('error', reject)
})
}
const server = createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json')
if (req.method === 'POST' && req.url === '/tasks') {
try {
const body = await readJsonBody(req)
// Валидатор в Task ловит НЕВЕРНЫЙ тип/значение title, но не отсутствие
// ключа целиком (он не вызывается для полей, которых нет во входных
// данных, — подробности в active-model-advanced.md). Обязательность
// поля поэтому проверяем явно, до вызова create().
if (!body || typeof body.title !== 'string' || !body.title.trim()) {
throw new TypeError('"title" is required and must be a non-empty string')
}
// Даже если клиент пришлёт { id: '...', createdAt: '...' } в body,
// это будет молча проигнорировано — оба поля readonly + fillable: false.
const task = Task.create(body)
tasks.set(task.id, task)
res.writeHead(201)
res.end(JSON.stringify(task))
} catch (e) {
res.writeHead(400)
res.end(JSON.stringify({ error: (e as Error).message }))
}
return
}
if (req.method === 'GET' && req.url === '/tasks') {
res.writeHead(200)
res.end(JSON.stringify([...tasks.values()]))
return
}
res.writeHead(404)
res.end(JSON.stringify({ error: 'Not found' }))
})
server.listen(3000, () => {
console.log('Listening on http://localhost:3000')
})Проверка
bash
curl -X POST http://localhost:3000/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Buy milk", "id": "spoofed-id", "createdAt": "2000-01-01"}'
# → 201, id и createdAt в ответе будут настоящими, сгенерированными сервером,
# а не теми, что прислал клиент
curl -X POST http://localhost:3000/tasks \
-H 'Content-Type: application/json' \
-d '{}'
# → 400 { "error": "\"title\" is required and must be a non-empty string" }Что здесь показано
readonly+fillable: falseдля полей, генерируемых сервером (id,createdAt) — надёжный способ гарантировать, что клиент не сможет подменить идентификатор или дату создания через тело запроса. Одинreadonlyдля этого недостаточен: он разрешает установить значение один раз изdataпри создании, и безfillable: falseэтой единственной попыткой мог бы воспользоваться клиент. Подробнее — в active-model-advanced.md, разделreadonly.validator, который бросает исключение при неверном значении — но не при отсутствующем ключе: валидатор не вызывается для полей, которых просто нет во входных данных, поэтому проверку "поле обязательно" делаем явно, доTask.create(body)(подробности и почему — там же, в active-model-advanced.md). Валидатор в модели остаётся полезен как защита от значений неверного типа.JSON.stringify(task)— работает "из коробки" благодаря встроенномуtoJSON(); если бы в модели былоhidden-поле (например, внутренний служебный флаг), оно бы не попало в ответ клиенту.