From aa61ed8786d3265cd9c1f6a189d5dab0df3a0a70 Mon Sep 17 00:00:00 2001 From: asmhatre Date: Thu, 22 Oct 2026 08:13:35 +0000 Subject: [PATCH] System design URL shortener: Spring Boot 4.1.1, base62 ids, Redis cache-aside, Redis Lua token bucket, 24 tests and live transcripts --- .gitignore | 4 + LICENSE | 21 ++++ README.md | 85 +++++++++++++ dump.rdb | Bin 0 -> 89 bytes output/01-create-and-redirect.txt | 10 ++ output/02-cache-aside-and-clicks.txt | 15 +++ output/03-negative-cache.txt | 13 ++ output/04-alias-and-validation.txt | 21 ++++ output/05-rate-limit.txt | 17 +++ output/06-expiry.txt | 5 + output/07-id-generation.txt | 24 ++++ output/08-snowflake.txt | 10 ++ output/09-test-results.txt | 25 ++++ pom.xml | 63 ++++++++++ scripts/demo.sh | 106 ++++++++++++++++ scripts/id-generation-demo.sh | 49 ++++++++ scripts/run.sh | 8 ++ scripts/test-report.sh | 17 +++ .../urlshortener/UrlShortenerApplication.java | 24 ++++ .../urlshortener/api/ApiExceptionHandler.java | 32 +++++ .../urlshortener/api/CreateUrlRequest.java | 4 + .../urlshortener/api/CreateUrlResponse.java | 5 + .../urlshortener/api/UrlController.java | 69 +++++++++++ .../urlshortener/cache/InMemoryUrlCache.java | 66 ++++++++++ .../urlshortener/cache/RedisUrlCache.java | 55 ++++++++ .../ankurm/urlshortener/cache/UrlCache.java | 26 ++++ .../com/ankurm/urlshortener/core/Base62.java | 38 ++++++ .../urlshortener/core/BlockIdGenerator.java | 37 ++++++ .../urlshortener/core/CodeScrambler.java | 46 +++++++ .../ankurm/urlshortener/core/IdGenerator.java | 6 + .../ankurm/urlshortener/core/ShortUrl.java | 5 + .../core/ShortenerProperties.java | 17 +++ .../core/SnowflakeIdGenerator.java | 54 ++++++++ .../urlshortener/core/UrlRepository.java | 38 ++++++ .../ankurm/urlshortener/core/UrlService.java | 117 ++++++++++++++++++ .../InMemoryTokenBucketRateLimiter.java | 42 +++++++ .../ratelimit/RateLimitFilter.java | 47 +++++++ .../urlshortener/ratelimit/RateLimiter.java | 9 ++ .../RedisTokenBucketRateLimiter.java | 37 ++++++ src/main/resources/application.yml | 23 ++++ src/main/resources/schema.sql | 11 ++ src/main/resources/token_bucket.lua | 31 +++++ .../com/ankurm/urlshortener/Base62Test.java | 37 ++++++ .../urlshortener/CodeScramblerTest.java | 39 ++++++ .../com/ankurm/urlshortener/MutableClock.java | 18 +++ .../ankurm/urlshortener/RateLimitTest.java | 52 ++++++++ .../SnowflakeIdGeneratorTest.java | 70 +++++++++++ .../urlshortener/SnowflakeProfileTest.java | 26 ++++ .../com/ankurm/urlshortener/TestSupport.java | 38 ++++++ .../urlshortener/UrlShortenerApiTest.java | 99 +++++++++++++++ src/test/resources/application-inmemory.yml | 6 + 51 files changed, 1717 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dump.rdb create mode 100644 output/01-create-and-redirect.txt create mode 100644 output/02-cache-aside-and-clicks.txt create mode 100644 output/03-negative-cache.txt create mode 100644 output/04-alias-and-validation.txt create mode 100644 output/05-rate-limit.txt create mode 100644 output/06-expiry.txt create mode 100644 output/07-id-generation.txt create mode 100644 output/08-snowflake.txt create mode 100644 output/09-test-results.txt create mode 100644 pom.xml create mode 100755 scripts/demo.sh create mode 100755 scripts/id-generation-demo.sh create mode 100755 scripts/run.sh create mode 100755 scripts/test-report.sh create mode 100644 src/main/java/com/ankurm/urlshortener/UrlShortenerApplication.java create mode 100644 src/main/java/com/ankurm/urlshortener/api/ApiExceptionHandler.java create mode 100644 src/main/java/com/ankurm/urlshortener/api/CreateUrlRequest.java create mode 100644 src/main/java/com/ankurm/urlshortener/api/CreateUrlResponse.java create mode 100644 src/main/java/com/ankurm/urlshortener/api/UrlController.java create mode 100644 src/main/java/com/ankurm/urlshortener/cache/InMemoryUrlCache.java create mode 100644 src/main/java/com/ankurm/urlshortener/cache/RedisUrlCache.java create mode 100644 src/main/java/com/ankurm/urlshortener/cache/UrlCache.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/Base62.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/BlockIdGenerator.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/CodeScrambler.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/IdGenerator.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/ShortUrl.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/ShortenerProperties.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/SnowflakeIdGenerator.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/UrlRepository.java create mode 100644 src/main/java/com/ankurm/urlshortener/core/UrlService.java create mode 100644 src/main/java/com/ankurm/urlshortener/ratelimit/InMemoryTokenBucketRateLimiter.java create mode 100644 src/main/java/com/ankurm/urlshortener/ratelimit/RateLimitFilter.java create mode 100644 src/main/java/com/ankurm/urlshortener/ratelimit/RateLimiter.java create mode 100644 src/main/java/com/ankurm/urlshortener/ratelimit/RedisTokenBucketRateLimiter.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/schema.sql create mode 100644 src/main/resources/token_bucket.lua create mode 100644 src/test/java/com/ankurm/urlshortener/Base62Test.java create mode 100644 src/test/java/com/ankurm/urlshortener/CodeScramblerTest.java create mode 100644 src/test/java/com/ankurm/urlshortener/MutableClock.java create mode 100644 src/test/java/com/ankurm/urlshortener/RateLimitTest.java create mode 100644 src/test/java/com/ankurm/urlshortener/SnowflakeIdGeneratorTest.java create mode 100644 src/test/java/com/ankurm/urlshortener/SnowflakeProfileTest.java create mode 100644 src/test/java/com/ankurm/urlshortener/TestSupport.java create mode 100644 src/test/java/com/ankurm/urlshortener/UrlShortenerApiTest.java create mode 100644 src/test/resources/application-inmemory.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2e433c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +*.class +.idea/ +*.iml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aa5473f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ankur Mhatre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..04a004c --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# system-design-url-shortener + +A URL shortener built the way a system-design interview asks you to design one, then actually run: +base62 ids, a block-allocating id generator, scrambled 7-character codes, Redis cache-aside with negative +caching, click counters, and a Redis token-bucket rate limiter written as one atomic Lua script. + +Companion to [System Design Interview for Java Developers: Building a URL Shortener with Spring Boot](https://ankurm.com/system-design-interview-url-shortener-spring-boot/). + +## Versions + +| Component | Version | Verified against | +|---|---|---| +| Spring Boot | 4.1.1 | `spring-boot-starter-parent` metadata on Maven Central; `Starting ... Spring Boot (v4.1.1)` in the test log | +| Spring Framework | 7.0.9 | `spring-beans-7.0.9.jar` in the packaged app | +| Tomcat | 11.0.24 | startup log | +| JDK | 25.0.4.1+1 (Temurin LTS); source level 21 | `java -version` | +| PostgreSQL | 16.15 | `select version()` (H2 in PostgreSQL mode for the automated tests) | +| Redis | 7.0.15 | `redis-server --version` | + +## Run it + +```bash +# PostgreSQL on :5433 with an empty database `urlshort`, Redis on :6379 +./scripts/run.sh # builds and starts the app on :8080 +./scripts/demo.sh # curl transcripts 01-06 into output/ +./scripts/id-generation-demo.sh # transcripts 07-08 (starts its own instances on :8082 and :8083) +./scripts/test-report.sh # 24 tests, one line each, into output/09-test-results.txt +``` + +`mvn test` needs neither PostgreSQL nor Redis: the `inmemory` profile swaps in H2, an in-memory cache and an +in-memory token bucket. Time is injected (`java.time.Clock`), so TTL and refill tests never sleep. + +**Run with `-Duser.timezone=UTC`** (the scripts do). The PostgreSQL JDBC driver sends the JVM's time zone to the +server, and a legacy zone name such as `Asia/Calcutta` can be rejected with `invalid value for parameter "TimeZone"`. + +## Endpoints + +| Method and path | What it does | Success | Failures | +|---|---|---|---| +| `POST /api/v1/urls` | Create a link. Body: `{"url": "...", "alias": "optional", "ttlSeconds": optional}` | 201 + `Location` | 400 invalid URL or alias, 409 alias taken, 429 rate limited | +| `GET /{code}` | Redirect | 302 + `Location` | 404 unknown, 410 expired | +| `GET /api/v1/urls/{code}/stats` | Target, click count, dates | 200 | 404 | +| `GET /api/v1/debug/cache-stats` | **Diagnostic, delete before shipping.** Cache hits and misses | 200 | | + +## Profiles and settings + +| Setting | Default | Effect | +|---|---|---| +| profile `snowflake` | off | Snowflake ids instead of database-allocated blocks; codes become 9-11 characters | +| profile `inmemory` | off (tests) | H2 + in-memory cache and rate limiter, no Redis or PostgreSQL | +| `app.rate-limit.capacity` / `refill-per-second` | 5 / 0.5 | Token bucket per client address, on `POST /api/v1/urls` only | +| `app.cache-ttl` / `app.negative-cache-ttl` | 1h / 60s | Cache lifetime for hits / for "no such code" | +| `app.node-id` | 1 | Snowflake node (0-1023); must differ per instance | + +## Layout + +``` +src/main/java/com/ankurm/urlshortener/ + api/ UrlController, request/response records, ApiExceptionHandler (RFC 9457 problem details) + core/ UrlService, UrlRepository (JdbcClient), Base62, CodeScrambler (40-bit Feistel), + IdGenerator, BlockIdGenerator, SnowflakeIdGenerator, ShortenerProperties + cache/ UrlCache, RedisUrlCache, InMemoryUrlCache + ratelimit/ RateLimiter, RedisTokenBucketRateLimiter (+ token_bucket.lua), InMemory..., RateLimitFilter +src/main/resources/ application.yml, schema.sql, token_bucket.lua +src/test/java/... Base62Test, CodeScramblerTest, SnowflakeIdGeneratorTest, SnowflakeProfileTest, + UrlShortenerApiTest, RateLimitTest +``` + +## Captured output (`output/`) + +| File | What it shows | +|---|---| +| `01-create-and-redirect.txt` | 201 with `Location`, then a 302 to the long URL | +| `02-cache-aside-and-clicks.txt` | First redirect misses and fills Redis (TTL 3600), the next nine hit; click counter in Redis | +| `03-negative-cache.txt` | Unknown code: first lookup misses, second is answered from the `-` marker with TTL 60 | +| `04-alias-and-validation.txt` | Alias conflict 409, `ftp:` rejected, reserved alias rejected | +| `05-rate-limit.txt` | Burst of 5, then 429 with `Retry-After`, refill after 2.2 s, redirects never limited, the bucket as stored in Redis | +| `06-expiry.txt` | A 2-second link: 302, then 410; the Redis TTL is capped at the link's remaining life | +| `07-id-generation.txt` | Ids and codes; a restart skips the rest of a block; two instances never overlap | +| `08-snowflake.txt` | The snowflake profile: 10-character codes from 57-bit ids | +| `09-test-results.txt` | All 24 tests, generated from the Surefire XML report | + +## License + +MIT, see [LICENSE](LICENSE). diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..87de9b01e1353a4eb0e3ef249196b7dba8537a1a GIT binary patch literal 89 zcmWG?b@2=~FfcUu#aWb^l3A=5xUrsf`EU=U&W#gUkwrkj*loO*!a|MM?PE8@~mwE+OY_#^}X literal 0 HcmV?d00001 diff --git a/output/01-create-and-redirect.txt b/output/01-create-and-redirect.txt new file mode 100644 index 0000000..7ed18b2 --- /dev/null +++ b/output/01-create-and-redirect.txt @@ -0,0 +1,10 @@ +$ curl -si -X POST /api/v1/urls -d '{"url":"https://ankurm.com/java-interview-preparation-guide/"}' +HTTP/1.1 201 +X-RateLimit-Remaining: 4 +Location: http://localhost:8080/6MelkkB +{"code":"6MelkkB","shortUrl":"http://localhost:8080/6MelkkB","expiresAt":null} + +$ curl -si -X GET /6MelkkB +HTTP/1.1 302 +Location: https://ankurm.com/java-interview-preparation-guide/ + diff --git a/output/02-cache-aside-and-clicks.txt b/output/02-cache-aside-and-clicks.txt new file mode 100644 index 0000000..4b93a36 --- /dev/null +++ b/output/02-cache-aside-and-clicks.txt @@ -0,0 +1,15 @@ +code = 7qqChen +$ redis-cli EXISTS url:7qqChen # before the first redirect +0 +--- after 1st redirect (cache miss, database read, cache filled) +$ redis-cli GET url:7qqChen +https://example.com/cache-demo +$ redis-cli TTL url:7qqChen +3600 +cache hits +0, misses +1 +--- after 9 more redirects +cache hits +9, misses +1 +$ redis-cli GET clicks:7qqChen +10 +$ curl -s http://127.0.0.1:8080/api/v1/urls/7qqChen/stats +{"code":"7qqChen","longUrl":"https://example.com/cache-demo","clicks":10,... diff --git a/output/03-negative-cache.txt b/output/03-negative-cache.txt new file mode 100644 index 0000000..101a5c5 --- /dev/null +++ b/output/03-negative-cache.txt @@ -0,0 +1,13 @@ +$ curl -si -X GET /Zq9Zq9 +HTTP/1.1 404 +{"detail":"no such code: Zq9Zq9","instance":"/Zq9Zq9","status":404,"title":"Not Found"} + +$ curl -si -X GET /Zq9Zq9 +HTTP/1.1 404 +{"detail":"no such code: Zq9Zq9","instance":"/Zq9Zq9","status":404,"title":"Not Found"} + +cache misses: before=2 after-first-404=3 after-second-404=3 +$ redis-cli GET url:Zq9Zq9 # the negative-cache marker +- +$ redis-cli TTL url:Zq9Zq9 +60 diff --git a/output/04-alias-and-validation.txt b/output/04-alias-and-validation.txt new file mode 100644 index 0000000..aa05ffb --- /dev/null +++ b/output/04-alias-and-validation.txt @@ -0,0 +1,21 @@ +$ curl -si -X POST /api/v1/urls -d '{"url":"https://example.com/one","alias":"demo-link"}' +HTTP/1.1 201 +X-RateLimit-Remaining: 4 +Location: http://localhost:8080/demo-link +{"code":"demo-link","shortUrl":"http://localhost:8080/demo-link","expiresAt":null} + +$ curl -si -X POST /api/v1/urls -d '{"url":"https://example.com/two","alias":"demo-link"}' +HTTP/1.1 409 +X-RateLimit-Remaining: 3 +{"detail":"alias already taken: demo-link","instance":"/api/v1/urls","status":409,"title":"Conflict"} + +$ curl -si -X POST /api/v1/urls -d '{"url":"ftp://example.com/file"}' +HTTP/1.1 400 +X-RateLimit-Remaining: 2 +{"detail":"only http and https URLs are allowed","instance":"/api/v1/urls","status":400,"title":"Bad Request"} + +$ curl -si -X POST /api/v1/urls -d '{"url":"https://example.com","alias":"api"}' +HTTP/1.1 400 +X-RateLimit-Remaining: 1 +{"detail":"alias must be 4-32 chars of A-Z a-z 0-9 _ -","instance":"/api/v1/urls","status":400,"title":"Bad Request"} + diff --git a/output/05-rate-limit.txt b/output/05-rate-limit.txt new file mode 100644 index 0000000..aaa3346 --- /dev/null +++ b/output/05-rate-limit.txt @@ -0,0 +1,17 @@ +capacity 5, refill 0.5 tokens/second (one every 2 s) +request 1 -> HTTP/1.1 201 X-RateLimit-Remaining: 4 +request 2 -> HTTP/1.1 201 X-RateLimit-Remaining: 3 +request 3 -> HTTP/1.1 201 X-RateLimit-Remaining: 2 +request 4 -> HTTP/1.1 201 X-RateLimit-Remaining: 1 +request 5 -> HTTP/1.1 201 X-RateLimit-Remaining: 0 +request 6 -> HTTP/1.1 429 X-RateLimit-Remaining: 0 Retry-After: 2 +request 7 -> HTTP/1.1 429 X-RateLimit-Remaining: 0 Retry-After: 2 +$ redis-cli HGETALL rl:127.0.0.1 +tokens +0.046000000000000082 +ts +1792656760797 +--- sleeping 2.2 s +request 8 -> HTTP/1.1 201 X-RateLimit-Remaining: 0 +--- redirects are not limited: 30 GETs in a row +302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 diff --git a/output/06-expiry.txt b/output/06-expiry.txt new file mode 100644 index 0000000..8d1a524 --- /dev/null +++ b/output/06-expiry.txt @@ -0,0 +1,5 @@ +created ArYvqWu with ttlSeconds=2 +t=0s -> 302 +$ redis-cli TTL url:ArYvqWu # capped at the link's remaining life, not the 1 h default +2 +t=3s -> 410 diff --git a/output/07-id-generation.txt b/output/07-id-generation.txt new file mode 100644 index 0000000..5a3c4e5 --- /dev/null +++ b/output/07-id-generation.txt @@ -0,0 +1,24 @@ +--- instance A: three links + id | code | len +----+---------+----- + 1 | 6MelkkB | 7 + 2 | 7qqChen | 7 + 3 | J4Wn4LP | 7 +(3 rows) + +--- instance A stopped and restarted (ids 4..100 of its first block are never used) + id | code | len +-----+---------+----- + 1 | 6MelkkB | 7 + 2 | 7qqChen | 7 + 3 | J4Wn4LP | 7 + 101 | IZKi2wO | 7 +(4 rows) + +--- two instances running at once never overlap: B starts while A is alive + id | code +-----+--------- + 102 | DDCfjej + 201 | Globq13 +(2 rows) + diff --git a/output/08-snowflake.txt b/output/08-snowflake.txt new file mode 100644 index 0000000..fd4c89d --- /dev/null +++ b/output/08-snowflake.txt @@ -0,0 +1,10 @@ +code=7sWzEmdQLw length=10 +code=7sWzF1xlNQ length=10 +code=7sWzF7djyi length=10 + id | code +--------------------+------------ + 106666141551718400 | 7sWzEmdQLw + 106666141778210816 | 7sWzF1xlNQ + 106666141862096896 | 7sWzF7djyi +(3 rows) + diff --git a/output/09-test-results.txt b/output/09-test-results.txt new file mode 100644 index 0000000..3b10d6e --- /dev/null +++ b/output/09-test-results.txt @@ -0,0 +1,25 @@ +PASS Base62Test.knownValues +PASS Base62Test.roundTripsAcrossTheRange +PASS Base62Test.rejectsGarbage +PASS Base62Test.sevenCharactersCoverAboutThreePointFiveTrillion +PASS CodeScramblerTest.noTwoIdsShareACodeAcrossTwoMillionConsecutiveIds +PASS CodeScramblerTest.everyCodeIsExactlySevenCharacters +PASS CodeScramblerTest.idsBeyondTheFortyBitDomainFallBackToPlainBase62 +PASS CodeScramblerTest.consecutiveIdsDoNotGiveConsecutiveCodes +PASS RateLimitTest.burstOfFiveThenRejectedThenRefilled +PASS RateLimitTest.redirectsAreNeverRateLimited +PASS SnowflakeIdGeneratorTest.idsIncreaseWithinOneThread +PASS SnowflakeIdGeneratorTest.base62CodeIsLongerThanForASequenceId +PASS SnowflakeIdGeneratorTest.refusesToIssueIdsWhenTheClockGoesBackwards +PASS SnowflakeIdGeneratorTest.idsAreUniqueAcrossEightThreads +PASS SnowflakeIdGeneratorTest.nodeIdIsEncodedInTheId +PASS SnowflakeIdGeneratorTest.rejectsOutOfRangeNodeId +PASS SnowflakeProfileTest.snowflakeIdsGiveLongerCodes +PASS UrlShortenerApiTest.sameLongUrlGetsAnIndependentCodeEachTime +PASS UrlShortenerApiTest.customAliasWorksAndSecondUseIsAConflict +PASS UrlShortenerApiTest.unknownCodeIs404AndTheMissIsNegativelyCached +PASS UrlShortenerApiTest.createsAShortLinkAndRedirectsWith302 +PASS UrlShortenerApiTest.countsClicks +PASS UrlShortenerApiTest.rejectsBadInput +PASS UrlShortenerApiTest.expiredLinkIs410 +24 tests, 0 failures diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..08db362 --- /dev/null +++ b/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + system-design-url-shortener + 1.0.0 + system-design-url-shortener + System design interview: a URL shortener with base62 ids, Redis cache-aside and a Redis token bucket + + + 21 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.postgresql + postgresql + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..7f2cf32 --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Exercises a RUNNING instance (java -jar ... on :8080, PostgreSQL and Redis up) and writes every transcript in output/. +# Each section resets the per-client rate-limit bucket first, so sections do not affect each other. +set -uo pipefail +cd "$(dirname "$0")/.." +H=http://127.0.0.1:8080 +PSQL=${PSQL:-"psql -h /tmp -p 5433 -U pgrun -d urlshort -X -q"} +mkdir -p output + +reset_bucket() { redis-cli --raw DEL rl:127.0.0.1 > /dev/null; } + +# show METHOD PATH [json] -> status line + the headers that matter + body +show() { + local method=$1 path=$2 body=${3:-} + echo "\$ curl -si -X $method $path${body:+ -d '$body'}" + if [ -n "$body" ]; then + curl -si -X "$method" -H 'Content-Type: application/json' -d "$body" "$H$path" + else + curl -si -X "$method" "$H$path" + fi | tr -d '\r' | grep -E '^(HTTP/|Location:|Retry-After:|X-RateLimit-Remaining:|\{|$)' | grep -v '^$' + echo +} +code_of() { sed -n 's/.*"code":"\([^"]*\)".*/\1/p'; } + +{ + reset_bucket + BODY='{"url":"https://ankurm.com/java-interview-preparation-guide/"}' + echo "\$ curl -si -X POST /api/v1/urls -d '$BODY'" + RESP=$(curl -si -X POST -H 'Content-Type: application/json' -d "$BODY" $H/api/v1/urls | tr -d '\r') + echo "$RESP" | grep -E '^(HTTP/|Location:|X-RateLimit-Remaining:|\{)'; echo + C=$(echo "$RESP" | code_of) + show GET "/$C" +} > output/01-create-and-redirect.txt 2>&1 + +{ + reset_bucket + C=$(curl -s -X POST -H 'Content-Type: application/json' -d '{"url":"https://example.com/cache-demo"}' $H/api/v1/urls | code_of) + stat() { curl -s $H/api/v1/debug/cache-stats | sed 's/.*"hits":\([0-9]*\).*"misses":\([0-9]*\).*/\1 \2/; s/.*"misses":\([0-9]*\).*"hits":\([0-9]*\).*/\2 \1/'; } + read h0 m0 < <(stat) + delta() { read h m < <(stat); echo "cache hits +$((h-h0)), misses +$((m-m0))"; } + echo "code = $C" + echo "\$ redis-cli EXISTS url:$C # before the first redirect" + redis-cli EXISTS "url:$C" + curl -s -o /dev/null "$H/$C" + echo "--- after 1st redirect (cache miss, database read, cache filled)" + echo "\$ redis-cli GET url:$C"; redis-cli GET "url:$C" + echo "\$ redis-cli TTL url:$C"; redis-cli TTL "url:$C" + delta + for i in 1 2 3 4 5 6 7 8 9; do curl -s -o /dev/null "$H/$C"; done + echo "--- after 9 more redirects" + delta + echo "\$ redis-cli GET clicks:$C"; redis-cli GET "clicks:$C" + echo "\$ curl -s $H/api/v1/urls/$C/stats"; curl -s "$H/api/v1/urls/$C/stats" | sed 's/"createdAt".*/.../'; echo +} > output/02-cache-aside-and-clicks.txt 2>&1 + +{ + before=$(curl -s $H/api/v1/debug/cache-stats | sed 's/.*"misses":\([0-9]*\).*/\1/') + show GET /Zq9Zq9 + mid=$(curl -s $H/api/v1/debug/cache-stats | sed 's/.*"misses":\([0-9]*\).*/\1/') + show GET /Zq9Zq9 + after=$(curl -s $H/api/v1/debug/cache-stats | sed 's/.*"misses":\([0-9]*\).*/\1/') + echo "cache misses: before=$before after-first-404=$mid after-second-404=$after" + echo "\$ redis-cli GET url:Zq9Zq9 # the negative-cache marker"; redis-cli GET url:Zq9Zq9 + echo "\$ redis-cli TTL url:Zq9Zq9"; redis-cli TTL url:Zq9Zq9 +} > output/03-negative-cache.txt 2>&1 + +{ + reset_bucket + redis-cli --raw DEL url:demo-link > /dev/null; $PSQL -c "DELETE FROM urls WHERE code='demo-link'" > /dev/null + show POST /api/v1/urls '{"url":"https://example.com/one","alias":"demo-link"}' + show POST /api/v1/urls '{"url":"https://example.com/two","alias":"demo-link"}' + show POST /api/v1/urls '{"url":"ftp://example.com/file"}' + show POST /api/v1/urls '{"url":"https://example.com","alias":"api"}' +} > output/04-alias-and-validation.txt 2>&1 + +{ + reset_bucket + echo "capacity 5, refill 0.5 tokens/second (one every 2 s)" + for i in 1 2 3 4 5 6 7; do + echo -n "request $i -> " + curl -si -X POST -H 'Content-Type: application/json' -d '{"url":"https://example.com/rl"}' $H/api/v1/urls \ + | tr -d '\r' | grep -E '^(HTTP/|Retry-After:|X-RateLimit-Remaining:)' | paste -sd' ' - + done + echo "\$ redis-cli HGETALL rl:127.0.0.1"; redis-cli HGETALL rl:127.0.0.1 + echo "--- sleeping 2.2 s"; sleep 2.2 + echo -n "request 8 -> " + curl -si -X POST -H 'Content-Type: application/json' -d '{"url":"https://example.com/rl"}' $H/api/v1/urls \ + | tr -d '\r' | grep -E '^(HTTP/|Retry-After:|X-RateLimit-Remaining:)' | paste -sd' ' - + echo "--- redirects are not limited: 30 GETs in a row" + reset_bucket + C=$(curl -s -X POST -H 'Content-Type: application/json' -d '{"url":"https://example.com/free"}' $H/api/v1/urls | code_of) + for i in $(seq 1 30); do curl -s -o /dev/null -w '%{http_code} ' "$H/${C:-x}"; done; echo +} > output/05-rate-limit.txt 2>&1 + +{ + reset_bucket + C=$(curl -s -X POST -H 'Content-Type: application/json' -d '{"url":"https://example.com/temp","ttlSeconds":2}' $H/api/v1/urls | code_of) + echo "created $C with ttlSeconds=2" + echo -n "t=0s -> "; curl -s -o /dev/null -w '%{http_code}\n' "$H/$C" + echo "\$ redis-cli TTL url:$C # capped at the link's remaining life, not the 1 h default"; redis-cli TTL "url:$C" + sleep 3 + echo -n "t=3s -> "; curl -s -o /dev/null -w '%{http_code}\n' "$H/$C" +} > output/06-expiry.txt 2>&1 + + +echo "transcripts 01-06 written to output/ (run scripts/id-generation-demo.sh for 07 and 08)" diff --git a/scripts/id-generation-demo.sh b/scripts/id-generation-demo.sh new file mode 100755 index 0000000..94d9cb3 --- /dev/null +++ b/scripts/id-generation-demo.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Starts its own instances (ports 8082 and 8083) against the same PostgreSQL + Redis, so it needs the packaged jar: +# mvn -DskipTests package +# Shows (07) block allocation across a restart and (08) the snowflake profile's longer codes. +set -uo pipefail +cd "$(dirname "$0")/.." +JAR=target/system-design-url-shortener-1.0.0.jar +PSQL=${PSQL:-"psql -h /tmp -p 5433 -U pgrun -d urlshort -X -q"} +mkdir -p output + +start() { # start PORT [extra args] ; waits until it answers + local port=$1; shift + java -Duser.timezone=UTC -jar $JAR --server.port=$port "$@" > /tmp/urlshort-$port.log 2>&1 & + echo $! > /tmp/urlshort-$port.pid + for _ in $(seq 1 60); do curl -s -o /dev/null http://127.0.0.1:$port/api/v1/debug/cache-stats && return 0; sleep 0.5; done + echo "instance on $port did not start" >&2; return 1 +} +stop() { kill "$(cat /tmp/urlshort-$1.pid)" 2>/dev/null; wait "$(cat /tmp/urlshort-$1.pid)" 2>/dev/null; } +create() { # create PORT URL -> prints the code + redis-cli --raw DEL rl:127.0.0.1 > /dev/null + curl -s -X POST -H 'Content-Type: application/json' -d "{\"url\":\"$2\"}" http://127.0.0.1:$1/api/v1/urls | sed -n 's/.*"code":"\([^"]*\)".*/\1/p' +} + +{ + $PSQL -c "TRUNCATE urls" -c "ALTER SEQUENCE url_id_seq RESTART WITH 1" + start 8082 + echo "--- instance A: three links" + for n in 1 2 3; do create 8082 "https://example.com/a$n" > /dev/null; done + $PSQL -c "SELECT id, code, length(code) AS len FROM urls ORDER BY id" + stop 8082 + echo "--- instance A stopped and restarted (ids 4..100 of its first block are never used)" + start 8082 + create 8082 "https://example.com/after-restart" > /dev/null + $PSQL -c "SELECT id, code, length(code) AS len FROM urls ORDER BY id" + echo "--- two instances running at once never overlap: B starts while A is alive" + start 8083 + create 8083 "https://example.com/from-b" > /dev/null + create 8082 "https://example.com/from-a" > /dev/null + $PSQL -c "SELECT id, code FROM urls WHERE long_url IN ('https://example.com/from-b','https://example.com/from-a') ORDER BY id" + stop 8083; stop 8082 +} > output/07-id-generation.txt 2>&1 + +{ + start 8083 --spring.profiles.active=snowflake --app.node-id=7 + for n in 1 2 3; do c=$(create 8083 "https://example.com/snow$n"); echo "code=$c length=${#c}"; done + $PSQL -c "SELECT id, code FROM urls WHERE long_url LIKE 'https://example.com/snow%' ORDER BY id" + stop 8083 +} > output/08-snowflake.txt 2>&1 +echo "transcripts 07 and 08 written to output/" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..7c877e8 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the app on :8080 against PostgreSQL (localhost:5433, db urlshort) and Redis (localhost:6379). +# -Duser.timezone=UTC matters: the PostgreSQL JDBC driver sends the JVM's zone to the server, and a legacy +# zone name such as Asia/Calcutta can be rejected ("invalid value for parameter TimeZone"). +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -DskipTests package +exec java -Duser.timezone=UTC -jar target/system-design-url-shortener-1.0.0.jar "$@" diff --git a/scripts/test-report.sh b/scripts/test-report.sh new file mode 100755 index 0000000..6a48359 --- /dev/null +++ b/scripts/test-report.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Runs the tests and writes one line per test (from the Surefire XML) to output/09-test-results.txt. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -B -q test > /dev/null +python3 - <<'PY' > output/09-test-results.txt +import glob, xml.etree.ElementTree as ET +total = fail = 0 +for f in sorted(glob.glob("target/surefire-reports/TEST-*.xml")): + root = ET.parse(f).getroot() + cls = root.get("name").rsplit(".", 1)[-1] + for tc in root.iter("testcase"): + bad = tc.find("failure") is not None or tc.find("error") is not None + total += 1; fail += bad + print(("FAIL " if bad else "PASS ") + cls + "." + tc.get("name")) +print(f"{total} tests, {fail} failures") +PY diff --git a/src/main/java/com/ankurm/urlshortener/UrlShortenerApplication.java b/src/main/java/com/ankurm/urlshortener/UrlShortenerApplication.java new file mode 100644 index 0000000..20181c1 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/UrlShortenerApplication.java @@ -0,0 +1,24 @@ +package com.ankurm.urlshortener; + +import com.ankurm.urlshortener.core.ShortenerProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +@EnableConfigurationProperties(ShortenerProperties.class) +public class UrlShortenerApplication { + + public static void main(String[] args) { + SpringApplication.run(UrlShortenerApplication.class, args); + } + + /** Injected everywhere that needs "now", so tests can move time by hand. */ + @Bean + Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/com/ankurm/urlshortener/api/ApiExceptionHandler.java b/src/main/java/com/ankurm/urlshortener/api/ApiExceptionHandler.java new file mode 100644 index 0000000..cbed99e --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/api/ApiExceptionHandler.java @@ -0,0 +1,32 @@ +package com.ankurm.urlshortener.api; + +import com.ankurm.urlshortener.core.UrlService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** RFC 9457 problem responses with the status each failure deserves. */ +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(UrlService.InvalidUrlException.class) + ProblemDetail invalid(UrlService.InvalidUrlException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler(UrlService.AliasTakenException.class) + ProblemDetail taken(UrlService.AliasTakenException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage()); + } + + @ExceptionHandler(UrlService.NotFoundException.class) + ProblemDetail notFound(UrlService.NotFoundException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); + } + + @ExceptionHandler(UrlService.ExpiredException.class) + ProblemDetail expired(UrlService.ExpiredException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.GONE, e.getMessage()); + } +} diff --git a/src/main/java/com/ankurm/urlshortener/api/CreateUrlRequest.java b/src/main/java/com/ankurm/urlshortener/api/CreateUrlRequest.java new file mode 100644 index 0000000..afbbd98 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/api/CreateUrlRequest.java @@ -0,0 +1,4 @@ +package com.ankurm.urlshortener.api; + +/** {@code ttlSeconds} and {@code alias} are optional. */ +public record CreateUrlRequest(String url, String alias, Long ttlSeconds) {} diff --git a/src/main/java/com/ankurm/urlshortener/api/CreateUrlResponse.java b/src/main/java/com/ankurm/urlshortener/api/CreateUrlResponse.java new file mode 100644 index 0000000..c1495fa --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/api/CreateUrlResponse.java @@ -0,0 +1,5 @@ +package com.ankurm.urlshortener.api; + +import java.time.Instant; + +public record CreateUrlResponse(String code, String shortUrl, Instant expiresAt) {} diff --git a/src/main/java/com/ankurm/urlshortener/api/UrlController.java b/src/main/java/com/ankurm/urlshortener/api/UrlController.java new file mode 100644 index 0000000..89cfa66 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/api/UrlController.java @@ -0,0 +1,69 @@ +package com.ankurm.urlshortener.api; + +import com.ankurm.urlshortener.cache.UrlCache; +import com.ankurm.urlshortener.core.ShortUrl; +import com.ankurm.urlshortener.core.ShortenerProperties; +import com.ankurm.urlshortener.core.UrlService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +@RestController +public class UrlController { + private final UrlService service; + private final UrlCache cache; + private final ShortenerProperties props; + + public UrlController(UrlService service, UrlCache cache, ShortenerProperties props) { + this.service = service; + this.cache = cache; + this.props = props; + } + + @PostMapping("/api/v1/urls") + public ResponseEntity create(@RequestBody CreateUrlRequest req) { + Duration ttl = req.ttlSeconds() == null ? null : Duration.ofSeconds(req.ttlSeconds()); + ShortUrl u = service.create(req.url(), req.alias(), ttl); + String shortUrl = props.baseUrl() + "/" + u.code(); + return ResponseEntity.created(URI.create(shortUrl)).body(new CreateUrlResponse(u.code(), shortUrl, u.expiresAt())); + } + + /** + * 302, not 301: a 301 is cached by browsers forever, so later clicks would never reach us and the click + * count (and the ability to expire or change the target) would be lost. + */ + @GetMapping("/{code:[A-Za-z0-9_-]{1,32}}") + public ResponseEntity redirect(@PathVariable String code) { + String target = service.resolve(code); + return ResponseEntity.status(HttpStatus.FOUND).header(HttpHeaders.LOCATION, target).build(); + } + + @GetMapping("/api/v1/urls/{code}/stats") + public Map stats(@PathVariable String code) { + ShortUrl u = service.find(code); + Map m = new java.util.LinkedHashMap<>(); + m.put("code", u.code()); + m.put("longUrl", u.longUrl()); + m.put("clicks", service.clicks(code)); + m.put("createdAt", u.createdAt()); + m.put("expiresAt", u.expiresAt()); + return m; + } + + /** DIAGNOSTIC ONLY: delete before shipping. Shows whether the cache is doing its job. */ + @GetMapping("/api/v1/debug/cache-stats") + public Map cacheStats() { + UrlCache.CacheStats s = cache.stats(); + return Map.of("hits", s.hits(), "misses", s.misses(), "at", Instant.now().toString()); + } +} diff --git a/src/main/java/com/ankurm/urlshortener/cache/InMemoryUrlCache.java b/src/main/java/com/ankurm/urlshortener/cache/InMemoryUrlCache.java new file mode 100644 index 0000000..bf0ca13 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/cache/InMemoryUrlCache.java @@ -0,0 +1,66 @@ +package com.ankurm.urlshortener.cache; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** Used by the tests ({@code inmemory} profile) so they need no Redis. Honours TTLs through the injected Clock. */ +@Component +@Profile("inmemory") +public class InMemoryUrlCache implements UrlCache { + private record Entry(String value, Instant expiresAt) {} + + private final Clock clock; + private final Map entries = new ConcurrentHashMap<>(); + private final Map clicks = new ConcurrentHashMap<>(); + private final AtomicLong hits = new AtomicLong(); + private final AtomicLong misses = new AtomicLong(); + + public InMemoryUrlCache(Clock clock) { + this.clock = clock; + } + + @Override + public Optional get(String code) { + Entry e = entries.get(code); + if (e != null && e.expiresAt().isBefore(clock.instant())) { + entries.remove(code); + e = null; + } + (e == null ? misses : hits).incrementAndGet(); + return Optional.ofNullable(e).map(Entry::value); + } + + @Override + public void put(String code, String value, Duration ttl) { + entries.put(code, new Entry(value, clock.instant().plus(ttl))); + } + + @Override + public void evict(String code) { + entries.remove(code); + } + + @Override + public long incrementClicks(String code) { + return clicks.computeIfAbsent(code, k -> new AtomicLong()).incrementAndGet(); + } + + @Override + public long clicks(String code) { + AtomicLong n = clicks.get(code); + return n == null ? 0 : n.get(); + } + + @Override + public CacheStats stats() { + return new CacheStats(hits.get(), misses.get()); + } +} diff --git a/src/main/java/com/ankurm/urlshortener/cache/RedisUrlCache.java b/src/main/java/com/ankurm/urlshortener/cache/RedisUrlCache.java new file mode 100644 index 0000000..8d93ef8 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/cache/RedisUrlCache.java @@ -0,0 +1,55 @@ +package com.ankurm.urlshortener.cache; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +@Component +@Profile("!inmemory") +public class RedisUrlCache implements UrlCache { + private final StringRedisTemplate redis; + private final AtomicLong hits = new AtomicLong(); + private final AtomicLong misses = new AtomicLong(); + + public RedisUrlCache(StringRedisTemplate redis) { + this.redis = redis; + } + + @Override + public Optional get(String code) { + String v = redis.opsForValue().get("url:" + code); + (v == null ? misses : hits).incrementAndGet(); + return Optional.ofNullable(v); + } + + @Override + public void put(String code, String value, Duration ttl) { + redis.opsForValue().set("url:" + code, value, ttl); + } + + @Override + public void evict(String code) { + redis.delete("url:" + code); + } + + @Override + public long incrementClicks(String code) { + Long n = redis.opsForValue().increment("clicks:" + code); + return n == null ? 0 : n; + } + + @Override + public long clicks(String code) { + String v = redis.opsForValue().get("clicks:" + code); + return v == null ? 0 : Long.parseLong(v); + } + + @Override + public CacheStats stats() { + return new CacheStats(hits.get(), misses.get()); + } +} diff --git a/src/main/java/com/ankurm/urlshortener/cache/UrlCache.java b/src/main/java/com/ankurm/urlshortener/cache/UrlCache.java new file mode 100644 index 0000000..6473ba6 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/cache/UrlCache.java @@ -0,0 +1,26 @@ +package com.ankurm.urlshortener.cache; + +import java.time.Duration; +import java.util.Optional; + +/** + * Hot-path store for the redirect: code to long URL (cache-aside), plus click counters. + * The value {@link #NOT_FOUND} is a negative-cache marker so that random-code scans do not hit the database. + */ +public interface UrlCache { + String NOT_FOUND = "-"; + + Optional get(String code); + + void put(String code, String value, Duration ttl); + + void evict(String code); + + long incrementClicks(String code); + + long clicks(String code); + + CacheStats stats(); + + record CacheStats(long hits, long misses) {} +} diff --git a/src/main/java/com/ankurm/urlshortener/core/Base62.java b/src/main/java/com/ankurm/urlshortener/core/Base62.java new file mode 100644 index 0000000..b1b7e58 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/Base62.java @@ -0,0 +1,38 @@ +package com.ankurm.urlshortener.core; + +/** + * Base62 turns a non-negative long into a short string over [0-9A-Za-z]. + * 62^6 = 56.8 billion codes fit in 6 characters, 62^7 = 3.5 trillion in 7. + */ +public final class Base62 { + private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + private Base62() {} + + public static String encode(long value) { + if (value < 0) throw new IllegalArgumentException("negative id: " + value); + if (value == 0) return "0"; + StringBuilder sb = new StringBuilder(); + while (value > 0) { + sb.append(ALPHABET.charAt((int) (value % 62))); + value /= 62; + } + return sb.reverse().toString(); + } + + /** Left-pads with '0' to a fixed width, so the code length never depends on the value. */ + public static String encode(long value, int width) { + String s = encode(value); + return s.length() >= width ? s : "0".repeat(width - s.length()) + s; + } + + public static long decode(String code) { + long value = 0; + for (int i = 0; i < code.length(); i++) { + int digit = ALPHABET.indexOf(code.charAt(i)); + if (digit < 0) throw new IllegalArgumentException("not base62: " + code); + value = Math.addExact(Math.multiplyExact(value, 62L), digit); + } + return value; + } +} diff --git a/src/main/java/com/ankurm/urlshortener/core/BlockIdGenerator.java b/src/main/java/com/ankurm/urlshortener/core/BlockIdGenerator.java new file mode 100644 index 0000000..160fce8 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/BlockIdGenerator.java @@ -0,0 +1,37 @@ +package com.ankurm.urlshortener.core; + +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Component; + +/** + * Default generator: one database round trip per BLOCK of ids, not per id. + * + * The sequence is declared with INCREMENT BY 100 (see schema.sql), so each nextval() claims a block of + * 100 ids for this instance. Within the block ids come from a local counter. Two instances can never + * overlap because the database hands out disjoint blocks; a crash wastes at most the rest of a block, + * which is fine because ids only need to be unique, not gap-free. + */ +@Component +@Profile("!snowflake") +public class BlockIdGenerator implements IdGenerator { + static final int BLOCK_SIZE = 100; // must match INCREMENT BY in schema.sql + + private final JdbcClient jdbc; + private long next; + private long blockEnd; // exclusive + + public BlockIdGenerator(JdbcClient jdbc) { + this.jdbc = jdbc; + } + + @Override + public synchronized long nextId() { + if (next >= blockEnd) { + Long start = jdbc.sql("SELECT nextval('url_id_seq')").query(Long.class).single(); + next = start; + blockEnd = start + BLOCK_SIZE; + } + return next++; + } +} diff --git a/src/main/java/com/ankurm/urlshortener/core/CodeScrambler.java b/src/main/java/com/ankurm/urlshortener/core/CodeScrambler.java new file mode 100644 index 0000000..871db5e --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/CodeScrambler.java @@ -0,0 +1,46 @@ +package com.ankurm.urlshortener.core; + +/** + * Turns a sequential id into a code that does not look sequential, with no lookup table and no collisions. + * + * It is a 4-round Feistel network over 40 bits (two 20-bit halves). A Feistel network is a bijection no matter what + * its round function does, so every id maps to exactly one scrambled value and no two ids share one: uniqueness is + * inherited from the id generator. The result is below 2^40 (about 1.1 trillion), which is below 62^7, so it always + * fits in 7 base62 characters (left-padded with '0'). + * + * This is obfuscation, not encryption. It stops casual enumeration ("add one to the code and see what is there"); + * it does not make codes secret. Anything that must be unguessable needs random codes (and a collision check). + */ +public final class CodeScrambler { + public static final long DOMAIN = 1L << 40; + private static final int HALF_BITS = 20; + private static final int HALF_MASK = (1 << HALF_BITS) - 1; + private static final int[] ROUND_KEYS = {0x5BD1E995, 0x1B873593, 0x68E31DA4, 0x2C9277B5}; + + private CodeScrambler() {} + + public static long scramble(long id) { + if (id < 0 || id >= DOMAIN) throw new IllegalArgumentException("id out of range for the 40-bit scrambler: " + id); + int left = (int) (id >>> HALF_BITS) & HALF_MASK; + int right = (int) id & HALF_MASK; + for (int key : ROUND_KEYS) { + int next = left ^ roundFunction(right, key); + left = right; + right = next; + } + return ((long) left << HALF_BITS) | right; + } + + private static int roundFunction(int x, int key) { + int h = x * 0x9E3779B1 + key; + h ^= h >>> 15; + h *= 0x85EBCA6B; + h ^= h >>> 13; + return h & HALF_MASK; + } + + /** 7 scrambled characters while ids fit in 40 bits; plain base62 (longer) beyond that, e.g. for snowflake ids. */ + public static String codeFor(long id) { + return id < DOMAIN ? Base62.encode(scramble(id), 7) : Base62.encode(id); + } +} diff --git a/src/main/java/com/ankurm/urlshortener/core/IdGenerator.java b/src/main/java/com/ankurm/urlshortener/core/IdGenerator.java new file mode 100644 index 0000000..f298fa0 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/IdGenerator.java @@ -0,0 +1,6 @@ +package com.ankurm.urlshortener.core; + +/** Hands out unique, never-reused ids. Two implementations, picked by Spring profile. */ +public interface IdGenerator { + long nextId(); +} diff --git a/src/main/java/com/ankurm/urlshortener/core/ShortUrl.java b/src/main/java/com/ankurm/urlshortener/core/ShortUrl.java new file mode 100644 index 0000000..beccb84 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/ShortUrl.java @@ -0,0 +1,5 @@ +package com.ankurm.urlshortener.core; + +import java.time.Instant; + +public record ShortUrl(long id, String code, String longUrl, Instant createdAt, Instant expiresAt) {} diff --git a/src/main/java/com/ankurm/urlshortener/core/ShortenerProperties.java b/src/main/java/com/ankurm/urlshortener/core/ShortenerProperties.java new file mode 100644 index 0000000..76f3437 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/ShortenerProperties.java @@ -0,0 +1,17 @@ +package com.ankurm.urlshortener.core; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** Everything tunable lives under {@code app.*}; defaults are in application.yml. */ +@ConfigurationProperties(prefix = "app") +public record ShortenerProperties( + String baseUrl, + Duration cacheTtl, + Duration negativeCacheTtl, + RateLimit rateLimit, + long nodeId) { + + public record RateLimit(int capacity, double refillPerSecond) {} +} diff --git a/src/main/java/com/ankurm/urlshortener/core/SnowflakeIdGenerator.java b/src/main/java/com/ankurm/urlshortener/core/SnowflakeIdGenerator.java new file mode 100644 index 0000000..958f858 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/SnowflakeIdGenerator.java @@ -0,0 +1,54 @@ +package com.ankurm.urlshortener.core; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.Clock; + +/** + * Twitter-style ids: 41 bits of milliseconds since a custom epoch, 10 bits of node id, 12 bits of per-millisecond + * sequence. Needs no database, and ids are roughly time-ordered, but they are 63-bit numbers, so the base62 code is + * about 10-11 characters instead of 5-7. Activate with {@code --spring.profiles.active=snowflake}. + */ +@Component +@Profile("snowflake") +public class SnowflakeIdGenerator implements IdGenerator { + static final long EPOCH = java.time.Instant.parse("2026-01-01T00:00:00Z").toEpochMilli(); + static final int NODE_BITS = 10, SEQUENCE_BITS = 12; + static final long MAX_NODE = (1L << NODE_BITS) - 1, MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; + + private final Clock clock; + private final long nodeId; + private long lastMillis = -1; + private long sequence = 0; + + @Autowired // two constructors: tell Spring which one to use + public SnowflakeIdGenerator(Clock clock, ShortenerProperties props) { + this(clock, props.nodeId()); + } + + public SnowflakeIdGenerator(Clock clock, long nodeId) { + if (nodeId < 0 || nodeId > MAX_NODE) throw new IllegalArgumentException("nodeId must be 0.." + MAX_NODE); + this.clock = clock; + this.nodeId = nodeId; + } + + @Override + public synchronized long nextId() { + long now = clock.millis(); + if (now < lastMillis) { + throw new IllegalStateException("clock moved backwards by " + (lastMillis - now) + " ms; refusing to issue ids"); + } + if (now == lastMillis) { + sequence = (sequence + 1) & MAX_SEQUENCE; + if (sequence == 0) { // 4096 ids in this millisecond: wait for the next one + while (now <= lastMillis) now = clock.millis(); + } + } else { + sequence = 0; + } + lastMillis = now; + return ((now - EPOCH) << (NODE_BITS + SEQUENCE_BITS)) | (nodeId << SEQUENCE_BITS) | sequence; + } +} diff --git a/src/main/java/com/ankurm/urlshortener/core/UrlRepository.java b/src/main/java/com/ankurm/urlshortener/core/UrlRepository.java new file mode 100644 index 0000000..55df7d8 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/UrlRepository.java @@ -0,0 +1,38 @@ +package com.ankurm.urlshortener.core; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; + +/** Plain SQL through JdbcClient. The UNIQUE constraint on code is what makes custom aliases race-safe. */ +@Repository +public class UrlRepository { + private final JdbcClient jdbc; + + public UrlRepository(JdbcClient jdbc) { + this.jdbc = jdbc; + } + + /** @throws DuplicateKeyException when the code is already taken */ + public void insert(ShortUrl u) { + jdbc.sql("INSERT INTO urls (id, code, long_url, created_at, expires_at) VALUES (?, ?, ?, ?, ?)") + .param(u.id()).param(u.code()).param(u.longUrl()) + .param(Timestamp.from(u.createdAt())) + .param(u.expiresAt() == null ? null : Timestamp.from(u.expiresAt())) + .update(); + } + + public Optional findByCode(String code) { + return jdbc.sql("SELECT id, code, long_url, created_at, expires_at FROM urls WHERE code = ?") + .param(code) + .query((rs, n) -> new ShortUrl( + rs.getLong("id"), rs.getString("code"), rs.getString("long_url"), + rs.getTimestamp("created_at").toInstant(), + rs.getTimestamp("expires_at") == null ? null : rs.getTimestamp("expires_at").toInstant())) + .optional(); + } +} diff --git a/src/main/java/com/ankurm/urlshortener/core/UrlService.java b/src/main/java/com/ankurm/urlshortener/core/UrlService.java new file mode 100644 index 0000000..cc75ec7 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/core/UrlService.java @@ -0,0 +1,117 @@ +package com.ankurm.urlshortener.core; + +import com.ankurm.urlshortener.cache.UrlCache; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Set; +import java.util.regex.Pattern; + +@Service +public class UrlService { + static final Pattern ALIAS = Pattern.compile("[A-Za-z0-9_-]{4,32}"); + static final Set RESERVED = Set.of("api", "actuator", "error", "health", "admin"); + static final int MAX_URL_LENGTH = 2048; + + public static class InvalidUrlException extends RuntimeException { + public InvalidUrlException(String m) { super(m); } + } + public static class AliasTakenException extends RuntimeException { + public AliasTakenException(String alias) { super("alias already taken: " + alias); } + } + public static class NotFoundException extends RuntimeException { + public NotFoundException(String code) { super("no such code: " + code); } + } + public static class ExpiredException extends RuntimeException { + public ExpiredException(String code) { super("link expired: " + code); } + } + + private final UrlRepository repo; + private final IdGenerator ids; + private final UrlCache cache; + private final ShortenerProperties props; + private final Clock clock; + + public UrlService(UrlRepository repo, IdGenerator ids, UrlCache cache, ShortenerProperties props, Clock clock) { + this.repo = repo; + this.ids = ids; + this.cache = cache; + this.props = props; + this.clock = clock; + } + + public ShortUrl create(String longUrl, String alias, Duration ttl) { + validate(longUrl); + long id = ids.nextId(); + String code = alias != null ? validAlias(alias) : CodeScrambler.codeFor(id); + Instant now = clock.instant(); + ShortUrl u = new ShortUrl(id, code, longUrl, now, ttl == null ? null : now.plus(ttl)); + try { + repo.insert(u); + } catch (DuplicateKeyException e) { + throw new AliasTakenException(code); + } + return u; + } + + /** Cache-aside read. Returns the target URL and counts the click. */ + public String resolve(String code) { + var cached = cache.get(code); + if (cached.isPresent()) { + if (UrlCache.NOT_FOUND.equals(cached.get())) throw new NotFoundException(code); // negative cache hit + cache.incrementClicks(code); + return cached.get(); + } + ShortUrl u = repo.findByCode(code).orElse(null); + if (u == null) { + cache.put(code, UrlCache.NOT_FOUND, props.negativeCacheTtl()); + throw new NotFoundException(code); + } + Instant now = clock.instant(); + if (u.expiresAt() != null && !u.expiresAt().isAfter(now)) { + throw new ExpiredException(code); + } + Duration ttl = props.cacheTtl(); + if (u.expiresAt() != null) { // never cache past the link's own life + Duration remaining = Duration.between(now, u.expiresAt()); + if (remaining.compareTo(ttl) < 0) ttl = remaining; + } + cache.put(code, u.longUrl(), ttl); + cache.incrementClicks(code); + return u.longUrl(); + } + + public ShortUrl find(String code) { + return repo.findByCode(code).orElseThrow(() -> new NotFoundException(code)); + } + + public long clicks(String code) { + return cache.clicks(code); + } + + static void validate(String longUrl) { + if (longUrl == null || longUrl.isBlank()) throw new InvalidUrlException("url is required"); + if (longUrl.length() > MAX_URL_LENGTH) throw new InvalidUrlException("url longer than " + MAX_URL_LENGTH); + URI uri; + try { + uri = URI.create(longUrl); + } catch (IllegalArgumentException e) { + throw new InvalidUrlException("not a valid URL"); + } + String scheme = uri.getScheme(); + if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) { + throw new InvalidUrlException("only http and https URLs are allowed"); + } + if (uri.getHost() == null || uri.getHost().isBlank()) throw new InvalidUrlException("url has no host"); + } + + static String validAlias(String alias) { + if (!ALIAS.matcher(alias).matches()) throw new InvalidUrlException("alias must be 4-32 chars of A-Z a-z 0-9 _ -"); + if (RESERVED.contains(alias.toLowerCase())) throw new InvalidUrlException("alias is reserved"); + return alias; + } +} diff --git a/src/main/java/com/ankurm/urlshortener/ratelimit/InMemoryTokenBucketRateLimiter.java b/src/main/java/com/ankurm/urlshortener/ratelimit/InMemoryTokenBucketRateLimiter.java new file mode 100644 index 0000000..50b78de --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/ratelimit/InMemoryTokenBucketRateLimiter.java @@ -0,0 +1,42 @@ +package com.ankurm.urlshortener.ratelimit; + +import com.ankurm.urlshortener.core.ShortenerProperties; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** Same algorithm as the Lua script, for a single JVM. Used by the tests (and fine for one instance). */ +@Component +@Profile("inmemory") +public class InMemoryTokenBucketRateLimiter implements RateLimiter { + private static final class Bucket { double tokens; long lastMillis; } + + private final Clock clock; + private final int capacity; + private final double refillPerSecond; + private final Map buckets = new ConcurrentHashMap<>(); + + public InMemoryTokenBucketRateLimiter(Clock clock, ShortenerProperties props) { + this.clock = clock; + this.capacity = props.rateLimit().capacity(); + this.refillPerSecond = props.rateLimit().refillPerSecond(); + } + + @Override + public Decision tryAcquire(String key) { + Bucket b = buckets.computeIfAbsent(key, k -> { var nb = new Bucket(); nb.tokens = capacity; nb.lastMillis = clock.millis(); return nb; }); + synchronized (b) { + long now = clock.millis(); + b.tokens = Math.min(capacity, b.tokens + (now - b.lastMillis) * refillPerSecond / 1000.0); + b.lastMillis = now; + if (b.tokens >= 1) { + b.tokens -= 1; + return new Decision(true, (long) Math.floor(b.tokens), 0); + } + return new Decision(false, 0, (long) Math.ceil((1 - b.tokens) / refillPerSecond)); + } + } +} diff --git a/src/main/java/com/ankurm/urlshortener/ratelimit/RateLimitFilter.java b/src/main/java/com/ankurm/urlshortener/ratelimit/RateLimitFilter.java new file mode 100644 index 0000000..0fc43aa --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/ratelimit/RateLimitFilter.java @@ -0,0 +1,47 @@ +package com.ankurm.urlshortener.ratelimit; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Only the WRITE path is limited: creating links is the abusable, expensive operation. Redirects are + * cheap, cached, and are exactly the traffic we want to serve at full speed. + * + * Behind a load balancer, getRemoteAddr() is the balancer's address; configure + * server.forward-headers-strategy so this is the real client (see the post's rate-limiting section). + */ +@Component +public class RateLimitFilter extends OncePerRequestFilter { + private final RateLimiter limiter; + + public RateLimitFilter(RateLimiter limiter) { + this.limiter = limiter; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !(request.getMethod().equals("POST") && request.getRequestURI().equals("/api/v1/urls")); + } + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws ServletException, IOException { + RateLimiter.Decision d = limiter.tryAcquire(req.getRemoteAddr()); + res.setHeader("X-RateLimit-Remaining", Long.toString(d.remaining())); + if (!d.allowed()) { + res.setStatus(429); + res.setHeader("Retry-After", Long.toString(d.retryAfterSeconds())); + res.setContentType("application/problem+json"); + res.getWriter().write("{\"title\":\"Too Many Requests\",\"status\":429,\"detail\":\"Retry in " + + d.retryAfterSeconds() + "s\"}"); + return; + } + chain.doFilter(req, res); + } +} diff --git a/src/main/java/com/ankurm/urlshortener/ratelimit/RateLimiter.java b/src/main/java/com/ankurm/urlshortener/ratelimit/RateLimiter.java new file mode 100644 index 0000000..1af439f --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/ratelimit/RateLimiter.java @@ -0,0 +1,9 @@ +package com.ankurm.urlshortener.ratelimit; + +/** Token-bucket rate limiter. One bucket per key (here: the client address). */ +public interface RateLimiter { + Decision tryAcquire(String key); + + /** @param retryAfterSeconds how long until a token is available again (0 when allowed) */ + record Decision(boolean allowed, long remaining, long retryAfterSeconds) {} +} diff --git a/src/main/java/com/ankurm/urlshortener/ratelimit/RedisTokenBucketRateLimiter.java b/src/main/java/com/ankurm/urlshortener/ratelimit/RedisTokenBucketRateLimiter.java new file mode 100644 index 0000000..811d2f0 --- /dev/null +++ b/src/main/java/com/ankurm/urlshortener/ratelimit/RedisTokenBucketRateLimiter.java @@ -0,0 +1,37 @@ +package com.ankurm.urlshortener.ratelimit; + +import com.ankurm.urlshortener.core.ShortenerProperties; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * The whole check-refill-take sequence runs as ONE Lua script inside Redis, so it is atomic across every + * application instance. The script also reads the clock from Redis ({@code TIME}), so instances with skewed + * clocks cannot cheat each other. + */ +@Component +@Profile("!inmemory") +public class RedisTokenBucketRateLimiter implements RateLimiter { + private final StringRedisTemplate redis; + private final ShortenerProperties.RateLimit limit; + private final DefaultRedisScript script = new DefaultRedisScript<>(); + + public RedisTokenBucketRateLimiter(StringRedisTemplate redis, ShortenerProperties props) { + this.redis = redis; + this.limit = props.rateLimit(); + script.setLocation(new ClassPathResource("token_bucket.lua")); + script.setResultType(List.class); + } + + @Override + public Decision tryAcquire(String key) { + List r = redis.execute(script, List.of("rl:" + key), + String.valueOf(limit.capacity()), String.valueOf(limit.refillPerSecond())); + return new Decision(((Long) r.get(0)) == 1L, (Long) r.get(1), (Long) r.get(2)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..be90523 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,23 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5433/urlshort + username: pgrun + data: + redis: + host: localhost + port: 6379 + sql: + init: + mode: always + +app: + base-url: http://localhost:8080 + cache-ttl: 1h + negative-cache-ttl: 60s + node-id: 1 # only used by the snowflake profile; must differ per instance + rate-limit: + capacity: 5 # burst + refill-per-second: 0.5 # then one new link every 2 seconds per client + +server: + forward-headers-strategy: framework # trust X-Forwarded-For from the load balancer diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..999607b --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,11 @@ +-- INCREMENT BY 100 is what makes BlockIdGenerator work: each nextval() claims a block of 100 ids. +-- Ids start at 1; CodeScrambler maps them to 7-character codes (62^7 = 3.5 trillion possible ids). +CREATE SEQUENCE IF NOT EXISTS url_id_seq START WITH 1 INCREMENT BY 100; + +CREATE TABLE IF NOT EXISTS urls ( + id BIGINT PRIMARY KEY, + code VARCHAR(32) NOT NULL UNIQUE, + long_url VARCHAR(2048) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE +); diff --git a/src/main/resources/token_bucket.lua b/src/main/resources/token_bucket.lua new file mode 100644 index 0000000..b440452 --- /dev/null +++ b/src/main/resources/token_bucket.lua @@ -0,0 +1,31 @@ +-- KEYS[1] = bucket key; ARGV[1] = capacity; ARGV[2] = refill tokens per second +-- returns {allowed (1/0), tokens left (floored), retry-after seconds} +local capacity = tonumber(ARGV[1]) +local refill = tonumber(ARGV[2]) +local t = redis.call('TIME') +local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000) + +local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts') +local tokens = tonumber(data[1]) +local ts = tonumber(data[2]) +if tokens == nil then + tokens = capacity + ts = now +end + +tokens = math.min(capacity, tokens + math.max(0, now - ts) * refill / 1000) + +local allowed = 0 +if tokens >= 1 then + tokens = tokens - 1 + allowed = 1 +end + +redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now) +redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refill * 1000) + 1000) + +local retry = 0 +if allowed == 0 then + retry = math.ceil((1 - tokens) / refill) +end +return {allowed, math.floor(tokens), retry} diff --git a/src/test/java/com/ankurm/urlshortener/Base62Test.java b/src/test/java/com/ankurm/urlshortener/Base62Test.java new file mode 100644 index 0000000..c998257 --- /dev/null +++ b/src/test/java/com/ankurm/urlshortener/Base62Test.java @@ -0,0 +1,37 @@ +package com.ankurm.urlshortener; + +import static org.junit.jupiter.api.Assertions.*; + +import com.ankurm.urlshortener.core.Base62; +import org.junit.jupiter.api.Test; + +class Base62Test { + @Test + void knownValues() { + assertEquals("0", Base62.encode(0)); + assertEquals("9", Base62.encode(9)); + assertEquals("A", Base62.encode(10)); + assertEquals("z", Base62.encode(61)); + assertEquals("10", Base62.encode(62)); + } + + @Test + void roundTripsAcrossTheRange() { + for (long v : new long[]{0, 1, 61, 62, 3843, 3844, 1_000_000, 1_000_099, 56_800_235_583L, Long.MAX_VALUE}) { + assertEquals(v, Base62.decode(Base62.encode(v)), "value " + v); + } + } + + @Test + void sevenCharactersCoverAboutThreePointFiveTrillion() { + long sixtyTwoToTheSeven = 3_521_614_606_208L; + assertEquals(7, Base62.encode(sixtyTwoToTheSeven - 1).length()); + assertEquals(8, Base62.encode(sixtyTwoToTheSeven).length()); + } + + @Test + void rejectsGarbage() { + assertThrows(IllegalArgumentException.class, () -> Base62.encode(-1)); + assertThrows(IllegalArgumentException.class, () -> Base62.decode("ab-c")); + } +} diff --git a/src/test/java/com/ankurm/urlshortener/CodeScramblerTest.java b/src/test/java/com/ankurm/urlshortener/CodeScramblerTest.java new file mode 100644 index 0000000..1f86cc3 --- /dev/null +++ b/src/test/java/com/ankurm/urlshortener/CodeScramblerTest.java @@ -0,0 +1,39 @@ +package com.ankurm.urlshortener; + +import static org.junit.jupiter.api.Assertions.*; + +import com.ankurm.urlshortener.core.CodeScrambler; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class CodeScramblerTest { + + @Test + void noTwoIdsShareACodeAcrossTwoMillionConsecutiveIds() { + Set codes = new HashSet<>(); + for (long id = 1; id <= 2_000_000; id++) assertTrue(codes.add(CodeScrambler.codeFor(id)), "collision at id " + id); + } + + @Test + void everyCodeIsExactlySevenCharacters() { + for (long id : new long[]{1, 2, 99, 100, 1_000_000, CodeScrambler.DOMAIN - 1}) { + assertEquals(7, CodeScrambler.codeFor(id).length(), "id " + id); + } + } + + @Test + void consecutiveIdsDoNotGiveConsecutiveCodes() { + String a = CodeScrambler.codeFor(1), b = CodeScrambler.codeFor(2), c = CodeScrambler.codeFor(3); + // neither the first nor the last characters step by one, which is what a naive "base62 of the counter" does + assertNotEquals(a.charAt(0), b.charAt(0)); + assertNotEquals(a.charAt(6), b.charAt(6)); + assertNotEquals(b.charAt(6), c.charAt(6)); + } + + @Test + void idsBeyondTheFortyBitDomainFallBackToPlainBase62() { + assertTrue(CodeScrambler.codeFor(CodeScrambler.DOMAIN).length() >= 7); + assertThrows(IllegalArgumentException.class, () -> CodeScrambler.scramble(CodeScrambler.DOMAIN)); + } +} diff --git a/src/test/java/com/ankurm/urlshortener/MutableClock.java b/src/test/java/com/ankurm/urlshortener/MutableClock.java new file mode 100644 index 0000000..1a69c8a --- /dev/null +++ b/src/test/java/com/ankurm/urlshortener/MutableClock.java @@ -0,0 +1,18 @@ +package com.ankurm.urlshortener; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; + +/** A clock tests can move by hand, so TTL and refill tests never sleep. */ +public class MutableClock extends Clock { + private volatile Instant now = Instant.parse("2026-10-22T09:00:00Z"); + + public void advance(Duration d) { now = now.plus(d); } + + @Override public Instant instant() { return now; } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } +} diff --git a/src/test/java/com/ankurm/urlshortener/RateLimitTest.java b/src/test/java/com/ankurm/urlshortener/RateLimitTest.java new file mode 100644 index 0000000..c6c8330 --- /dev/null +++ b/src/test/java/com/ankurm/urlshortener/RateLimitTest.java @@ -0,0 +1,52 @@ +package com.ankurm.urlshortener; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.http.HttpResponse; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("inmemory") +@TestPropertySource(properties = {"app.rate-limit.capacity=5", "app.rate-limit.refill-per-second=0.5"}) +@Import(RateLimitTest.ClockConfig.class) +class RateLimitTest { + + @TestConfiguration + static class ClockConfig { + @Bean @Primary MutableClock testClock() { return new MutableClock(); } + } + + @LocalServerPort int port; + @Autowired MutableClock clock; + + @Test + void burstOfFiveThenRejectedThenRefilled() throws Exception { + var http = new TestSupport(port); + String body = "{\"url\":\"https://example.com/r\"}"; + for (int i = 0; i < 5; i++) assertEquals(201, http.post("/api/v1/urls", body).statusCode(), "request " + (i + 1)); + + HttpResponse limited = http.post("/api/v1/urls", body); + assertEquals(429, limited.statusCode()); + assertEquals("2", limited.headers().firstValue("Retry-After").orElseThrow()); + + clock.advance(Duration.ofSeconds(2)); // 0.5 tokens/s * 2 s = 1 token + assertEquals(201, http.post("/api/v1/urls", body).statusCode()); + assertEquals(429, http.post("/api/v1/urls", body).statusCode()); + } + + @Test + void redirectsAreNeverRateLimited() throws Exception { + var http = new TestSupport(port); + for (int i = 0; i < 50; i++) assertEquals(404, http.get("/Nope" + (i % 3)).statusCode()); + } +} diff --git a/src/test/java/com/ankurm/urlshortener/SnowflakeIdGeneratorTest.java b/src/test/java/com/ankurm/urlshortener/SnowflakeIdGeneratorTest.java new file mode 100644 index 0000000..71e81f4 --- /dev/null +++ b/src/test/java/com/ankurm/urlshortener/SnowflakeIdGeneratorTest.java @@ -0,0 +1,70 @@ +package com.ankurm.urlshortener; + +import static org.junit.jupiter.api.Assertions.*; + +import com.ankurm.urlshortener.core.Base62; +import com.ankurm.urlshortener.core.SnowflakeIdGenerator; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; + +class SnowflakeIdGeneratorTest { + + @Test + void idsAreUniqueAcrossEightThreads() throws Exception { + var gen = new SnowflakeIdGenerator(java.time.Clock.systemUTC(), 7); + Set all = ConcurrentHashMap.newKeySet(); + int perThread = 20_000; + try (var pool = Executors.newFixedThreadPool(8)) { + List> fs = new ArrayList<>(); + for (int t = 0; t < 8; t++) { + fs.add(pool.submit(() -> { for (int i = 0; i < perThread; i++) all.add(gen.nextId()); })); + } + for (Future f : fs) f.get(); + } + assertEquals(8 * perThread, all.size()); + } + + @Test + void idsIncreaseWithinOneThread() { + var gen = new SnowflakeIdGenerator(java.time.Clock.systemUTC(), 1); + long prev = gen.nextId(); + for (int i = 0; i < 50_000; i++) { + long next = gen.nextId(); + assertTrue(next > prev); + prev = next; + } + } + + @Test + void refusesToIssueIdsWhenTheClockGoesBackwards() { + var clock = new MutableClock(); + var gen = new SnowflakeIdGenerator(clock, 1); + gen.nextId(); + clock.advance(Duration.ofMillis(-5)); + assertThrows(IllegalStateException.class, gen::nextId); + } + + @Test + void nodeIdIsEncodedInTheId() { + var clock = new MutableClock(); + long id = new SnowflakeIdGenerator(clock, 5).nextId(); + assertEquals(5, (id >> 12) & 0x3FF); + } + + @Test + void base62CodeIsLongerThanForASequenceId() { + var clock = new MutableClock(); + clock.advance(Duration.ofDays(300)); + long id = new SnowflakeIdGenerator(clock, 1).nextId(); + assertTrue(Base62.encode(id).length() >= 9, Base62.encode(id)); + } + + @Test + void rejectsOutOfRangeNodeId() { + assertThrows(IllegalArgumentException.class, () -> new SnowflakeIdGenerator(new MutableClock(), 1024)); + } +} diff --git a/src/test/java/com/ankurm/urlshortener/SnowflakeProfileTest.java b/src/test/java/com/ankurm/urlshortener/SnowflakeProfileTest.java new file mode 100644 index 0000000..7de0b23 --- /dev/null +++ b/src/test/java/com/ankurm/urlshortener/SnowflakeProfileTest.java @@ -0,0 +1,26 @@ +package com.ankurm.urlshortener; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** The snowflake profile must actually start (an earlier version failed on two constructors) and give longer codes. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles({"inmemory", "snowflake"}) +@TestPropertySource(properties = {"app.rate-limit.capacity=1000", "app.node-id=7"}) +class SnowflakeProfileTest { + @LocalServerPort int port; + + @Test + void snowflakeIdsGiveLongerCodes() throws Exception { + var http = new TestSupport(port); + String code = TestSupport.field(http.post("/api/v1/urls", "{\"url\":\"https://example.com/snow\"}").body(), "code"); + assertNotNull(code); + assertTrue(code.length() >= 9, "snowflake codes are 9-11 characters, got " + code); + assertEquals(302, http.get("/" + code).statusCode()); + } +} diff --git a/src/test/java/com/ankurm/urlshortener/TestSupport.java b/src/test/java/com/ankurm/urlshortener/TestSupport.java new file mode 100644 index 0000000..521b6b1 --- /dev/null +++ b/src/test/java/com/ankurm/urlshortener/TestSupport.java @@ -0,0 +1,38 @@ +package com.ankurm.urlshortener; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Tiny HTTP helper: JDK HttpClient that does NOT follow redirects, so we can assert on the 302 itself. */ +final class TestSupport { + private final HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(); + private final String base; + + TestSupport(int port) { this.base = "http://localhost:" + port; } + + HttpResponse post(String path, String json) throws IOException, InterruptedException { + return client.send(HttpRequest.newBuilder(URI.create(base + path)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)).build(), HttpResponse.BodyHandlers.ofString()); + } + + HttpResponse get(String path) throws IOException, InterruptedException { + return client.send(HttpRequest.newBuilder(URI.create(base + path)).GET().build(), HttpResponse.BodyHandlers.ofString()); + } + + static String field(String json, String name) { + Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } + + static long number(String json, String name) { + Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*(\\d+)").matcher(json); + if (!m.find()) throw new AssertionError(name + " not in " + json); + return Long.parseLong(m.group(1)); + } +} diff --git a/src/test/java/com/ankurm/urlshortener/UrlShortenerApiTest.java b/src/test/java/com/ankurm/urlshortener/UrlShortenerApiTest.java new file mode 100644 index 0000000..8c38000 --- /dev/null +++ b/src/test/java/com/ankurm/urlshortener/UrlShortenerApiTest.java @@ -0,0 +1,99 @@ +package com.ankurm.urlshortener; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("inmemory") +@TestPropertySource(properties = "app.rate-limit.capacity=1000") +@Import(UrlShortenerApiTest.ClockConfig.class) +class UrlShortenerApiTest { + + @TestConfiguration + static class ClockConfig { + @Bean @Primary MutableClock testClock() { return new MutableClock(); } + } + + @LocalServerPort int port; + @Autowired MutableClock clock; + + @Test + void createsAShortLinkAndRedirectsWith302() throws Exception { + var http = new TestSupport(port); + HttpResponse created = http.post("/api/v1/urls", "{\"url\":\"https://example.com/a/very/long/path?x=1\"}"); + assertEquals(201, created.statusCode()); + String code = TestSupport.field(created.body(), "code"); + assertNotNull(code); + assertEquals(7, code.length(), code); + + HttpResponse redirect = http.get("/" + code); + assertEquals(302, redirect.statusCode()); + assertEquals("https://example.com/a/very/long/path?x=1", redirect.headers().firstValue("Location").orElseThrow()); + } + + @Test + void sameLongUrlGetsAnIndependentCodeEachTime() throws Exception { + var http = new TestSupport(port); + String a = TestSupport.field(http.post("/api/v1/urls", "{\"url\":\"https://example.com/same\"}").body(), "code"); + String b = TestSupport.field(http.post("/api/v1/urls", "{\"url\":\"https://example.com/same\"}").body(), "code"); + assertNotEquals(a, b); + } + + @Test + void customAliasWorksAndSecondUseIsAConflict() throws Exception { + var http = new TestSupport(port); + assertEquals(201, http.post("/api/v1/urls", "{\"url\":\"https://example.com/one\",\"alias\":\"my-link\"}").statusCode()); + assertEquals(409, http.post("/api/v1/urls", "{\"url\":\"https://example.com/two\",\"alias\":\"my-link\"}").statusCode()); + assertEquals("https://example.com/one", http.get("/my-link").headers().firstValue("Location").orElseThrow()); + } + + @Test + void rejectsBadInput() throws Exception { + var http = new TestSupport(port); + assertEquals(400, http.post("/api/v1/urls", "{\"url\":\"ftp://example.com/x\"}").statusCode()); + assertEquals(400, http.post("/api/v1/urls", "{\"url\":\"javascript:alert(1)\"}").statusCode()); + assertEquals(400, http.post("/api/v1/urls", "{\"url\":\"not a url\"}").statusCode()); + assertEquals(400, http.post("/api/v1/urls", "{\"url\":\"https://example.com\",\"alias\":\"api\"}").statusCode()); + assertEquals(400, http.post("/api/v1/urls", "{\"url\":\"https://example.com\",\"alias\":\"ab\"}").statusCode()); + } + + @Test + void unknownCodeIs404AndTheMissIsNegativelyCached() throws Exception { + var http = new TestSupport(port); + long before = TestSupport.number(http.get("/api/v1/debug/cache-stats").body(), "misses"); + assertEquals(404, http.get("/Zz9Zz9").statusCode()); // miss: goes to the database + assertEquals(404, http.get("/Zz9Zz9").statusCode()); // negative-cache hit: database not touched + long after = TestSupport.number(http.get("/api/v1/debug/cache-stats").body(), "misses"); + assertEquals(before + 1, after, "second lookup must be answered from the negative cache"); + } + + @Test + void expiredLinkIs410() throws Exception { + var http = new TestSupport(port); + String code = TestSupport.field(http.post("/api/v1/urls", "{\"url\":\"https://example.com/temp\",\"ttlSeconds\":60}").body(), "code"); + assertEquals(302, http.get("/" + code).statusCode()); + clock.advance(Duration.ofSeconds(61)); + assertEquals(410, http.get("/" + code).statusCode()); + } + + @Test + void countsClicks() throws Exception { + var http = new TestSupport(port); + String code = TestSupport.field(http.post("/api/v1/urls", "{\"url\":\"https://example.com/clicks\"}").body(), "code"); + for (int i = 0; i < 7; i++) http.get("/" + code); + assertEquals(7, TestSupport.number(http.get("/api/v1/urls/" + code + "/stats").body(), "clicks")); + } +} diff --git a/src/test/resources/application-inmemory.yml b/src/test/resources/application-inmemory.yml new file mode 100644 index 0000000..30f978a --- /dev/null +++ b/src/test/resources/application-inmemory.yml @@ -0,0 +1,6 @@ +# Test profile: H2 in PostgreSQL mode, in-memory cache and rate limiter, no Redis needed. +spring: + datasource: + url: jdbc:h2:mem:urls;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 + username: sa + password: ""