
Привет, друзья!
В этом небольшом туториале я хочу показать вам, как разработать простой, но довольно-таки полноценный сервер для тестирования 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 ...
Зависимости:
express—Node.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-канале ↩

