Главная
Все проекты

URL Shortener

API коротких ссылок с редиректами и аналитикой кликов

Роль

Backend-разработчик

Год

2024

Стек

NestJSTypeScriptPostgreSQLPrismaDockerGitHub ActionsREST API

Задача

Мне нужны были короткие ссылки на своём домене — для резюме, портфолио, статей и рассылок. Готовые сервисы вроде bit.ly закрывают задачу, но с оговорками:

  • ссылка живёт на чужом домене и умирает вместе с чужим тарифом;
  • статистика переходов заперта в чужой панели;
  • нет удобного программного доступа под свои сценарии — сгенерировать ссылку из скрипта или из CMS собственного сайта.

Отсюда постановка: свой API-сервис, который можно дёрнуть из любого места и который переживёт смену хостинга — то есть переносимый, воспроизводимый и с автоматическим деплоем.

Этапы

01

Каркас и доменная модель

  • Модульная структура NestJS: инфраструктурный слой (core) отдельно от доменного модуля (url).
  • Prisma-схема с единственной сущностью Url и уникальным индексом на короткую ссылку.
  • DatabaseService как инжектируемая обёртка над Prisma-клиентом — чтобы домен не импортировал клиента напрямую.
02

Функциональность API

  • Пять эндпоинтов: создание, список, редирект, обновление, удаление.
  • UidService на nanoid с длиной как параметром вызова.
  • UrlExistsPipe — резолв uid в сущность и единый 404 на трёх маршрутах.
  • PaginationService: выборка и общее количество по одному условию, готовые nextPage / prevPage в метаданных, регистронезависимый поиск сразу по названию, описанию и адресу.
03

Границы приложения

  • AuthGuard по x-api-key на CRUD-маршрутах; ключ читается через getOrThrow — приложение падает на старте, а не поднимается с открытым CRUD.
  • Глобальный ValidationPipe с whitelist: true и transform: true, @IsUrl() на редиректе.
  • TransformResponseInterceptor — единый конверт { data } / { data, meta }.
  • helmet() и структурные логи на winston: JSON на проде, цветной вывод в dev.
04

Тесты трёх уровней

  • Unit — на моках, integration — против реальной PostgreSQL, e2e — по HTTP через всё приложение.
  • Две конфигурации jest: быстрые тесты по src, «тяжёлые» — отдельной командой с --runInBand.
  • Изоляция через TRUNCATE ... CASCADE в afterEach вместо пересоздания базы.
05

Воспроизводимое тестовое окружение

  • Отдельный compose-файл для тестов: свои порты (5444 и 6380), контейнеры, сеть, тома и .env.test.
  • Healthcheck'и pg_isready и redis-cli ping плюс запуск с --wait.
  • Собственный скрипт ожидания: TCP-порт, затем реальный prisma.$connect() — до 120 попыток с интервалом в секунду.
06

Контейнеризация и автодеплой

  • Multi-stage Dockerfile на node:20-alpine: в runtime-слой переезжают только dist, node_modules и артефакты Prisma — вместе со схемой и папкой миграций.
  • Перевод CI на тот же пакетный менеджер, что и локально, установка вынесена в composite action.
  • Деплой по событию workflow_run от workflow тестов с checkout по head_sha того же коммита.
  • Публикация образа в GHCR под тегами main и sha-<commit>.

Процесс

Требования

ТребованиеПочему именно так
1POST /url создаёт ссылку вида https://url.dphil.ru/{uid}короткий домен + короткий идентификатор
2GET /:uid — 302-редирект с инкрементом счётчика переходовбазовая аналитика без внешних систем
3Полный CRUD над ссылками, закрытый авторизациейссылки нужно править и удалять, публично — нельзя
4Список с пагинацией и текстовым поискомдесятки ссылок неудобно листать без фильтра
5Единый формат ответа APIчтобы фронт и скрипты не разбирали два разных формата
6Структурные логина проде читать логи глазами — не вариант
7Тесты, которые ловят регрессии на уровне HTTPпет-проект без тестов ломается на второй месяц
8Деплой без ручных шаговиначе обновления просто не выкатываются

Архитектура

Модульная структура NestJS с разделением на инфраструктурный слой и доменный модуль:

src/
├── core/                     # инфраструктура (глобальный модуль)
│   ├── cache/                # Redis через Cacheable + keyv
│   ├── logger/               # winston: JSON на проде, цветной вывод в dev
│   ├── middleware/logger/    # логирование каждого HTTP-запроса
│   └── interceptors/         # единый формат ответа { data, meta }
├── database/                 # Prisma-клиент как инжектируемый сервис
├── auth/                     # AuthGuard по x-api-key
├── modules/url/              # доменный модуль: controller, service, DTO, pipe
├── services/
│   ├── uid/                  # генерация идентификатора (nanoid)
│   └── pagination/           # пагинация и фильтрация
└── utils/                    # ожидание БД, тестовые фикстуры

Ключевой принцип: доменный модуль ничего не знает об инфраструктуре. UrlService работает с DatabaseService, UidService, PaginationService и ConfigService — все они внедряются через DI, поэтому в unit-тестах подменяются моками без единой строчки инфраструктурного кода.

Путь запроса через приложение:

Модель данных

prisma
model Url {
  id          Int      @id @default(autoincrement())
  redirect    String              // куда ведём
  url         String   @unique    // сама короткая ссылка целиком
  title       String
  description String?
  clicks      Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

Уникальный индекс на url — не только удобство поиска, но и защита от коллизий генератора на уровне БД: две одинаковые короткие ссылки физически не запишутся.


Ключевые решения

1. Проверка существования ссылки вынесена в Pipe, а не в контроллер

Три эндпоинта — GET /:uid, PATCH /url/:uid, DELETE /url/:uid — начинались бы с одинаковых пяти строк «найди по uid, если нет — брось 404». Вместо этого сделан кастомный pipe, который превращает строковый параметр маршрута сразу в сущность:

ts
@Injectable()
export class UrlExistsPipe implements PipeTransform {
  constructor(private readonly urlService: UrlService) {}

  async transform(uid: any) {
    const redirectUrl = await this.urlService.findOne(uid);
    if (!redirectUrl) throw new NotFoundException('Url not found');
    return redirectUrl;
  }
}

В контроллере остаётся декларативная подпись — обработчик получает уже готовый объект:

ts
@Get(':uid')
findOne(@Param('uid', UrlExistsPipe) url: Url, @Res() res: Response) {
  this.urlService.incrementClicks(+url.id);
  return res.redirect(url.redirect);
}

Побочный эффект решения: 404 гарантированно одинаковый на всех маршрутах, и его нельзя забыть.

2. Единый контракт ответа через глобальный интерцептор

TransformResponseInterceptor оборачивает любой результат в { data }, а пагинированный — в { data, meta }. Сервисы при этом возвращают чистые доменные объекты и ничего не знают о формате транспорта.

3. Пагинация с готовыми ссылками на соседние страницы

PaginationService считает выборку и общее количество по одному и тому же where, а в meta отдаёт не только числа, но и собранные URL следующей и предыдущей страницы:

json
"meta": {
  "totalCount": 42, "currentPage": 2, "perPage": 10, "totalPages": 5,
  "nextPage": "https://url.dphil.ru/url?limit=10&page=3",
  "prevPage": "https://url.dphil.ru/url?limit=10&page=1"
}

Поиск — регистронезависимый contains сразу по title, description и url, чтобы одна строка запроса покрывала сценарий «найти ту ссылку, которую я делал в мае».

4. Валидация на границе приложения

Глобальный ValidationPipe с whitelist: true и transform: true плюс DTO на class-validator: @IsUrl() на поле редиректа, @Min(1) и приведение типов для query-параметров пагинации. Всё, чего нет в DTO, вырезается из тела запроса — клиент не может дописать себе clicks: 9999. Этот сценарий закреплён отдельным e2e-тестом.

5. Безопасность по границам

  • helmet() на уровне приложения;
  • AuthGuard по заголовку x-api-key, причём ключ читается через getOrThrow — приложение падает на старте, если ключ не задан, вместо того чтобы тихо работать с открытым CRUD;
  • публичным остаётся только редирект, всё остальное закрыто.

6. Логирование, пригодное для машины

winston в двух режимах: в dev — цветной человекочитаемый формат, на проде — JSON с таймстемпом, который сразу ложится в любой сборщик логов. Middleware пишет каждый ответ и выбирает уровень по статус-коду (5xx → error, 4xx → warn, остальное → info), а в тестовом окружении отключается, чтобы не засорять вывод jest.

Сценарий редиректа целиком


Тестирование: три уровня вместо одного

Самая большая часть работы. Тесты разделены по стоимости и назначению:

УровеньФайлыЧто проверяетЗависимости
Unit*.spec.tsлогику сервисов, guard, pipeвсё замокано
Integration*.int-spec.tsчто сервис реально пишет и читает из БДPostgreSQL в Docker
E2E*.e2e-spec.tsHTTP-контракт целиком: коды, тела, авторизациюприложение + БД + Redis

Быстрые и «тяжёлые» тесты разведены по разным конфигам jest: npm run test гоняет только unit-тесты по src, а npm run test:e2e поднимает окружение и запускает интеграционные и e2e-тесты одним прогоном с --runInBand, чтобы они не дрались за одну базу.

Изоляция между тестами — не пересоздание базы, а TRUNCATE ... CASCADE по всем таблицам, кроме _prisma_migrations, в afterEach. Это заметно быстрее, чем накатывать миграции перед каждым тестом, и при этом каждый тест стартует с чистого состояния.

E2E-набор покрывает в том числе неприятные случаи, которые легко сломать рефакторингом: запрос без API-ключа → 401, с неверным ключом → 401, пустое тело → 400, невалидный URL в redirect → 400, попытка передать лишние поля → они отбрасываются валидацией.


Инфраструктура и CI/CD

Docker. Multi-stage сборка на node:20-alpine: в build-стадии ставятся зависимости через corepack/pnpm с --frozen-lockfile, генерируется Prisma-клиент и собирается приложение; в runtime-образ переезжают только dist, node_modules и артефакты Prisma. Отдельно в финальный образ копируются schema.prisma и папка migrations — без них prisma migrate deploy в проде просто не с чем работать.

Два compose-файла. Обычный для локальной разработки и отдельный тестовый — со своими портами (5444 для Postgres, 6380 для Redis), своим .env.test, своей сетью и healthcheck'ами на обоих сервисах.

Деплой запускается не по push, а по событию workflow_run от workflow тестов, и делает checkout именно того коммита, который тестировался. Красный CI физически не может уехать на прод. Образ публикуется в GHCR сразу под несколькими тегами: main для «последней версии» и sha-<commit> — чтобы откат был вопросом одной строки, а не пересборки.


Сложности и как решались

1. Плавающие падения e2e в CI. Локально всё зелёное, в Actions — периодические ошибки подключения к БД. Причина классическая: docker compose up -d возвращает управление, когда контейнер запущен, а не когда Postgres готов принимать соединения. Решение в два слоя: healthcheck'и с pg_isready и redis-cli ping плюс запуск с флагом --wait, и собственный скрипт ожидания, который сначала проверяет доступность TCP-порта, а затем делает реальный prisma.$connect() — до 120 попыток с интервалом в секунду. Проверка именно через Prisma важна: открытый порт ещё не означает, что база доступна тому пользователю, под которым пойдут миграции.

2. Конфликты портов между dev- и тестовым окружением. Тестовый прогон мешал локальной базе разработки. Развёл окружения полностью: отдельный compose-файл, фиксированный порт 5444, отдельные имена контейнеров, сети и томов, отдельный .env.test, подключаемый через dotenv-cli.

3. Prisma в контейнере. Первая рабочая сборка образа падала на проде при накатке миграций — в runtime-слой не попадали ни схема, ни миграции. Плюс генерация клиента должна происходить внутри сборки под целевую платформу, а не тащиться с машины разработчика: установка идёт с --ignore-scripts, а prisma generate вызывается явно и в нужный момент.

4. Переезд CI на pnpm. Локальный проект использовал pnpm, а workflow'ы — npm; из-за этого lock-файл не соблюдался и CI ловил версии зависимостей, которых не было у меня. Привёл всё к единому менеджеру и вынес установку в переиспользуемый composite action.

5. Конфигурация Redis из переменных окружения. У разных хостингов разный набор: где-то только пароль, где-то пара логин/пароль, где-то ни того ни другого. Строка подключения собирается динамически с URL-энкодингом учётных данных, так что спецсимволы в пароле не ломают подключение.

Что дальше

Честный список того, что я знаю про проект и куда он развивается:

  • Короткий домен вместо поддомена.

  • Кэш редиректов. Redis и обёртка над ним уже подключены, но горячий путь GET /:uid пока ходит в БД. Следующий шаг — кэшировать uid → redirect с инвалидацией на update и delete: это самый частый запрос сервиса, и он идеально кэшируется.

  • Счётчик переходов. Инкремент намеренно не блокирует редирект, но при этом не имеет гарантий доставки. Правильное развитие — буферизация и запись пачками.

  • Rate limiting на публичном редиректе и проверка целевых URL — защита от использования сервиса как открытого редиректора.

  • Пользователи вместо одного API-ключа, если сервисом будет пользоваться кто-то кроме меня.

  • Курсорная пагинация вместо offset — когда ссылок станет достаточно, чтобы это стало заметно.

Результаты

40

Автотестов в проекте

≈1 мин 15 c

Сборка образа и деплой

5

Эндпоинтов в API

Экраны