Home
All projects

URL Shortener

Link shortening API with redirects and click analytics

Role

Backend developer

Year

2024

Stack

NestJSTypeScriptPostgreSQLPrismaDockerGitHub ActionsREST API

The task

I needed short links on my own domain — for my CV, portfolio, articles and mailings. Off-the-shelf services such as bit.ly do solve that, but with caveats:

  • the link lives on someone else’s domain and dies together with someone else’s pricing plan;
  • click statistics are locked inside someone else’s dashboard;
  • there is no convenient programmatic access for my own scenarios — generating a link from a script or from the CMS of my own site.

Hence the brief: my own API service that can be called from anywhere and that will outlive a change of hosting — in other words portable, reproducible and automatically deployed.

Stages

01

Skeleton and domain model

  • A modular NestJS structure: the infrastructure layer (core) kept separate from the domain module (url).
  • A Prisma schema with a single Url entity and a unique index on the short link.
  • DatabaseService as an injectable wrapper around the Prisma client, so the domain never imports the client directly.
02

API functionality

  • Five endpoints: create, list, redirect, update, delete.
  • UidService built on nanoid, with the identifier length passed as a call parameter.
  • UrlExistsPipe — resolves a uid into an entity and produces one consistent 404 across three routes.
  • PaginationService: the page of results and the total count derived from one and the same condition, ready-made nextPage / prevPage links in the metadata, and case-insensitive search across title, description and URL at once.
03

Application boundaries

  • AuthGuard based on x-api-key on the CRUD routes; the key is read via getOrThrow, so the application fails at startup instead of coming up with wide-open CRUD.
  • A global ValidationPipe with whitelist: true and transform: true, plus @IsUrl() on the redirect field.
  • TransformResponseInterceptor — a single envelope, { data } or { data, meta }.
  • helmet() and structured winston logs: JSON in production, colourised output in development.
04

Three levels of tests

  • Unit tests against mocks, integration tests against a real PostgreSQL, e2e tests over HTTP through the entire application.
  • Two jest configurations: fast tests across src, and the heavyweight ones behind a separate command with --runInBand.
  • Isolation through TRUNCATE ... CASCADE in afterEach rather than recreating the database.
05

A reproducible test environment

  • A dedicated compose file for tests: its own ports (5444 and 6380), containers, network, volumes and .env.test.
  • pg_isready and redis-cli ping healthchecks, combined with starting the stack using --wait.
  • A custom wait script: first the TCP port, then a genuine prisma.$connect() — up to 120 attempts at one-second intervals.
06

Containerisation and automated deployment

  • A multi-stage Dockerfile on node:20-alpine: only dist, node_modules and the Prisma artefacts move into the runtime layer — together with the schema and the migrations folder.
  • CI moved onto the same package manager used locally, with installation extracted into a composite action.
  • Deployment triggered by the workflow_run event from the test workflow, checking out the exact commit by its head_sha.
  • The image published to GHCR under both a main and a sha-<commit> tag.

The process

Requirements

#RequirementRationale
1POST /url creates a link in the form https://url.dphil.ru/{uid}short domain plus short identifier
2GET /:uid — a 302 redirect that increments the click counterbasic analytics without external systems
3Full CRUD over links, protected by authenticationlinks need editing and deleting, but not publicly
4A list with pagination and full-text searchdozens of links are awkward to page through without a filter
5A single API response formatso that the frontend and scripts never have to parse two different shapes
6Structured logsreading logs by eye in production is not an option
7Tests that catch regressions at the HTTP levela side project without tests breaks in its second month
8Deployment with no manual stepsotherwise updates simply never ship

Architecture

A modular NestJS structure that separates the infrastructure layer from the domain module:

src/
├── core/                     # infrastructure (global module)
│   ├── cache/                # Redis via Cacheable + keyv
│   ├── logger/               # winston: JSON in production, colourised in dev
│   ├── middleware/logger/    # logging of every HTTP request
│   └── interceptors/         # unified response shape { data, meta }
├── database/                 # the Prisma client as an injectable service
├── auth/                     # AuthGuard based on x-api-key
├── modules/url/              # domain module: controller, service, DTOs, pipe
├── services/
│   ├── uid/                  # identifier generation (nanoid)
│   └── pagination/           # pagination and filtering
└── utils/                    # database readiness wait, test fixtures

The guiding principle: the domain module knows nothing about the infrastructure. UrlService talks to DatabaseService, UidService, PaginationService and ConfigService, all injected through DI — which is why unit tests can swap them for mocks without touching a single line of infrastructure code.

The path a request takes through the application:

Data model

prisma
model Url {
  id          Int      @id @default(autoincrement())
  redirect    String              // where we send the visitor
  url         String   @unique    // the complete short link itself
  title       String
  description String?
  clicks      Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

The unique index on url is not merely a lookup convenience but database-level protection against generator collisions: two identical short links physically cannot be stored.


Key decisions

1. The existence check lives in a pipe, not in the controller

Three endpoints — GET /:uid, PATCH /url/:uid and DELETE /url/:uid — would each have started with the same five lines of “look it up by uid, throw a 404 if it isn’t there”. Instead there is a custom pipe that turns the string route parameter straight into an entity:

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

What remains in the controller is a declarative signature — the handler receives a ready-made object:

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

A welcome side effect: the 404 is guaranteed to be identical on every route, and it cannot be forgotten.

2. A single response contract through a global interceptor

TransformResponseInterceptor wraps any result into { data }, and a paginated one into { data, meta }. The services meanwhile return clean domain objects and know nothing about the transport format.

3. Pagination that ships ready-made links to adjacent pages

PaginationService computes the result set and the total count from one and the same where, and its meta returns not just numbers but fully assembled URLs for the next and previous pages:

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

Search is a case-insensitive contains across title, description and url simultaneously, so that a single query string covers the “find that link I made back in May” scenario.

4. Validation at the application boundary

A global ValidationPipe with whitelist: true and transform: true, plus DTOs built on class-validator: @IsUrl() on the redirect field, @Min(1) and type coercion for the pagination query parameters. Anything absent from the DTO is stripped out of the request body — a client cannot award itself clicks: 9999. That scenario is pinned down by a dedicated e2e test.

5. Security at the boundaries

  • helmet() at application level;
  • AuthGuard on the x-api-key header, with the key read via getOrThrow — the application fails at startup if the key is missing, rather than quietly running with wide-open CRUD;
  • the redirect is the only thing that stays public; everything else is locked down.

6. Logging that machines can read

winston in two modes: a colourised, human-readable format in development, and timestamped JSON in production that drops straight into any log collector. Middleware records every completed response and picks the level from the status code (5xx → error, 4xx → warn, everything else → info); in the test environment it switches off so as not to pollute jest output.

The redirect scenario end to end


Testing: three levels instead of one

The largest part of the work. The tests are split by cost and by purpose:

LevelFilesWhat it verifiesDependencies
Unit*.spec.tsservice logic, guard, pipeeverything mocked
Integration*.int-spec.tswhat the service actually writes to and reads from the databasePostgreSQL in Docker
E2E*.e2e-spec.tsthe whole HTTP contract: status codes, bodies, authenticationapplication + database + Redis

Fast and heavyweight tests are separated into different jest configs: npm run test runs only the unit tests across src, while npm run test:e2e brings up the environment and runs the integration and e2e tests in a single pass with --runInBand, so that they do not fight over one database.

Isolation between tests is not a database rebuild but a TRUNCATE ... CASCADE across every table except _prisma_migrations, executed in afterEach. That is noticeably faster than applying migrations before each test, and every test still starts from a clean state.

The e2e suite also covers the unpleasant cases that refactoring breaks easily: a request without an API key → 401, with the wrong key → 401, an empty body → 400, an invalid URL in redirect → 400, an attempt to pass extra fields → they are discarded by validation.


Infrastructure and CI/CD

Docker. A multi-stage build on node:20-alpine: the build stage installs dependencies through corepack/pnpm with --frozen-lockfile, generates the Prisma client and compiles the application; only dist, node_modules and the Prisma artefacts move into the runtime image. schema.prisma and the migrations folder are copied into the final image separately — without them prisma migrate deploy has nothing to work with in production.

Two compose files. A regular one for local development and a separate one for tests, with its own ports (5444 for Postgres, 6380 for Redis), its own .env.test, its own network and healthchecks on both services.

Deployment is triggered not by a push but by the workflow_run event from the test workflow, and it checks out precisely the commit that was tested. Red CI physically cannot reach production. The image is published to GHCR under several tags at once: main for “the latest version” and sha-<commit> — so that a rollback is a one-line change rather than a rebuild.


Problems and how they were solved

1. Flaky e2e failures in CI. Everything green locally, intermittent database connection errors in Actions. The cause is a classic one: docker compose up -d returns control once the container is running, not once Postgres is ready to accept connections. The fix has two layers: healthchecks using pg_isready and redis-cli ping together with the --wait flag, and a custom wait script that first checks that the TCP port is reachable and then performs a genuine prisma.$connect() — up to 120 attempts at one-second intervals. Checking through Prisma specifically matters: an open port does not yet mean the database is reachable by the user the migrations will run as.

2. Port conflicts between the development and test environments. Test runs interfered with the local development database. I separated the environments completely: a dedicated compose file, a fixed port 5444, separate container, network and volume names, and a separate .env.test loaded through dotenv-cli.

3. Prisma inside the container. The first working image build failed in production while applying migrations — neither the schema nor the migrations made it into the runtime layer. On top of that, client generation has to happen inside the build for the target platform rather than being dragged across from the developer machine: installation runs with --ignore-scripts, and prisma generate is invoked explicitly at the right moment.

4. Migrating CI to pnpm. The project used pnpm locally while the workflows used npm; as a result the lockfile was not honoured and CI picked up dependency versions I never had. I unified everything on one package manager and extracted installation into a reusable composite action.

5. Redis configuration from environment variables. Different hosting providers offer different sets: sometimes only a password, sometimes a username/password pair, sometimes neither. The connection string is assembled dynamically with URL-encoded credentials, so special characters in the password do not break the connection.

What comes next

An honest list of what I know about the project and where it is heading:

  • A short domain instead of a subdomain.
  • Redirect caching. Redis and a wrapper around it are already wired in, but the hot path GET /:uid still goes to the database. The next step is caching uid → redirect with invalidation on update and delete: it is the service’s most frequent query and an ideal candidate for a cache.
  • The click counter. The increment deliberately does not block the redirect, but it also has no delivery guarantees. The right evolution is buffering and writing in batches.
  • Rate limiting on the public redirect and validation of target URLs — protection against the service being used as an open redirector.
  • Users instead of a single API key, if anyone besides me ever uses the service.
  • Cursor-based pagination instead of offset — once there are enough links for the difference to show.

The results

40

Automated tests in the project

≈1 min 15 s

Image build and deployment

5

API endpoints

Screens