Практическое знакомство с Deno: разрабатываем REST API + MongoDB + Linux
Всем привет. В этот раз я решил сделать нечто более интересное, чем очередной бот, поэтому далее я покажу как реализовать REST API с Deno, подключить и использовать MongoDB в качестве базы данных, и всё это запустить из под Linux.
Видео версия данной заметки доступна ниже:
Описание задачи
В качестве примера я выбрал Github Gists API и следующие методы:
-
[POST] Create a gist;
-
[GET] List public gists;
-
[GET] Get a gist;
-
[PATCH] Update a gist;
-
[DELETE] Delete a gist.
Создание проекта
Для начала мы добавляем файл api/mod.ts
:
console.log('hello world');
И проверяем, что всё работает командой deno run mod.ts
:
Добавление зависимостей
Создаём файл api/deps.ts
и добавляем следующие зависимости:
/* REST API */
export { Application, Router } from "<https://deno.land/x/oak/mod.ts>";
export type { RouterContext } from "<https://deno.land/x/oak/mod.ts>";
export { getQuery } from "<https://deno.land/x/oak/helpers.ts>";
/* MongoDB driver */
export { MongoClient, Bson } from "<https://deno.land/x/[email protected]/mod.ts>";
Отступление: В отличие от NodeJS, авторы Deno отказались от поддержки npm и node_modules
, а необходимые библиотеки подключаются по url и кешируются локально. Сами библиотеки можно найти в разделе Third Party Modules на сайте http://deno.land.
Добавление API Boilerplate
Далее, добавляем код для запуска API в файл mod.ts
:
import { Application, Router } from "./deps.ts";
const router = new Router();
router
.get("/", (context) => {
context.response.body = "Hello world!";
});
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
Причём функции Application
и Router
импортируем уже из локального файла deps.ts
.
Проверим, что всё было сделано верно:
-
Запускаем приложение командой
deno run --allow-net mod.ts
; -
Открываем в браузере
http://localhost:8000
; -
Получаем страницу с сообщением ‘Hello world!’;
Отступление: Deno позиционируется как secure by default. Другими словами, у запускаемого приложения (скрипта) не будет доступа к сети (--allow-net
, файловой системе (--allow-read
и --allow-write
, параметрам окружения (--allow-env
) пока этот доступ явно не разрешён.
Добавление метода POST /gists
Пришло время добавить первый метод, который будет сохранять запись в базу данных.
Прежде всего опишем контракт:
-
[POST] /gists
-
Параметры:
-
content: string | body;
-
-
Ответы:
-
201 Created;
-
400 Bad Request;
-
Обработчик
Добавляем папку handlers
и файл create.ts
, в котором будет расположен handler (обработчик) запроса:
import { RouterContext } from "../deps.ts";
import { createGist } from "../service.ts";
export async function create(context: RouterContext) {
if (!context.request.hasBody) {
context.throw(400, "Bad Request: body is missing");
}
const body = context.request.body();
const { content } = await body.value;
if (!content) {
context.throw(400, "Bad Request: content is missing");
}
const gist = await createGist(content);
context.response.body = gist;
context.response.status = 201;
}
В этой функции мы:
-
Валидируем входные значения (
request.hasBody
и!content
); -
Вызываем функцию
createGist
нашего сервиса (добавим далее); -
Возвращаем добавленный объект в ответе и 201 Created.
Сервис
Далее, нам необходимо передать управление из обработчика в сервис (добавляем service.ts
):
import { insertGist } from "./db.ts";
export async function createGist(content: string): Promise<IGist> {
const values = {
content,
created_at: new Date(),
};
const _id = await insertGist(values);
return {
_id,
...values,
};
}
interface IGist {
_id: string;
content: string;
created_at: Date;
}
В данном случае функция принимает единственный аргумент content: string
и возвращает объект, структура которого описывается интерфейсом IGist
.
Репозиторий
Последним этапом обработки запроса является сохранение записи в MongoDB. Для этого мы добавляем файл db.ts
и соответствующую функцию:
import { Collection } from "<https://deno.land/x/[email protected]/src/collection/collection.ts>";
import { Bson, MongoClient } from "./deps.ts";
async function connect(): Promise<Collection<IGistSchema>> {
const client = new MongoClient();
await client.connect("mongodb://localhost:27017");
return client.database("gist_api").collection<IGistSchema>("gists");
}
export async function insertGist(gist: any): Promise<string> {
const collection = await connect();
return (await collection.insertOne(gist)).toString();
}
interface IGistSchema {
_id: { $oid: string };
content: string;
created_at: Date;
}
В этом файле мы:
-
Импортируем необходимые типы и функции для работы с MongoDB;
-
Подключаемся к базе данных
gist_api
в функцииconnect
; -
Описываем формат объектов, которые хранятся в коллекции
gist_api
интерфейсомIGistSchema
; -
Сохраняем объект методом
insertOne
и возвращаем его идентификатор (inserted id);
Запускаем экземпляр MongoDB
Далее мы запускаем терминал, запускаем и проверяем статус нашей базы данных следующими командами:
sudo systemctl start mongod
sudo systemctl status mongod
Если всё было сделано верно, то получим следующий результат:
Отступление: Как установить MongoDB на Ubuntu.
Запускаем приложение
-
Компилируем и запускаем приложение командой
deno run --allow-net mod.ts
; -
Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 201 Created
и сохранённый объект с проставленным _id
:
Отступление: Как вы могли заметить, в процессе разработки мы используем TypeScript без транспайлеров. Причина проста – Deno поддерживает TypeScript из коробки.
Добавление метода GET /gists
Следующим методом мы сможем получить записи из базы данных, а заодно реализовать базовую пагинацию.
Прежде всего опишем контракт:
-
[GET] /gists
-
Параметры:
-
skip: string | query;
-
limit: string | query;
-
-
Ответы:
Обработчик
Добавляем файл handlers/list.ts
, в котором будет расположен handler (обработчик) запроса:
import { getQuery, RouterContext } from "../deps.ts";
import { getGists } from "../service.ts";
export async function list(context: RouterContext) {
const { skip, limit } = getQuery(context);
const gists = await getGists(+skip || 0, +limit || 0);
context.response.body = gists;
context.response.status = 200;
}
В этой функции мы:
-
Получаем параметры с query string с помощь функции
getQuery
; -
Вызываем функцию
getGists
нашего сервиса (добавим далее); -
Возвращаем массив найденных объектов в ответе и 200 OK;
Отступление: Функция сервиса будет принимать аргументы типа number
, в то время как в обработчик к нам приходят параметры типа string
. Для этого мы делаем приведение типов следующей конструкцией +skip || 0
(корректные значения конвертируются, некорректные приводятся к NaN
и игнорируются в пользу 0
).
Сервис
Далее, передаём управление из обработчика в сервис:
export function getGists(skip: number, limit: number): Promise<IGist[]> {
return fetchGists(skip, limit);
}
В данном случае функция принимает аргументы skip: number
и limit: number
, и возвращает массив объектов, структура которых описывается интерфейсом IGist
.
Репозиторий
Последним этапом обработки запроса является получение записей из MongoDB. Для этого мы добавляем функцию fetchGists
в файл db.ts
:
export async function fetchGists(skip: number, limit: number): Promise<any> {
const collection = await connect();
return await collection.find().skip(skip).limit(limit).toArray();
}
В этой функции мы:
-
Подключаемся к базе данных
gist_api
в функцииconnect
; -
Получаем все записи коллекции, пропускаем
skip
из них и возвращаем в кол-веlimit
;
Запускаем приложение
-
Компилируем и запускаем приложение командой
deno run --allow-net mod.ts
; -
Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 200 OK
и массив ранее добавленных объектов:
Добавление метода GET /gists/:id
Следующим методом мы получаем запись из базы данных по её идентификатору.
Прежде всего опишем контракт:
-
[GET] /gists/:id
-
Параметры:
-
id: string | path
-
-
Ответы:
-
200 OK;
-
400 Bad Request;
-
404 Not Found.
-
Обработчик
Добавляем файл handlers/get.ts
, в котором будет расположен handler (обработчик) запроса:
import { RouterContext } from "../deps.ts"
import { getGist } from "../service.ts";
export async function get(context: RouterContext) {
const { id } = context.params;
if(!id) {
context.throw(400, "Bad Request: id is missing");
}
const gist = await getGist(id);
if(!gist) {
context.throw(404, "Not Found: the gist is missing");
}
context.response.body = gist;
context.response.status = 200;
}
В этой функции мы:
-
Проверяем наличие
id
и возвращаем 400 если он отсутствует; -
Запрашиваем объект в базе данных функцией
getGist
и возвращаем 404 если он не найден (добавим далее); -
Возвращаем найденный объект и 200 OK;
Сервис
Далее, передаём управление из обработчика в сервис:
export function getGist(id: string): Promise<IGist> {
return fetchGist(id);
}
interface IGist {
_id: string;
content: string;
created_at: Date;
}
В данном случае функция принимает аргумент id: string
и возвращает объект, структура которого описывается интерфейсом IGist
.
Репозиторий
Последним этапом обработки запроса является получение записи из MongoDB. Для этого мы добавляем функцию fetchGist
в файл db.ts
:
export async function fetchGist(id: string): Promise<any> {
const collection = await connect();
return await collection.findOne({ _id: new Bson.ObjectId(id) });
}
В этой функции мы:
-
Подключаемся к базе данных
gist_api
в функцииconnect
; -
Используем метод
findOne
для поиска записи удовлетворяющей фильтру по_id
;
Запускаем приложение
-
Компилируем и запускаем приложение командой
deno run --allow-net mod.ts
; -
Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 200 OK
и ранее добавленный объект:
Добавление метода PATCH /gists/:id
Следующим методом мы обновляем запись в базе данных по её идентификатору.
Как и прежде, начинаем с контракта:
-
[PATCH] /gists/:id
-
Параметры:
-
id: string | path
-
content: string | body
-
-
Ответы:
-
200 OK;
-
400 Bad Request;
-
404 Not Found.
-
Обработчик
Добавляем файл handlers/update.ts
, в котором будет расположен handler (обработчик) запроса:
import { RouterContext } from "../deps.ts";
import { getGist, patchGist } from "../service.ts";
export async function update(context: RouterContext) {
const { id } = context.params;
if (!id) {
context.throw(400, "Bad Request: id is missing");
}
const body = context.request.body();
const { content } = await body.value;
if (!content) {
context.throw(400, "Bad Request: content is missing");
}
const gist = await getGist(id);
if (!gist) {
context.throw(404, "Not Found: the gist is missing");
}
await patchGist(id, content);
context.response.status = 200;
}
В этой функции мы:
-
По аналогии проверяем наличие
id
и возвращаем 400 если он отсутствует; -
Валидируем входное значение
!content
; -
Запрашиваем объект в базе данных функцией
getGist
и возвращаем 404 если он не найден; -
Обновляем объект в базе данных функцией
patchGist
(добавим далее); -
Возвращаем 200 OK.
Сервис
Далее, передаём управление из обработчика в сервис:
export async function patchGist(id: string, content: string): Promise<any> {
return updateGist({ id, content });
}
interface IGist {
_id: string;
content: string;
created_at: Date;
}
В данном случае функция принимает аргументы id: string
и content: string
, и возвращает any
.
Репозиторий
Последним этапом обработки запроса является обновлении записи в MongoDB. Для этого мы добавляем функцию updateGist
в файл db.ts
:
export async function updateGist(gist: any): Promise<any> {
const collection = await connect();
const filter = { _id: new Bson.ObjectId(gist.id) };
const update = { $set: { content: gist.content } };
return await collection.updateOne(filter, update);
}
В этой функции мы:
-
Подключаемся к базе данных
gist_api
в функцииconnect
; -
Описываем фильтр
filter
объектов, которые мы хотим обновить; -
Описываем инструкцию
update
, которую применяем для обновления найденных объектов; -
Используем метод
updateOne
собрав всё воедино;
Запускаем приложение
-
Компилируем и запускаем приложение командой
deno run --allow-net mod.ts
; -
Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 200 OK
:
Добавление метода DELETE /gists/:id
Последним по списку, но не по важности, мы добавляем метод удаления записей из базы данных по идентификатору.
По традиции, начинаем с контракта:
-
[DELETE] /gists/:id
-
Параметры:
-
id: string | path
-
-
Ответы:
-
204 No Content;
-
404 Not Found.
-
Обработчик
Добавляем файл handlers/remove.ts
, в котором будет расположен handler (обработчик) запроса:
import { RouterContext } from "../deps.ts";
import { getGist, removeGist } from "../service.ts";
export async function remove(context: RouterContext) {
const { id } = context.params;
if (!id) {
context.throw(400, "Bad Request: id is missing");
}
const gist = await getGist(id);
if (!gist) {
context.throw(404, "Not Found: the gist is missing");
}
await removeGist(id);
context.response.status = 204;
}
В этой функции мы:
-
По аналогии проверяем наличие
id
и возвращаем 400 если он отсутствует; -
Запрашиваем объект в базе данных функцией
getGist
и возвращаем 404 если он не найден; -
Удаляем объект из базы данных функцией
removeGist
(добавим далее); -
Возвращаем 204 No Content.
Сервис
Далее, передаём управление из обработчика в сервис:
export function removeGist(id: string): Promise<number> {
return deleteGist(id);
}
В данном случае функция принимает единственный аргумент id: string
и возвращает number
.
Репозиторий
Последним этапом обработки запроса является удаление записи из коллекции MongoDB. Для этого мы добавляем функцию deleteGist
в файл db.ts
:
export async function deleteGist(id: string): Promise<any> {
const collection = await connect();
return await collection.deleteOne({ _id: new Bson.ObjectId(id) });
}
В этой функции мы:
-
Подключаемся к базе данных
gist_api
в функцииconnect
; -
Используем метод
deleteOne
для удаления объекта удовлетворяющего фильтру по_id
;
Запускаем приложение
-
Компилируем и запускаем приложение командой
deno run --allow-net mod.ts
; -
Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 204 No Content
:
Отступление: В данном случае фактическое удаление объекта из коллекции выбрано для наглядности. В реальных приложениях я предпочитаю добавить и обновлять у объекта поле isDeleted: boolean
.
FAQ
Вызывая методы API я всегда получаю только 404 Not Found
Убедитесь что вы не забыли сконфигурировать router
в файле mod.ts
соответствующими обработчиками:
import { Application, Router } from "./deps.ts";
import { list } from "./handlers/list.ts";
import { create } from "./handlers/create.ts";
import { remove } from "./handlers/remove.ts";
import { get } from "./handlers/get.ts";
import { update } from "./handlers/update.ts";
const app = new Application();
const router = new Router();
router
.post("/gists", create)
.get("/gists", list)
.get("/gists/:id", get)
.delete("/gists/:id", remove)
.patch("/gists/:id", update);
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
Вызывая методы API я получаю 500 Internal Server Error
Отловить ошибку можно следующим способом:
const app = new Application();
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
console.log(err);
}
});
...
Ссылки
Заключение
Спасибо за то что дочитали до конца.
В заключении упомяну, что к сожалению, не каждое из моих видео удаётся опубликовать в текстовом виде, поэтому если данные тема и формат вам интересны, то я приглашаю вас подписаться на телеграм канал https://t.me/seasoneddevru.