Тест на вакансию

Простой REST API на Node.js

17 октября 2024 г.
122

Инициализация проекта

Создадим директорию и перейдите в нее:
mkdir news-api
cd news-api
Инициализируем npm проект:
npm init -y

Установим необходимые зависимости:
npm install express uuid
npm install -D nodemon
npm install express-validator

Структура проекта

  • news-api/
    • package.json
    • server.js
    • routes/
      • news.js
    • controllers/
      • newsController.js
    • data/
      • news.json
    • middleware/
      • validateNews.js

Настройка package.json

Добавим скрипты в package.json:
{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  }
}

Создание сервера (server.js)

const express = require('express');
const newsRoutes = require('./routes/news');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.use('/api/news', newsRoutes);

app.get('/', (req, res) => {
  res.json({ message: 'News API is working!' });
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

Модель данных (data/news.json)

Создадим файл с начальными данными:
[
  {
    "id": "b3251072-9185-4c0d-9279-28b315f0d047",
    "title": "Новая технология в веб-разработке",
    "content": "Node.js и Express продолжают развиваться, предлагая новые возможности для разработчиков.",
    "category": "technology",
    "author": "Eugene Starodubcev",
    "published": true,
    "createdAt": "2024-09-17T12:15:46.983Z",
    "updatedAt": "2024-09-17T12:15:46.984Z"
  }
]

Контроллер (controllers/newsController.js)

const fs = require('fs').promises;
const path = require('path');

const dataPath = path.join(__dirname, '../data/news.json');

let uuidv4;
(async () => {
  const uuidModule = await import('uuid');
  uuidv4 = uuidModule.v4;
})();

const readNews = async () => {
  try {
    const data = await fs.readFile(dataPath, 'utf8');
    return JSON.parse(data);
  } catch (error) {
    return [];
  }
};

const writeNews = async (news) => {
  await fs.writeFile(dataPath, JSON.stringify(news, null, 2));
};

const getAllNews = async (req, res) => {
  try {
    let news = await readNews();
   
    const { category, author, published } = req.query;
   
    if (category) {
      news = news.filter(item => item.category === category);
    }
   
    if (author) {
      news = news.filter(item => item.author === author);
    }
   
    if (published !== undefined) {
      const isPublished = published === 'true';
      news = news.filter(item => item.published === isPublished);
    }
   
    res.json(news);
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch news' });
  }
};

const getNewsById = async (req, res) => {
  try {
    const news = await readNews();
    const newsItem = news.find(item => item.id === req.params.id);
   
    if (!newsItem) {
      return res.status(404).json({ error: 'News not found' });
    }
   
    res.json(newsItem);
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch news' });
  }
};

const createNews = async (req, res) => {
  try {
    const { title, content, category, author, published = false } = req.body;
   
    if (!title || !content || !category || !author) {
      return res.status(400).json({ error: 'All fields are required' });
    }
   
    const news = await readNews();
    const newNewsItem = {
      id: uuidv4(),
      title,
      content,
      category,
      author,
      published: Boolean(published),
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    };
   
    news.push(newNewsItem);
    await writeNews(news);
   
    res.status(201).json(newNewsItem);
  } catch (error) {
    res.status(500).json({ error: 'Failed to create news' });
  }
};

const updateNews = async (req, res) => {
  try {
    const news = await readNews();
    const newsIndex = news.findIndex(item => item.id === req.params.id);
   
    if (newsIndex === -1) {
      return res.status(404).json({ error: 'News not found' });
    }
   
    const updatedNews = {
      ...news[newsIndex],
      ...req.body,
      id: news[newsIndex].id,
      updatedAt: new Date().toISOString()
    };
   
    news[newsIndex] = updatedNews;
    await writeNews(news);
   
    res.json(updatedNews);
  } catch (error) {
    res.status(500).json({ error: 'Failed to update news' });
  }
};

const deleteNews = async (req, res) => {
  try {
    const news = await readNews();
    const newsIndex = news.findIndex(item => item.id === req.params.id);
   
    if (newsIndex === -1) {
      return res.status(404).json({ error: 'News not found' });
    }
   
    news.splice(newsIndex, 1);
    await writeNews(news);
   
    res.status(204).send();
  } catch (error) {
    res.status(500).json({ error: 'Failed to delete news' });
  }
};

module.exports = {
  getAllNews,
  getNewsById,
  createNews,
  updateNews,
  deleteNews
};

Роуты (routes/news.js)

const express = require('express');
const router = express.Router();
const {
  getAllNews,
  getNewsById,
  createNews,
  updateNews,
  deleteNews
} = require('../controllers/newsController');
const {
  validateCreateNews,
  validateUpdateNews,
  validateNewsId
} = require('../middleware/validateNews');

router.get('/', getAllNews);

router.get('/:id', validateNewsId, getNewsById);

router.post('/', validateCreateNews, createNews);

router.put('/:id', validateNewsId, validateUpdateNews, updateNews);

router.delete('/:id', validateNewsId, deleteNews);

module.exports = router;

Middleware для валидации новостей (middleware/validateNews.js)

const { body, validationResult } = require('express-validator');

const validateCreateNews = [
  body('title')
    .notEmpty()
    .withMessage('Заголовок обязателен')
    .isLength({ min: 5, max: 200 })
    .withMessage('Заголовок должен быть от 5 до 200 символов')
    .trim()
    .escape(),
 
  body('content')
    .notEmpty()
    .withMessage('Содержание обязательно')
    .isLength({ min: 10 })
    .withMessage('Содержание должно быть не менее 10 символов')
    .trim(),
 
  body('category')
    .notEmpty()
    .withMessage('Категория обязательна')
    .isIn(['technology', 'politics', 'sports', 'entertainment', 'science', 'health', 'business', 'announcement'])
    .withMessage('Неверная категория'),
 
  body('author')
    .notEmpty()
    .withMessage('Автор обязателен')
    .isLength({ min: 2, max: 50 })
    .withMessage('Имя автора должно быть от 2 до 50 символов')
    .trim()
    .escape(),
 
  body('published')
    .optional()
    .isBoolean()
    .withMessage('Поле published должно быть boolean'),
  (req, res, next) => {
    const errors = validationResult(req);
   
    if (!errors.isEmpty()) {
      return res.status(400).json({
        error: 'Validation failed',
        details: errors.array()
      });
    }
   
    next();
  }
];

const validateUpdateNews = [
  body('title')
    .optional()
    .isLength({ min: 5, max: 200 })
    .withMessage('Заголовок должен быть от 5 до 200 символов')
    .trim()
    .escape(),
 
  body('content')
    .optional()
    .isLength({ min: 10 })
    .withMessage('Содержание должно быть не менее 10 символов')
    .trim(),
 
  body('category')
    .optional()
    .isIn(['technology', 'politics', 'sports', 'entertainment', 'science', 'health', 'business', 'announcement'])
    .withMessage('Неверная категория'),
 
  body('author')
    .optional()
    .isLength({ min: 2, max: 50 })
    .withMessage('Имя автора должно быть от 2 до 50 символов')
    .trim()
    .escape(),
 
  body('published')
    .optional()
    .isBoolean()
    .withMessage('Поле published должно быть boolean'),
  (req, res, next) => {
    const errors = validationResult(req);
   
    if (!errors.isEmpty()) {
      return res.status(400).json({
        error: 'Validation failed',
        details: errors.array()
      });
    }
   
    next();
  }
];

const validateNewsId = [
  (req, res, next) => {
    const { id } = req.params;
   
    if (!id || id.trim() === '') {
      return res.status(400).json({ error: 'News ID is required' });
    }
   
    if (!/^[0-9a-fA-F-]{36}$/.test(id)) {
      return res.status(400).json({ error: 'Invalid news ID format' });
    }
   
    next();
  }
];

module.exports = {
  validateCreateNews,
  validateUpdateNews,
  validateNewsId
};

Запуск и тестирование

Запуск в режиме разработки:
npm run dev
Или запуск в production:
npm start
Получить все новости:
curl -X GET http://localhost:3000/api/news
Получить новости с фильтрацией (по категории):
curl -X GET "http://localhost:3000/api/news?category=technology"
Получить новости с фильтрацией (по автору):
curl -X GET "http://localhost:3000/api/news?author=Admin"
Получить новости с фильтрацией (опубликованные новости):
curl -X GET "http://localhost:3000/api/news?published=true"
Получить новости с фильтрацией (комбинированные фильтры):
curl -X GET "http://localhost:3000/api/news?category=technology&published=true"
Получить конкретную новость по ID:
curl -X GET http://localhost:3000/api/news/1
Создать новую новость:
curl -X POST http://localhost:3000/api/news \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Новая технология в веб-разработке",
    "content": "Node.js и Express продолжают развиваться, предлагая новые возможности для разработчиков.",
    "category": "technology",
    "author": "Eugene Starodubcev",
    "published": true
  }'
Обновить существующую новость:
curl -X PUT http://localhost:3000/api/news/1 \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Обновленный заголовок новости",
    "published": false,
    "content": "Обновленное содержание новости с дополнительной информацией."
  }'
Удалить новость:
curl -X DELETE http://localhost:3000/api/news/1
Получить информацию о API:
curl -X GET http://localhost:3000/
Поделиться: