Практическое знакомство с 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:

mod.ts
mod.ts

Добавление зависимостей

Создаём файл api/deps.ts и добавляем следующие зависимости:

  • Пакет oak для работы с API;

  • Пакет mongo для работы с MongoDB;

/* 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.

Let’s block ads! (Why?)

Read More

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *