Node.js: разрабатываем сервер для тестирования API

от автора

Привет, друзья!

В этом небольшом туториале я хочу показать вам, как разработать простой, но довольно-таки полноценный сервер для тестирования API.

Основной функционал нашего приложения будет следующим:

  • админка с возможностью добавления данных (далее — проекты) путем их набора (ввода) или копирования/вставки, либо путем загрузки файла;
  • сохранение проектов на сервере;
  • безопасная запись, чтение и удаление файлов на любом уровне вложенности;
  • получение названий существующих проектов и их отображение в админке;
  • возможность редактирования и удаления проектов;
  • унифицированная обработка GET, POST, PUT и DELETE запросов к любому существующему проекту, включая GET-запросы, содержащие параметры и строки запроса;
  • обработка специальных параметров строки запроса sort, order, limit и offset;
  • и многое другое.

Наша админка будет выглядеть так:

Для быстрой стилизации приложения будет использоваться Bootstrap.

Исходный код проекта.

Разумеется, с приложением, которое мы с разработаем, сразу в продакшн не пойдешь, но при необходимости довести его до производственного уровня не составит труда.

При разработке приложения мы будет придерживаться 2 важных условий:

  • формат данных — JSON;
  • основная форма данных — массив.

Обратите внимание: статья рассчитана, преимущественно, на начинающих разработчиков, хотя, смею надеяться, что и опытные найдут в ней что-нибудь интересное для себя.

Вы готовы? Тогда вперед.

Подготовка проекта

Создаем директорию для проекта, переходим в нее, инициализируем проект и устанавливаем зависимости:

mkdir mock-api cd !$  yarn init -y # or npm init -y  yarn add express multer nodemon open-cli very-simple-fetch # or npm i ...

Зависимости:

  • expressNode.js-фреймворк для разработки сервера
  • multer — обертка над busboy, утилита для обработки данных в формате multipart/form-data, часто используемая для сохранения файлов
  • nodemon — утилита для запуска сервера для разработки
  • open-cli — утилита для автоматического открытия вкладки браузера по указанному адресу
  • very-simple-fetch — обертка над Fetch API, упрощающая работу с названным интерфейсом

Открываем package.json, определяем в нем основной файл сервера (index.js) как модуль и команду для запуска сервера для разработки:

{  "type": "module",  "scripts": {    "dev": "open-cli http://localhost:5000 && nodemon index.js"  } }

Команда dev указывает открыть вкладку браузера по адресу http://localhost:5000 (адрес, на котором будет запущен сервер) и выполнить код в файле index.js (запустить сервер для разработки).

Структура нашего проекта будет следующей:

  • projects — директория для проектов
  • public — директория со статическими файлами для админки
  • routes — директория для роутов
  • index.js — основной файл сервера
  • utils.js — вспомогательные функции

Пожалуй, проект готов к разработке. Не будем откладывать на завтра то, что можно отложить на послезавтра сделать сейчас.

Сервер, маршрутизатор для проектов и утилиты

В файле index.js мы делаем следующее:

  • импортируем express, полный путь к текущей (рабочей) директории и роуты для проектов;
  • создаем экземпляр Express-приложения;
  • добавляем посредников (промежуточных обработчиков): для обслуживания статических файлов, для разбора (парсинга) данных в JSON, для декодирования URL;
  • добавляем роут для получения файлов из директории node_modules;
  • добавляем роуты для проектов;
  • добавляем обработчик ошибок;
  • определяем порт и запускаем сервер.

import express from 'express' import { __dirname } from './utils.js' import projectRoutes from './routes/project.routes.js'  const app = express()  app.use(express.static('public')) app.use(express.json()) app.use(express.urlencoded({ extended: true }))  app.get('/node_modules/*', (req, res) => {  res.sendFile(`${__dirname}/${req.url}`) })  app.use('/project', projectRoutes)  // обратите внимание: обработчик ошибок должен быть последним в цепочке посредников app.use((err, req, res, next) => {  console.error(err.message || err)  res.sendStatus(err.status || 500) })  const PORT = process.env.PORT || 5000 app.listen(PORT, () => {  console.log(`? -> ${PORT}`) })

Подумаем о том, какие роуты нам нужны для работы с проектами. Как насчет следующих запросов:

  • GET — получение названий всех существующих проектов
  • GET — получение проекта по названию
  • POST — создание проекта
  • POST — загрузка проекта
  • DELETE — удаление проекта

Мы также могли бы определить отдельный роут для обновления проекта через PUT-запрос, но в этом нет особого смысла — проще перезаписать существующий проект новым.

В файле routes/project.routes.js мы делаем следующее:

  • импортируем роутер из express и вспомогательные функции из utils.js;
  • экспортируем новый экземпляр роутера;
  • определяем обработчики для каждого из указанных выше запроса.

import { Router } from 'express' // мы подробно рассмотрим каждую из этих утилит далее import {  getFileNames,  createFile,  readFile,  removeFile,  uploadFile } from '../utils.js'  export default Router()

Далее цепочкой (один за другим) идут обработчики.

Получение проекта по названию:

 .get('/', async (req, res, next) => {    // извлекаем название проекта из строки запроса - `?project_name=todos`    const { project_name } = req.query     // если `URL` не содержит строки запроса, значит,    // это запрос на получение названий всех проектов    // передаем управление следующему обработчику    if (!project_name) return next()     try {      // получаем проект      const project = await readFile(project_name)       // и возвращаем его      res.status(200).json(project)    } catch (e) {      // передаем ошибку обработчику ошибок      next(e)    }  })

Получение названий всех проектов:

 .get('/', async (req, res, next) => {    try {      // получаем названия проектов      const projects = (await getFileNames()) || []       // и возвращаем их      res.status(200).json(projects)    } catch (e) {      next(e)    }  })

Создание проекта:

 .post('/create', async (req, res, next) => {    // извлекаем название проекта и данные для него из тела запроса    const { project_name, project_data } = req.body     try {      // создаем проект      await createFile(project_data, project_name)       // сообщаем об успешном создании проекта      res.status(201).json({ message: `Project "${project_name}" created` })    } catch (e) {      next(e)    }  })

Загрузка проекта:

 .post(    '/upload',    // `multer`; обратите внимание на передаваемый ему аргумент -    // название поля, содержащего данные, в теле запроса должно соответствовать этому значению    uploadFile.single('project_data_upload'),    (req, res, next) => {      // сообщаем об успешной загрузке проекта      res.status(201).json({        message: `Project "${req.body.project_name}" uploaded`      })    }  )

Удаление проекта:

 .delete('/', async (req, res, next) => {    // извлекаем название проекта из строки запроса    const { project_name } = req.query     try {      // удаляем проект      await removeFile(project_name)       // сообщаем об успехе      res.status(201).json({ message: `Project "${project_name}" removed` })    } catch (e) {      next(e)    }  })

Ошибка, возникшая в процессе выполнения любой операции, будет передана в обработчик, определенный в index.js — централизованная обработка ошибок. Нечто похожее мы реализуем и на клиенте.

Вспомогательные функции, определенные в файле utils.js — пожалуй, самая интересная и полезная часть туториала. Я постарался сделать так, чтобы эти функции были максимально универсальными с той целью, чтобы вы могли использовать их в своих проектах без существенных изменений.

Начнем с импорта модулей, определения полного (абсолютного) пути к текущей директории и директории для проектов (корневой директории), а также с создания 2 небольших утилит:

  • для определения того, что файла или директории не существует по сообщению об ошибке;
  • для уменьшения пути на 1 «единицу».

import { dirname } from 'path' import { fileURLToPath } from 'url' import { promises as fs } from 'fs' import multer from 'multer'  // полный путь к текущей директории export const __dirname = dirname(fileURLToPath(import.meta.url)) // путь к директории с проектами const ROOT_PATH = `${__dirname}/projects`  // утилита для определения несуществующего файла const notExist = (e) => e.code === 'ENOENT' // утилита для уменьшения пути на единицу // например, путь `path/to/file` после вызова этой функции // будет иметь значение `path/to` const truncPath = (p) => p.split('/').slice(0, -1).join('/')

Утилита для создания файла:

// функция принимает 3 параметра: данные, путь и расширение (по умолчанию `json`) export async function createFile(fileData, filePath, fileExt = 'json') {  // формируем полный путь к файлу  const fileName = `${ROOT_PATH}/${filePath}.${fileExt}`   try {    // пробуем создать файл    // при отсутствии директории для файла, например, когда полным путем    // файла является `.../data/todos.json`, выбрасывается исключение    if (fileExt === 'json') {      await fs.writeFile(fileName, JSON.stringify(fileData, null, 2))    } else {      await fs.writeFile(fileName, fileData)    }  } catch (err) {    // если ошибка связана с отсутствующей директорией    if (notExist(err)) {      // создаем ее рекурсивно (несколько уровней вложенности)      await fs.mkdir(truncPath(`${ROOT_PATH}/${filePath}`), {        recursive: true      })      // и снова вызываем `createFile` с теми же параметрами - рекурсия      return createFile(fileData, filePath, fileExt)    }    // если ошибка не связана с отсутствующей директорией    // это позволяет подняться из утилиты в роут и передать ошибку в централизованный обработчик    throw err  } }

Утилита для чтения файла:

// функция принимает путь и расширение export async function readFile(filePath, fileExt = 'json') {  // полный путь  const fileName = `${ROOT_PATH}/${filePath}.${fileExt}`   // переменная для обработчика файла  let fileHandler = null  try {    // `fs.open()` возвращает обработчик файла при наличии файла    // или выбрасывает исключение при отсутствии файла    // это является рекомендуемым способом определения наличия файла    fileHandler = await fs.open(fileName)     // читаем содержимое файла    const fileContent = await fileHandler.readFile('utf-8')     // и возвращаем его    return fileExt === 'json' ? JSON.parse(fileContent) : fileContent  } catch (err) {    // если файл отсутствует    // вы поймете почему мы используем именно такую сигнатуру ошибки,    // когда мы перейдем к роутам для `API`    if (notExist(err)) {      throw { status: 404, message: 'Not found' }    }    // если возникла другая ошибка    throw err  } finally {    // закрываем обработчик файла    fileHandler?.close()  } }

Утилита для удаления файла:

// функция принимает путь и расширение export async function removeFile(filePath, fileExt = 'json') {  // полный путь  const fileName = `${ROOT_PATH}/${filePath}.${fileExt}`   try {    // пробуем удалить файл    await fs.unlink(fileName)     // нам также необходимо удалить директорию, если таковая имеется    // мы передаем утилите путь, сокращенный на единицу, т.е. без учета пути файла    await removeDir(truncPath(`${ROOT_PATH}/${filePath}`))  } catch (err) {    // если файл отсутствует    if (notExist(err)) {      throw { status: 404, message: 'Not found' }    }    // если возникла другая ошибка    throw err  } }

Утилита для удаления директории:

// утилита принимает путь к удаляемой директории и путь к корневой директории, // который по умолчанию имеет значение директории с проектами async function removeDir(dirPath, rootPath = ROOT_PATH) {  // останавливаемся, если достигли корневой директории  if (dirPath === rootPath) return   // определяем является ли директория пустой  // длина ее содержимого должна равняться 0  const isEmpty = (await fs.readdir(dirPath)).length < 1   // если директория является пустой  if (isEmpty) {    // удаляем ее    await fs.rmdir(dirPath)     // и... рекурсия    // на каждой итерации мы сокращаем путь на единицу,    // пока не поднимемся до корневой директории    removeDir(truncPath(dirPath), rootPath)  } }

Еще одна рекурсивная функции (обещаю, что последняя) для получения названий всех существующих проектов:

// функция принимает путь к корневой директории export async function getFileNames(path = ROOT_PATH) {  // переменная для названий проектов  let fileNames = []   try {    // читаем содержимое директории    const files = await fs.readdir(path)     // если в директории находится только один файл    // возвращаем массив с названиями проектов    if (files.length < 1) return fileNames     // иначе перебираем файлы    for (let file of files) {      // формируем путь каждого файла      file = `${path}/${file}`       // определяем, является ли файл директорией      const isDir = (await fs.stat(file)).isDirectory()       // если является      if (isDir) {        // прибегаем к рекурсии        fileNames = fileNames.concat(await getFileNames(file))      } else {        // если не является, добавляем его путь в массив        fileNames.push(file)      }    }     return fileNames  } catch (err) {    if (notExist(err)) {      throw { status: 404, message: 'Not found' }    }    throw err  } }

Последняя функция, которая нужна нам для работы с проектами — это функция для загрузки файлов. Данная функция не является такой универсальной, как предыдущие. Она предназначена для обработки данных в формате multipart/form-data, содержащими вполне определенные поля:

// создаем посредника для загрузки файлов export const uploadFile = multer({  storage: multer.diskStorage({    // пункт назначения - директория для файлов    destination: (req, file, cb) => {      // важно: последняя часть названия проекта должна совпадать с названием файла      // например, если проект называется `data/todos`, то файл должен называться `todos.json`      // мы также удаляем расширение файла из пути к директории      const dirPath = `${ROOT_PATH}/${req.body.project_name.replace(        file.originalname.replace('.json', ''),        ''      )}`      // здесь мы исходим из предположения, что директория для файла отсутствует      // с существующей директорией ничего не случится      fs.mkdir(dirPath, { recursive: true }).then(() => {        cb(null, dirPath)      })    },    // название файла    filename: (_, file, cb) => {      cb(null, file.originalname)    }  }) })

Все, что нам осталось сделать для работы с проектами — это разработать клиентскую часть админки.

Клиент

Разметка (public/index.html):

<head>  <!-- заголовок документа -->  <title>Mock API</title>  <!-- иконка -->  <link rel="icon" href="icon.png" />  <!-- Гугл-шрифт -->  <link rel="preconnect" href="https://fonts.googleapis.com" />  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />  <link    href="https://fonts.googleapis.com/css2?family=Montserrat&display=swap"    rel="stylesheet"  />  <!-- bootstrap -->  <link    rel="stylesheet"    href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"    integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"    crossorigin="anonymous"  />  <!-- bootstrap-icons -->  <link    rel="stylesheet"    href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.3.0/font/bootstrap-icons.css"  />  <!-- стили -->  <link rel="stylesheet" href="style.css" /> </head> <body>  <div class="container">    <header>      <h1 class="text-center my-3">Mock API</h1>    </header>     <main>      <div>        <h3 class="my-3">My projects</h3>        <!-- список существующих проектов -->        <ul class="list-group" id="project_list"></ul>      </div>       <div>        <h3 class="my-3">New project</h3>        <!-- форма для создания нового проекта -->        <form id="project_create">          <div class="mb-2">            <label for="project_name" class="form-label">Project name</label>            <!-- поле для ввода названия проекта -->            <input              type="text"              class="form-control mt-2"              name="project_name"              id="project_name"              aria-describedby="project_name"              placeholder="Project name"            />          </div>           <details class="mt-4">            <summary>Enter or paste project data</summary>            <!-- поле для ввода/вставки данных для проекта -->            <textarea              class="form-control mt-2"              name="project_data"              id="project_data_paste"              rows="10"            ></textarea>          </details>          <div class="mt-4">            <label for="project_data_upload" class="form-label"              >Upload project data</label            >            <!-- поле для загрузки файла с данными для проекта -->            <!-- принимает только JSON-файлы в единственном числе -->            <input              class="form-control mt-2"              type="file"              accept=".json"              name="project_data_upload"              id="project_data_upload"              aria-describedby="project_data_upload"            />          </div>          <!-- кнопка для создания проекта -->          <button class="btn btn-success my-4">Create project</button>        </form>      </div>    </main>  </div>   <!-- скрипт-модуль -->  <script src="script.js" type="module"></script> </body>

Обратите внимание на id элементов. Поскольку элементы с атрибутом id становятся свойствами глобального объекта window, доступ к таким элементам можно получать напрямую, т.е. без предварительного получения ссылки на элемент с помощью таких методов, как querySelector().

На стилях я останавливаться не буду: все, что мы там делаем — это определяем шрифт для всех элементов и ограничиваем максимальную ширину .container.

Переходим к public/script.js.

Импортируем very-simple-fetch, фиктивные данные, утилиту для определения того, является ли переданный аргумент JSON, и определяем базовый URL сервера:

import simpleFetch from '/node_modules/very-simple-fetch/index.js' import todos from './data/todos.js' import { isJson } from './utils.js'  simpleFetch.baseUrl = 'http://localhost:5000/project'

Фиктивные данные (public/data/todos.js):

export default [  {    id: '1',    text: 'Eat',    done: true,    edit: false  },  {    id: '2',    text: 'Code',    done: true,    edit: false  },  {    id: '3',    text: 'Sleep',    done: false,    edit: false  },  {    id: '4',    text: 'Repeat',    done: false,    edit: false  } ]

Утилита (public/utils.js):

export const isJson = (item) => {  try {    item = JSON.parse(item)  } catch (e) {    return false  }   if (typeof item === 'object' && item !== null) {    return true  }   return false }

Определяем функцию для получения названий проектов:

async function fetchProjects() {  // получаем данные и ошибку  // `customCache: false` отключает кеширование результатов  const { data, error } = await simpleFetch.get({ customCache: false })   // если при выполнении запроса возникла ошибка  if (error) {    return console.error(error)  }   // очищаем список проектов  project_list.innerHTML = ''   // если проектов нет  if (data.length < 1) {    // /*html*/ - расширение `es6-string-html` для VSCode    // включает подсветку и дополнение в шаблонных литералах    return (project_list.innerHTML = /*html*/ `      <li        class="list-group-item d-flex align-items-center"      >        You have no projects. Why don't create one?      </li>    `)  }   // форматируем список, оставляя только названия проектов  const projects = data.map((p) =>    p.replace(/.+projects\//, '').replace('.json', '')  )   // создаем элемент для каждого названия проекта  // обратите внимание на атрибуты `data-*`  for (const p of projects) {    project_list.innerHTML += /*html*/ `      <li        class="list-group-item d-flex align-items-center"        data-name="${p}"      >        <span class="flex-grow-1">          ${p}        </span>        <button          class="btn btn-outline-success"          data-action="edit"        >          <i class="bi bi-pencil"></i>        </button>        <button          class="btn btn-outline-danger"          data-action="remove"        >          <i class="bi bi-trash"></i>        </button>      </li>    `  } }

Функция для инициализации проекта с помощью фиктивных данных:

function initProject(name, data) {  project_name.value = name  project_data_paste.value = isJson(data) ? data : JSON.stringify(data, null, 2) }

Функция для инициализации обработчиков. Она включает в себя регистрацию обработчиков нажатия кнопок для редактирования и удаления проектов, а также обработчика отправки формы — создания или загрузки проекта:

function initHandlers() {  // ... }

Обработчик нажатия кнопок:

// обработка нажатия кнопок делегируется списку проектов - элементу `ul` project_list.onclick = ({ target }) => {  // получаем ссылку на кнопку  const button = target.matches('button') ? target : target.closest('button')   // получаем тип операции  const { action } = button.dataset   // получаем название проекта  const { name } = target.closest('li').dataset   if (button && action && name) {    switch (action) {      case 'edit':        // функция для редактирования проекта        return editProject(name)      case 'remove':        // функция для удаления проекта        return removeProject(name)      default:        return    }  } }

Обработчик отправки формы:

project_create.onsubmit = async (e) => {  e.preventDefault()   // проект должен иметь название  if (!project_name.value.trim()) return   // переменные для данных проекта и ответа от сервера  let data, response   // добавленный с помощью `<input type="file" />` файл  // имеет приоритет перед значением `<textarea>`  if (project_data_upload.value) {    // `multipart/form-data`    // названия полей совпадают со значениями    // атрибутов `name` элементов `input` и `textarea`    data = new FormData(project_create)     // удаляем лишнее поле    data.delete('project_data_paste')     // отправляем запрос и получаем ответ    response = await simpleFetch.post('/upload', data, {      // `multer` требует, чтобы заголовки запроса были пустыми      headers: {}    })  } else {    // получаем данные для проекта    data = project_data_paste.value.trim()    // получаем название проекта    const name = project_name.value.trim()     // если данные или название отсутствуют    if (!data || !name) return     // формируем тело запроса    const body = {      project_name: name,      project_data: isJson(data) ? JSON.parse(data) : data    }     // отправляем запрос и получаем ответ    response = await simpleFetch.post('/create', body)  }   // очищаем поля  project_name.value = ''  project_data_paste.value = ''  project_data_upload.value = ''   // вызываем обработчик ответа  // важно: для корректного обновления списка проектов  // необходимо ждать завершения обработки ответа  await handleResponse(response) }

Функция для редактирования проекта:

async function editProject(name) {  // название проекта передается на сервер в виде строки запроса  const { data, error } = await simpleFetch.get(`?project_name=${name}`)   if (error) {    return console.error(error)  }   // инициализируем проект с помощью полученных от сервера данных  initProject(name, data) }

Функция для удаления проекта:

async function removeProject(name) {  // название проекта передается на сервер в виде строки запроса  const response = await simpleFetch.remove(`?project_name=${name}`)   // вызываем обработчик ответа  await handleResponse(response) }

Функция для обработки ответа от сервера:

async function handleResponse(response) {  // извлекаем данные и ошибку из ответа  const { data, error } = response   // если при выполнении ответа возникла ошибка  if (error) {    return console.error(error)  }   // выводим в консоль сообщение об успешно выполненной операции  console.log(data.message)   // обновляем список проектов  await fetchProjects() }

Наконец, вызываем наши функции:

// получаем список существующих проектов fetchProjects() // инициализируем новый проект // initProject(название проекта, данные для проекта) initProject('todos', todos) // инициализируем обработчики событий initHandlers()

Проверим работоспособность нашего сервиса для работы с проектами.

Выполняем команду:

yarn dev # or npm run dev

Запускается сервер для разработки, открывается новая вкладка браузера по адресу http://localhost:5000:

У нас имеется название и данные для проекта. Нажимаем Create project. В директории projects появляется файл todos.json, список проектов обновляется:

Попробуем загрузить файл. Вводим название нового проекта, например, data/todos, загружаем через инпут JSON-файл из директории public/data, нажимаем Create project. В директории projects появляется директория data с файлом нового проекта, список проектов обновляется:

Нажатие кнопок для редактирования и удаления проекта также приводит к ожидаемым результатам.

Отлично, сервис для работы с проектами функционирует в штатном режиме. Но пока он не умеет отвечать на запросы со стороны. Давайте это исправим.

Роуты для API

Создаем файл api.routes.js в директории routes.

Импортируем роутер из express, утилиты из utils.js и экспортируем экземпляр роутера:

import { Router } from 'express' import { createFile, readFile, queryMap, areEqual } from '../utils.js'  export default Router()

Начнем с самого простого — POST-запроса на добавление данных в существующий проект:

.post('*', async (req, res, next) => {  try {    // получаем проект    const project = await readFile(req.url)     // создаем новый проект путем обновления существующего    const newProject = project.concat(req.body)     // сохраняем новый проект    await createFile(newProject, req.url)     // возвращаем новый проект    res.status(201).json(newProject)  } catch (e) {    next(e)  } })

DELETE-запрос на удаление данных из проекта:

// `slug` - это любой уникальный идентификатор проекта // он может называться как угодно, но, обычно, именуется как `slug` или `param` // как правило, таким идентификатором является `id` проекта // в нашем случае это также может быть текст задачи .delete('*/:slug', async (req, res, next) => {  // параметры запроса имеют вид `{ '0': '/todos', slug: '1' }`  // извлекаем путь и идентификатор  const [url, slug] = Object.values(req.params)   try {    // получаем проект    const project = await readFile(url)     // создаем новый проект путем фильтрации существующего    const newProject = project.filter(      (p) => !Object.values(p).find((v) => v === slug)    )     // если существующий и новый проекты равны, значит,    // данных для удаления не обнаружено    // небольшой хак    if (areEqual(project, newProject)) {      throw { status: 404, message: 'Not found' }    }     // создаем новый проект    await createFile(newProject, url)     // и возвращаем его    res.status(201).json(newProject)  } catch (e) {    next(e)  } })

Вот как выглядит утилита для сравнения объектов (utils.js):

export function areEqual(a, b) {  if (a === b) return true   if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()   if (!a || !b || (typeof a !== 'object' && typeof b !== 'object'))    return a === b   if (a.prototype !== b.prototype) return false   const keys = Object.keys(a)   if (keys.length !== Object.keys(b).length) return false   return keys.every((k) => areEqual(a[k], b[k])) }

PUT-запрос на обновление данных существующего проекта выглядит похоже:

.put('*/:slug', async (req, res, next) => {  const [url, slug] = Object.values(req.params)   try {    const project = await readFile(url)     // создаем новый проект путем обновления существующего    const newProject = project.map((p) => {      if (Object.values(p).find((v) => v === slug)) {        return { ...p, ...req.body }      } else return p    })     // если объекты равны...    if (areEqual(project, newProject)) {      throw { status: 404, message: 'Not found' }    }     await createFile(newProject, url)     res.status(201).json(newProject)  } catch (e) {    next(e)  } })

Что касается GET-запроса на получение проекта, то с ним все не так просто. Мы должны иметь возможность получать как проект целиком, так и отдельные данные из него. При этом в случае с отдельными данными у нас должна быть возможность получать их как с помощью уникального идентификатора (параметра) — в этом случае должен возвращаться объект, так и с помощью строки запроса — в этом случае должен возвращаться массив.

GET-запрос на получение всего проекта:

.get('*', async (req, res, next) => {  // если запрос включает `?`, значит, он содержит строку запроса  // передаем управление следующему роуту  if (req.url.includes('?')) {    return next()  }   try {    // пробуем получить проект    const project = await readFile(req.url)     // и возвращаем его    res.status(200).json(project)  } catch (e) {    // `throw { status: 404, message: 'Not found' }`    // если проект не обнаружен, возможно, мы имеем дело с запросом    // на получение уникальных данных по параметру    if (e.status === 404) {      // передаем управление следующему роуту      return next()    }     // другая ошибка    next(e)  } })

GET-запрос на получение данных по параметру или строке запроса:

.get('*/:slug', async (req, res, next) => {  let project = null   try {    // если запрос содержит строку запроса    if (req.url.includes('?')) {      // получаем проект, удаляя строку запроса      project = await readFile(req.url.replace(/\?.+/, ''))       // `req.query` - это объект вида `{ id: '1' }`, если строка запроса была `?id=1`      // нам необходимо исключить параметры, которые должны обрабатываться особым образом      // об утилите `queryMap` мы поговорим чуть позже      const notQueryKeyValues = Object.entries(req.query).filter(        ([k]) => !queryMap[k] && k !== 'order'      )       // если имеются "обычные" параметры      if (notQueryKeyValues.length > 0) {        // фильтруем данные на их основе        project = project.filter((p) =>          notQueryKeyValues.some(([k, v]) => {            if (p[k]) {              // унифицируем определение идентичности              return p[k].toString() === v.toString()            }          })        )      }       // если строка запроса содержит параметры `sort` и/или `order`      // выполняем сортировку данных      if (req.query['sort'] || req.query['order']) {        project = queryMap.sort(          project,          req.query['sort'],          req.query['order']        )      }       // если строка запроса содержит параметр `offset`      // выполняет сдвиг - пропускаем указанное количество элементов      if (req.query['offset']) {        project = queryMap.offset(project, req.query['offset'])      }       // если строка запроса содержит параметр `limit`      // возвращаем только указанное количество элементов      if (req.query['limit']) {        project = queryMap.limit(project, req.query['limit'])      }    } else {      // если запрос не содержит строки запроса      // значит, это запрос на получение уникального объекта      // получаем проект      const _project = await readFile(req.params[0])       // пытаемся найти данные по идентификатору      for (const item of _project) {        for (const key in item) {          if (item[key] === req.params.slug) {            project = item          }        }      }    }     // если данных не обнаружено    if (!project || project.length < 1) return res.sendStatus(404)     // возвращаем данные    res.status(200).json(project)  } catch (e) {    next(e)  } })

Утилита для обработки специальных параметров строки запроса выглядит следующим образом (utils.js):

// создаем экземпляры `Intl.Collator` для локализованного сравнения строк и чисел const strCollator = new Intl.Collator() const numCollator = new Intl.Collator([], { numeric: true })  export const queryMap = {  // сдвиг или пропуск указанного количества элементов  offset: (items, count) => items.slice(count),  // ограничение количества возвращаемых элементов  limit: (items, count) => items.slice(0, count),  // сортировка элементов  // по умолчанию элементы сортируются по `id` и по возрастанию  sort(items, field = 'id', order = 'asc') {    // определяем, являются ли значения поля для сортировки строками    const isString = typeof items[0][field] === 'string' && Number.isNaN(items[0][field])    // выбираем правильный экземпляр `Intl.Collator`    const collator = isString ? strCollator : numCollator    // выполняем сортировку    return items.sort((a, b) => order.toLowerCase() === 'asc'        ? collator.compare(a[field], b[field])        : collator.compare(b[field], a[field])    )  } }

Итак, у нас имеется сервис для работы с проектами и API для работы с запросами. В работоспособности сервиса мы уже убедились. Осталось проверить, что запросы к API также обрабатываются корректно.

REST Client

Тремя наиболее популярными решениями для быстрого тестирования API является следующее:

  • curl — интерфейс командной строки
  • postman или insomnia — специализированные сервисы
  • REST Client — расширение для VSCode

Я покажу, как использовать REST Client, однако запросы, которые мы сформируем, можно будет легко использовать и в других инструментах.

После установки REST Client в корневой директории проекта необходимо создать файл с расширением .http, например, test.http следующего содержания:

### ### todos ### GET http://localhost:5000/api/todos  ### GET http://localhost:5000/api/todos/2  ### GET http://localhost:5000/api/todos/5  ### GET http://localhost:5000/api/todos?text=Sleep  ### GET http://localhost:5000/api/todos?text=Test  ### GET http://localhost:5000/api/todos?done=true  ### POST http://localhost:5000/api/todos content-type: application/json  {  "id": "5",  "text": "Test",  "done": false,  "edit": false }  ### POST http://localhost:5000/api/todos content-type: application/json  [  {    "id": "6",    "text": "Test2",    "done": false,    "edit": false  },  {    "id": "7",    "text": "Test3",    "done": true,    "edit": false  } ]  ### PUT http://localhost:5000/api/todos/2 content-type: application/json  {  "text": "Test",  "done": false }  ### DELETE http://localhost:5000/api/todos/5  ### ### query ### GET http://localhost:5000/api/todos?limit=2  ### GET http://localhost:5000/api/todos?offset=2&limit=1  ### GET http://localhost:5000/api/todos?offset=3&limit=2  ### GET http://localhost:5000/api/todos?sort=id&order=desc  ### GET http://localhost:5000/api/todos?sort=title&order=desc  ### GET http://localhost:5000/api/todos?sort=text  ### GET http://localhost:5000/api/todos?sort=text&order=desc&offset=1&limit=2  ### ### data/todos ### GET http://localhost:5000/api/data/todos  ### GET http://localhost:5000/api/data/todos/2  ### GET http://localhost:5000/api/data/todos/5  ### GET http://localhost:5000/api/data/todos?text=Sleep  ### GET http://localhost:5000/api/data/todos?text=Test  ### GET http://localhost:5000/api/data/todos?done=false  ### POST http://localhost:5000/api/data/todos content-type: application/json  {  "id": "5",  "text": "Test",  "done": false,  "edit": false }  ### POST http://localhost:5000/api/data/todos content-type: application/json  [  {    "id": "6",    "text": "Test2",    "done": true,    "edit": false  },  {    "id": "7",    "text": "Test3",    "done": false,    "edit": false  } ]  ### PUT http://localhost:5000/api/data/todos/3 content-type: application/json  {  "text": "Test",  "done": true }  ### DELETE http://localhost:5000/api/data/todos/7

Здесь:

  • ### — разделитель запросов, который можно использовать для добавления комментариев
  • GET, POST и т.д. — метод запроса
  • content-type: application/json — заголовок запроса
  • [ ... ] или { ... } — тело запроса
  • заголовки и тело запроса должны разделяться пустой строкой

Над каждым запросом в VSCode имеется кнопка для выполнения запроса.

Для тестирования API необходимо создать два проекта: todos и data/todos.

Выполним парочку запросов.

GET-запрос на получение проекта todos:

GET-запрос на получение задачи с текстом Sleep из проекта todos с помощью строки запроса (такой запрос также можно выполнить с помощью параметра — GET http://localhost:5000/api/todos/Sleep):

POST-запрос на добавление новой задачи в проект todos:

GET-запрос на получение второй и третьей задач из проекта todos, отсортированных по полю text по убыванию:

И т.д.

Обратите внимание, что операции для работы с одним проектом не влияют на другие проекты.

Выполните несколько запросов самостоятельно, изучите ответы и мысленно свяжите их с роутами, реализованными в api.routes.js.

Пожалуй, это все, чем я хотел поделиться с вами в данной статье.

Буду рад любой форме обратной связи.

Благодарю за внимание и хорошего дня!


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


ссылка на оригинал статьи https://habr.com/ru/articles/583588/