developing a server for testing API

Hello friends!

In this small tutorial, I want to show you how to develop a simple, but quite complete server for testing. API

The main functionality of our application will be as follows:

  • admin panel with the ability to add data (hereinafter referred to as projects) by typing (entering) or copying / pasting, or by uploading a file;
  • saving projects on the server;
  • safe writing, reading and deleting files at any nesting level;
  • getting the names of existing projects and displaying them in the admin panel;
  • the ability to edit and delete projects;
  • unified processing GET, POST, PUT and DELETE requests to any existing project, including GET-запросыcontaining parameters and query strings;
  • handling special query string parameters sort, order, limit and offset;
  • and much more.

Our admin panel will look like this:

For quick styling of the application, we will use Bootstrap

The source code of the project is located here

Of course, with the application that we will develop, you will not go straight to production, but if necessary, it will not be difficult to bring it to the production level.

When developing an application, we will adhere to 2 important conditions:

  • data format – JSON;
  • the main form of data is an array.

note: The article is designed primarily for novice developers, although I dare to hope that experienced ones will find something interesting for themselves.

You are ready? Then go ahead.

Project preparation

Create a directory for the project, go to it, initialize the project and install the dependencies:

mkdir mock-api
cd !$

yarn init -y
# or
npm init -y

yarn add express multer nodemon open-cli very-simple-fetch
# or
npm i ...

Dependencies:

  • expressNode.js-фреймворк for server development
  • multer – wrapper over busboy, a utility for processing data in the format multipart/form-dataoften used to save files
  • nodemon – utility for starting the server for development
  • open-cli – a utility for automatically opening a browser tab at a specified address
  • very-simple-fetch – wrapper over Fetch APIsimplifying the work with the named interface

We open package.json, define the main server file (index.js) as a module and command to start the development server:

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

Command dev indicates to open a browser tab at http://localhost:5000 (the address where the server will run) and execute the code in the file index.js (start development server).

The structure of our project will be as follows:

  • projects – directory for projects
  • public – directory with static files for the admin panel
  • routes – directory for routes
  • index.js – main server file
  • utils.js – secondary functions

Perhaps the project is ready for development. Let’s not postpone what is possible for tomorrow postpone until the day after tomorrow do now.

Server, router for projects and utilities

In file index.js we do the following:

  • import express, full path to the current (working) directory and routes for projects;
  • create an instance Express-приложения;
  • add intermediaries (intermediate handlers): for serving static files, for parsing (parsing) data into JSON, for decoding URL;
  • add a route to receive files from the directory node_modules;
  • add routes for projects;
  • add an error handler;
  • define the port and start the server.
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}`)
})

Let’s think about what routes we need to work with projects. How about the following queries:

  • GET – getting the names of all existing projects
  • GET – getting a project by name
  • POST – project creation
  • POST – project loading
  • DELETE – deleting a project

We could also define a separate route to update the project via PUT-запрос, but that doesn’t make much sense – it’s easier to overwrite an existing project with a new one.

In file routes/project.routes.js we do the following:

  • import the router from express and helper functions from utils.js;
  • exporting a new instance of the router;
  • define handlers for each of the above requests.
import { Router } from 'express'
// мы подробно рассмотрим каждую из этих утилит далее
import {
 getFileNames,
 createFile,
 readFile,
 removeFile,
 uploadFile
} from '../utils.js'

export default Router()

Further, the chain (one after the other) are handlers.

Getting a project by name:

 .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)
   }
 })

Getting the names of all projects:

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

     // и возвращаем их
     res.status(200).json(projects)
   } catch (e) {
     next(e)
   }
 })

Project creation:

 .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)
   }
 })

Uploading the project:

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

Deleting a project:

 .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)
   }
 })

An error that occurs during the execution of any operation will be passed to the handler defined in index.js – centralized error handling. We will implement something similar on the client.

Helper functions defined in the file utils.js Is perhaps the most interesting and useful part of the tutorial. I tried to make these functions as versatile as possible so that you can use them in your projects without significant changes.

Let’s start by importing modules, defining the full (absolute) path to the current directory and directory for projects (root directory), and creating 2 small utilities:

  • to determine that a file or directory does not exist by an error message;
  • to reduce the path by 1 “unit”.
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('/')

Utility to create a file:

// функция принимает 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
 }
}

File reading utility:

// функция принимает путь и расширение
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()
 }
}

File deletion utility:

// функция принимает путь и расширение
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
 }
}

Utility for removing a directory:

// утилита принимает путь к удаляемой директории и путь к корневой директории,
// который по умолчанию имеет значение директории с проектами
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))
 }
}

Another recursive function (last one I promise) to get the names of all existing projects:

// функция принимает путь к корневой директории
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
 }
}

The last function that we need to work with projects is the function for uploading files. This function is not as versatile as the previous ones. It is designed to process data in the format multipart/form-datacontaining well-defined fields:

// создаем посредника для загрузки файлов
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)
   }
 })
})

All that remains for us to do to work with projects is to develop the client side of the admin panel.

Customer

Markup (public/index.html):

<head>
 <!-- заголовок документа -->
 <title>Mock API</title>
 <!-- иконка -->
 <link rel="icon" href="https://habr.com/ru/company/timeweb/blog/583588/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="https://habr.com/ru/company/timeweb/blog/583588/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="https://habr.com/ru/company/timeweb/blog/583588/script.js" type="module"></script>
</body>

note on id elements. Since elements with the attribute id become properties of the global object window, such elements can be accessed directly, i.e. without first getting a reference to the element using methods such as querySelector()

I will not dwell on the styles: all we do there is define the font for all elements and limit the maximum width .container

Moving on to public/script.js

We import very-simple-fetch, dummy data, a utility to determine if the passed argument is JSON, and define the base URL server:

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"

Dummy data (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
 }
]

Utility (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
}

Define a function to get project names:

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 to initialize the project with dummy data:

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

Function for initializing handlers. It includes registering button click handlers for editing and deleting projects, as well as a form submitting handler for creating or loading a project:

function initHandlers() {
 // ...
}

Button click handler:

// обработка нажатия кнопок делегируется списку проектов - элементу `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
   }
 }
}

Form submission handler:

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)
}

Function for editing a project:

async function editProject(name) {
 // название проекта передается на сервер в виде строки запроса
 const { data, error } = await simpleFetch.get(`?project_name=${name}`)

 if (error) {
   return console.error(error)
 }

 // инициализируем проект с помощью полученных от сервера данных
 initProject(name, data)
}

Function for deleting a project:

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

 // вызываем обработчик ответа
 await handleResponse(response)
}

Function for processing the response from the server:

async function handleResponse(response) {
 // извлекаем данные и ошибку из ответа
 const { data, error } = response

 // если при выполнении ответа возникла ошибка
 if (error) {
   return console.error(error)
 }

 // выводим в консоль сообщение об успешно выполненной операции
 console.log(data.message)

 // обновляем список проектов
 await fetchProjects()
}

Finally, we call our functions:

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

Let’s check the performance of our service for working with projects.

We execute the command:

yarn dev
# or
npm run dev

The development server starts, a new browser tab opens at http://localhost:5000:

We have a name and data for the project. Push Create project… In the directory projects file appears todos.json, the list of projects is updated:

Let’s try to download the file. Enter the name of the new project, for example, data/todos, load via input JSON-файл from directory public/data, press Create project… In the directory projects directory appears data with a new project file, the list of projects is updated:

Pressing the buttons to edit and delete the project also produces the expected results.

Excellent, the service for working with projects is functioning normally. But so far he does not know how to respond to requests from the outside. Let’s fix this.

Routes for API

Create a file api.routes.js in the directory routes

Importing a router from express, utilities from utils.js and export the router instance:

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

export default Router()

Let’s start with the simplest – POST-запроса to add data to an existing project:

.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-запрос to delete data from the project:

// `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)
 }
})

This is what the utility for comparing objects looks like (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-запрос it looks like updating the data of an existing project:

.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)
 }
})

Concerning GET-запроса to receive a project, then everything is not so simple with him. We should be able to receive both the entire project and individual data from it. Moreover, in the case of individual data, we should be able to receive them both using a unique identifier (parameter) – in this case, an object should be returned, and using a query string – in this case, an array should be returned.

GET-запрос to receive the entire project:

.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-запрос to get data by parameter or query string:

.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)
 }
})

The utility for handling special query string parameters looks like this (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])
   )
 }
}

So, we have a service for working with projects and API to work with queries. We have already made sure that the service works. It remains to check that requests to API are also handled correctly.

REST Client

The Top 3 Most Popular Quick Testing Solutions API is the following:

I will show you how to use REST Clienthowever, the queries we generate can be easily used in other tools as well.

After installation REST Client in the root directory of the project, you need to create a file with the extension .http, for example, test.http with the following content:

###
### 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

Here:

  • ### – a query separator that can be used to add comments
  • GET, POST etc. – request method
  • content-type: application/json – request header
  • [ ... ] or { ... } – request body
  • headers and request body must be separated by an empty line

Above every request in VSCode there is a button to execute the request.

For testing API you need to create two projects: todos and data/todos

Let’s run a couple of queries.

GET-запрос to receive a project todos:

GET-запрос to receive a task with text Sleep from the project todos using a query string (such a query can also be done using the parameter – GET http://localhost:5000/api/todos/Sleep):

POST-запрос to add a new task to the project todos:

GET-запрос to receive the second and third tasks from the project todossorted by field text descending:

Etc.

notethat operations for one project do not affect other projects.

Perform a few requests yourself, study the answers and mentally associate them with the routes implemented in api.routes.js

Perhaps this is all that I wanted to share with you in this article.

I would be glad to receive any feedback.

Thank you for your attention and have a nice day!


Similar Posts

Leave a Reply