Skip to content

tom-dorcely/code-exercise-java

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

URL Shortener

A small URL shortener built for the coding exercise brief.

  • Backend — Spring Boot 3.3 / Java 21 with Spring MVC, Spring Data JPA and PostgreSQL.
  • Frontend — Next.js 16 (App Router) / React 19 with Tailwind v4.
  • Tests — every feature was developed test-first (JUnit 5, Mockito, @WebMvcTest, @DataJpaTest + Testcontainers; Vitest, React Testing Library + MSW).
  • Containerised — one docker compose up for db + api + ui.

Architecture at a glance

┌──────────────┐    HTTP/JSON    ┌──────────────┐    JDBC    ┌──────────────┐
│ Next.js UI   │ ──────────────► │ Spring Boot  │ ─────────► │ PostgreSQL   │
│ :3000        │                 │ :8080        │            │ :5432        │
└──────────────┘                 └──────────────┘            └──────────────┘

Backend layout (backend/src/main/java/com/example/urlshortener/):

Package Responsibility
domain Value objects (Alias, FullUrl) and the Base62AliasEncoder (id → alias). Pure Java, fully unit-tested.
persistence ShortUrlEntity + Spring Data ShortUrlRepository, plus the short_url_id_seq sequence in schema.sql.
application ShortUrlService — implements the shorten flow (dedup → next id → encode → save), plus custom-alias guard and find/list/delete.
web DTOs, UrlController, GlobalExceptionHandler, ApiError.
config AppProperties (app.*), ApplicationConfig, CorsConfig.

Alias generation

 1. input: longURL
 2. longURL in DB?  ─yes─►  3. return existing shortURL
          │ no
          ▼
 4. nextval('short_url_id_seq')   ──►   5. base62-encode the id   ──►   6. save (id, alias, longURL)

A monotonic database sequence feeds a bijective base62 encoder, so two distinct ids always produce two distinct aliases — random collisions between auto-generated aliases are eliminated by construction. Custom aliases share the same alias column (protected by a unique index); on the rare chance an encoded id happens to shadow an existing custom alias, the service walks forward in the sequence until it finds a free slot.

API

Implements openapi.yaml. All endpoints are JSON.

Method Path Status codes Notes
POST /shorten 201, 400 Body: { "fullUrl": "...", "customAlias": "..." } (alias optional)
GET /{alias} 302 (Location), 404, 400 Public redirect endpoint
DELETE /{alias} 204, 404, 400
GET /urls 200 Returns [{ alias, fullUrl, shortUrl }]

Validation rules:

  • fullUrl must be http/https, ≤ 2048 chars, with a host.
  • customAlias (and any path alias) must match ^[A-Za-z0-9_-]+$, ≤ 64 chars, and is not one of the reserved names (shorten, urls).
  • A duplicate custom alias yields 400 with Alias '...' is already taken.
  • A blank customAlias is treated as "no alias" and a random one is generated.

Errors use a uniform envelope:

{
  "timestamp": "2026-05-03T18:00:00Z",
  "status": 400,
  "error": "Bad Request",
  "message": "URL must use the http or https scheme"
}

Quick start (Docker)

Prereqs: Docker + Docker Compose.

docker compose up --build

When everything is healthy:

The frontend image is built with NEXT_PUBLIC_API_BASE_URL=http://localhost:8080. To point it at a different backend, override that build arg in docker-compose.yml (or rebuild with docker compose build --build-arg NEXT_PUBLIC_API_BASE_URL=...).

Local development (without Docker)

1. Start PostgreSQL

docker run --rm -d --name url-shortener-db \
  -e POSTGRES_USER=urlshortener \
  -e POSTGRES_PASSWORD=urlshortener \
  -e POSTGRES_DB=urlshortener \
  -p 5432:5432 postgres:16-alpine

2. Run the backend

cd backend
./mvnw spring-boot:run    # or: mvn spring-boot:run

Configurable via env (defaults shown):

Env var Default
APP_PUBLIC_BASE_URL http://localhost:8080
APP_CORS_ALLOWED_ORIGINS http://localhost:3000
SPRING_DATASOURCE_URL jdbc:postgresql://localhost:5432/urlshortener
SPRING_DATASOURCE_USERNAME urlshortener
SPRING_DATASOURCE_PASSWORD urlshortener
SPRING_JPA_HIBERNATE_DDL_AUTO update

3. Run the frontend

cd frontend
npm install
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 npm run dev

Open http://localhost:3000.

Running the tests

Backend

cd backend
mvn test

What runs:

  • Pure unitAliasTest, FullUrlTest, Base62AliasEncoderTest, ShortUrlServiceTest (Mockito).
  • Web sliceUrlControllerTest, CorsConfigTest (@WebMvcTest).
  • Persistence sliceShortUrlRepositoryTest (@DataJpaTest against a Testcontainers Postgres).
  • Full contextUrlShortenerApplicationTests (@SpringBootTest).

The repository / Spring-context tests need a Docker daemon (Testcontainers spins up postgres:16-alpine).

Frontend

cd frontend
npm test            # vitest run, headless
npm run test:watch  # watch mode
npm run lint        # eslint

Covers the typed API client (with MSW), the ShortenForm and UrlList components, and the HomeView orchestrator (load → submit → refresh, error propagation, delete).

Example usage

From the UI

  1. Open http://localhost:3000.
  2. Paste a URL into "Full URL" (e.g. https://nextjs.org/docs).
  3. (Optional) type a custom alias like docs.
  4. Click Shorten — the new entry appears in the list with a copyable short URL.
  5. Click Delete to remove it; click the short URL to follow the redirect.

From the API

# Create with an auto-generated alias (id from short_url_id_seq, base62-encoded)
curl -i -X POST http://localhost:8080/shorten \
  -H 'Content-Type: application/json' \
  -d '{"fullUrl":"https://nextjs.org/docs"}'
# HTTP/1.1 201
# {"shortUrl":"http://localhost:8080/B"}      # for id=1; later ids grow as needed

# Create with a custom alias
curl -i -X POST http://localhost:8080/shorten \
  -H 'Content-Type: application/json' \
  -d '{"fullUrl":"https://nextjs.org/docs","customAlias":"docs"}'
# HTTP/1.1 201
# {"shortUrl":"http://localhost:8080/docs"}

# Follow the redirect
curl -i http://localhost:8080/docs
# HTTP/1.1 302
# Location: https://nextjs.org/docs

# List all
curl http://localhost:8080/urls

# Delete
curl -i -X DELETE http://localhost:8080/docs
# HTTP/1.1 204

Notes & assumptions

  • Persistence — chose Postgres over an embedded H2 so the deployed shape matches what tests run against (Testcontainers). The table is auto-managed by Hibernate (ddl-auto=update); the short_url_id_seq sequence is created by schema.sql (idempotent CREATE SEQUENCE IF NOT EXISTS). A real deployment would use Flyway/Liquibase to manage both.
  • Alias generation — a database sequence (short_url_id_seq) feeds a bijective base62 encoder, so distinct ids always produce distinct aliases. Auto-vs-auto random collisions are mathematically impossible. The custom-alias path is a separate guard that returns 400 immediately if the alias is taken; if a future encoded id ever shadows an existing custom alias, the service walks forward in the sequence until it finds a free slot.
  • Dedup on the auto-alias path — shortening the same URL twice without a custom alias returns the existing entry instead of creating a second one. The custom-alias path skips this on purpose: if you explicitly ask for /foo, you get /foo (or 400 if it's taken), even if the URL is already shortened under another alias.
  • Reserved aliasesshorten and urls are blocked at the Alias value object level so they can't shadow API routes.
  • Error modelIllegalArgumentException from value objects and AliasAlreadyTakenException both map to 400 with the exception message in the response body, so the UI can show it verbatim.
  • CORS — driven by app.cors-allowed-origins. Defaults to http://localhost:3000 for local dev; in compose it's set to the same.
  • Reading customAlias = " " — treated as "no alias provided" both in the controller and the API client, so a stray whitespace from the form doesn't break shortening.
  • Frontend state — kept inside HomeView (no global store) since the page has a single concern. Initial load lives in an async IIFE inside useEffect to keep React 19's set-state-in-effect lint rule happy.
  • Time spent — roughly half a day (≈4 hours) end-to-end across backend, frontend, tests, dockerisation and docs.

Exercise brief

The original task description is preserved below for context.

Task

Build a simple URL shortener in a preferably JVM-based language (e.g. Java, Kotlin).

It should:

  • Accept a full URL and return a shortened URL.
  • A shortened URL should have a randomly generated alias.
  • Allow a user to customise the shortened URL if they want to (e.g. user provides my-custom-alias instead of a random string).
  • Persist the shortened URLs across restarts.
  • Expose a decoupled web frontend built with a modern framework (e.g., React, Next.js, Vue.js, Angular, Flask with templates).
  • Expose a RESTful API to perform create/read/delete operations on URLs (per openapi.yaml).
  • Include the ability to delete a shortened URL via the API.
  • Have tests.
  • Be containerised (e.g. Docker).
  • Include instructions for running locally.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • Java 63.1%
  • TypeScript 34.1%
  • Dockerfile 1.6%
  • Other 1.2%