Привет, друзья!
В этом небольшом туториале я хочу показать вам, как разработать простой, но довольно-таки полноценный сервер для тестирования API.
Основной функционал нашего приложения будет следующим:
Наша админка будет выглядеть так:
Для быстрой стилизации приложения будет использоваться Bootstrap.
Исходный код проекта находится здесь.
Разумеется, с приложением, которое мы с разработаем, сразу в продакшн не пойдешь, но при необходимости довести его до производственного уровня не составит труда.
При разработке приложения мы будет придерживаться 2 важных условий:
Обратите внимание: статья рассчитана, преимущественно, на начинающих разработчиков, хотя, смею надеяться, что и опытные найдут в ней что-нибудь интересное для себя.
Вы готовы? Тогда вперед.
Создаем директорию для проекта, переходим в нее, инициализируем проект и устанавливаем зависимости:
mkdir mock-api
cd !$
yarn init -y
# or
npm init -y
yarn add express multer nodemon open-cli very-simple-fetch
# or
npm i ...
Зависимости:
Открываем package.json, определяем в нем основной файл сервера (index.js) как модуль и команду для запуска сервера для разработки:
{
"type": "module",
"scripts": {
"dev": "open-cli http://localhost:5000 && nodemon index.js"
}
}
Команда dev указывает открыть вкладку браузера по адресу http://localhost:5000 (адрес, на котором будет запущен сервер) и выполнить код в файле index.js (запустить сервер для разработки).
Структура нашего проекта будет следующей:
Пожалуй, проект готов к разработке. Не будем откладывать на завтра то, что можно отложить на послезавтра сделать сейчас.
В файле index.js мы делаем следующее:
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}`)
})
Подумаем о том, какие роуты нам нужны для работы с проектами. Как насчет следующих запросов:
Мы также могли бы определить отдельный роут для обновления проекта через PUT-запрос, но в этом нет особого смысла — проще перезаписать существующий проект новым.
В файле routes/project.routes.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 небольших утилит:
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))
}
}
Еще одна рекурсивная функции (обещаю, что последняя) для получения названий всех существующих проектов:
// функция принимает путь к корневой директории
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.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 также обрабатываются корректно.
Тремя наиболее популярными решениями для быстрого тестирования API является следующее:
Я покажу, как использовать 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
Здесь:
Над каждым запросом в 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.
Пожалуй, это все, чем я хотел поделиться с вами в данной статье.
В этом небольшом туториале я хочу показать вам, как разработать простой, но довольно-таки полноценный сервер для тестирования 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))
}
}
Еще одна рекурсивная функции (обещаю, что последняя) для получения названий всех существующих проектов:
// функция принимает путь к корневой директории
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.
Пожалуй, это все, чем я хотел поделиться с вами в данной статье.
Node.js: разрабатываем сервер для тестирования API
Привет, друзья! В этом небольшом туториале я хочу показать вам, как разработать простой, но довольно-таки полноценный сервер для тестирования API . Основной функционал нашего приложения будет...
habr.com