URL Shortener
API коротких ссылок с редиректами и аналитикой кликов
Роль
Backend-разработчик
Год
2024
Стек
NestJSTypeScriptPostgreSQLPrismaDockerGitHub ActionsREST API
Задача
Мне нужны были короткие ссылки на своём домене — для резюме, портфолио, статей и рассылок. Готовые сервисы вроде bit.ly закрывают задачу, но с оговорками:
- ссылка живёт на чужом домене и умирает вместе с чужим тарифом;
- статистика переходов заперта в чужой панели;
- нет удобного программного доступа под свои сценарии — сгенерировать ссылку из скрипта или из CMS собственного сайта.
Отсюда постановка: свой API-сервис, который можно дёрнуть из любого места и который переживёт смену хостинга — то есть переносимый, воспроизводимый и с автоматическим деплоем.
Этапы
Каркас и доменная модель
- Модульная структура NestJS: инфраструктурный слой (
core) отдельно от доменного модуля (url). - Prisma-схема с единственной сущностью
Urlи уникальным индексом на короткую ссылку. DatabaseServiceкак инжектируемая обёртка над Prisma-клиентом — чтобы домен не импортировал клиента напрямую.
Функциональность API
- Пять эндпоинтов: создание, список, редирект, обновление, удаление.
UidServiceна nanoid с длиной как параметром вызова.UrlExistsPipe— резолвuidв сущность и единый 404 на трёх маршрутах.PaginationService: выборка и общее количество по одному условию, готовыеnextPage/prevPageв метаданных, регистронезависимый поиск сразу по названию, описанию и адресу.
Границы приложения
AuthGuardпоx-api-keyна CRUD-маршрутах; ключ читается черезgetOrThrow— приложение падает на старте, а не поднимается с открытым CRUD.- Глобальный
ValidationPipeсwhitelist: trueиtransform: true,@IsUrl()на редиректе. TransformResponseInterceptor— единый конверт{ data }/{ data, meta }.helmet()и структурные логи на winston: JSON на проде, цветной вывод в dev.
Тесты трёх уровней
- Unit — на моках, integration — против реальной PostgreSQL, e2e — по HTTP через всё приложение.
- Две конфигурации jest: быстрые тесты по
src, «тяжёлые» — отдельной командой с--runInBand. - Изоляция через
TRUNCATE ... CASCADEвafterEachвместо пересоздания базы.
Воспроизводимое тестовое окружение
- Отдельный compose-файл для тестов: свои порты (5444 и 6380), контейнеры, сеть, тома и
.env.test. - Healthcheck'и
pg_isreadyиredis-cli pingплюс запуск с--wait. - Собственный скрипт ожидания: TCP-порт, затем реальный
prisma.$connect()— до 120 попыток с интервалом в секунду.
Контейнеризация и автодеплой
- Multi-stage Dockerfile на
node:20-alpine: в runtime-слой переезжают толькоdist,node_modulesи артефакты Prisma — вместе со схемой и папкой миграций. - Перевод CI на тот же пакетный менеджер, что и локально, установка вынесена в composite action.
- Деплой по событию
workflow_runот workflow тестов с checkout поhead_shaтого же коммита. - Публикация образа в GHCR под тегами
mainиsha-<commit>.
Процесс
Требования
| № | Требование | Почему именно так |
|---|---|---|
| 1 | POST /url создаёт ссылку вида https://url.dphil.ru/{uid} | короткий домен + короткий идентификатор |
| 2 | GET /: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-тестах подменяются моками без единой строчки инфраструктурного кода.
Путь запроса через приложение:
Модель данных
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, который превращает строковый параметр маршрута сразу в сущность:
@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;
}
}
В контроллере остаётся декларативная подпись — обработчик получает уже готовый объект:
@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 следующей и предыдущей страницы:
"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.ts | HTTP-контракт целиком: коды, тела, авторизацию | приложение + БД + 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 — когда ссылок станет достаточно, чтобы это стало заметно.
Результаты
Автотестов в проекте
Сборка образа и деплой
Эндпоинтов в API