contentintech
Learn/Fresher SDE Preparation/Guided URL shortener project
beginner~5 min read + exercises

Build a validated URL shortener with SQLite

Generate base62 lookup keys, validate HTTP destinations, and test a durable short-link business layer.

projectspythonsqliteurlstesting

A separate project brief

Build a personal link catalog for a study group. Members save long documentation addresses and share compact lookup keys during meetings. This is a different domain from the repair desk: its core challenge is validating destinations and allocating unique keys, rather than transitioning support tickets. The runnable deliverable is a business module and test suite. It deliberately stops before exposing an anonymous public redirect service.

Use Python 3.9 or newer with SQLite available, an editor, and a terminal. No dependencies or provider accounts are required. Review backend APIs for transport contracts and try constraints in the SQL playground. Budget one session for implementation and another for failure analysis.

Requirements and schema

Creating a link returns a generated eight-character base62 key: uppercase letters, lowercase letters, and digits. Looking up an existing key returns its saved destination; a missing key raises a distinct absence error. Repeated creation of the same destination creates separate records. This choice keeps the contract explicit instead of silently promising deduplication or idempotency.

The links table stores a text primary key and a destination. Keys are case-sensitive. The database checks their length and alphabet, while the application validates URLs before insertion. Only absolute HTTP or HTTPS destinations are accepted. Credentials, whitespace, control characters, invalid ports, and missing hosts are rejected. The policy is intentionally conservative; it is not a general-purpose URL canonicalizer.

Milestone 1: run the complete business layer

Start in a fresh directory so this project's database cannot be confused with repair tickets:

bash
mkdir study-links
cd study-links
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"

Save this complete file as shortener.py. It uses parameter binding and explicit connection closure, following Python's sqlite3 documentation. A bounded retry handles randomly generated primary-key collisions without disguising unrelated operational errors.

python
import secrets
import sqlite3
import string
from contextlib import closing
from urllib.parse import urlsplit

ALPHABET = string.ascii_letters + string.digits


def validate_destination(value):
    if not isinstance(value, str) or not 1 <= len(value) <= 2048:
        raise ValueError("destination must contain 1 to 2048 characters")
    if any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in value):
        raise ValueError("whitespace and control characters are forbidden")
    if "\\" in value:
        raise ValueError("backslashes are forbidden")
    try:
        parts = urlsplit(value)
        host = parts.hostname
        port = parts.port
        if parts.scheme.lower() not in ("http", "https") or not host:
            raise ValueError("use an absolute HTTP or HTTPS URL")
        if parts.username is not None or parts.password is not None:
            raise ValueError("credentials are forbidden")
        if port is not None and not 1 <= port <= 65535:
            raise ValueError("invalid port")
        if any(c in host for c in "%/?#"):
            raise ValueError("invalid hostname")
        host.encode("idna")
    except (ValueError, UnicodeError) as error:
        raise ValueError("invalid HTTP destination") from error
    return value


class LinkStore:
    def __init__(self, path, key_factory=None):
        self.path = str(path)
        self.key_factory = key_factory or self.new_key
        with closing(sqlite3.connect(self.path)) as db, db:
            db.execute("""
                CREATE TABLE IF NOT EXISTS links (
                    key TEXT PRIMARY KEY NOT NULL
                        CHECK(length(key) = 8)
                        CHECK(key NOT GLOB '*[^a-zA-Z0-9]*'),
                    destination TEXT NOT NULL
                        CHECK(length(destination) BETWEEN 1 AND 2048)
                )
            """)

    @staticmethod
    def new_key():
        return "".join(secrets.choice(ALPHABET) for _ in range(8))

    def create(self, destination):
        destination = validate_destination(destination)
        for attempt in range(5):
            key = self.key_factory()
            if not isinstance(key, str) or len(key) != 8 or any(
                c not in ALPHABET for c in key
            ):
                raise ValueError("key generator must return eight base62 characters")
            try:
                with closing(sqlite3.connect(self.path)) as db, db:
                    db.execute(
                        "INSERT INTO links (key, destination) VALUES (?, ?)",
                        (key, destination),
                    )
                return {"key": key, "destination": destination}
            except sqlite3.IntegrityError:
                with closing(sqlite3.connect(self.path)) as db:
                    exists = db.execute(
                        "SELECT 1 FROM links WHERE key = ?", (key,)
                    ).fetchone()
                if exists is None:
                    raise
        raise RuntimeError("could not allocate a unique key")

    def lookup(self, key):
        if not isinstance(key, str) or len(key) != 8 or any(
            c not in ALPHABET for c in key
        ):
            raise ValueError("invalid lookup key")
        with closing(sqlite3.connect(self.path)) as db:
            row = db.execute(
                "SELECT destination FROM links WHERE key = ?", (key,)
            ).fetchone()
        if row is None:
            raise LookupError("link not found")
        return row[0]

Randomness makes keys difficult to predict compared with sequential identifiers, but does not make them access credentials. The injected generator is a testing seam: production callers should use the default. There is no server-side fetching, DNS resolution, or claim that the target website exists. Python's URL parsing documentation warns that parsing alone is not validation; the explicit policy above supplies the exercise's checks.

Milestone 2: verify collisions and unsafe input

Save this as test_shortener.py. Temporary file-backed databases keep tests isolated while also allowing reopen checks.

python
import tempfile
import unittest
from pathlib import Path
from shortener import ALPHABET, LinkStore, validate_destination


class LinkTests(unittest.TestCase):
    def setUp(self):
        temp = tempfile.TemporaryDirectory()
        self.addCleanup(temp.cleanup)
        self.path = Path(temp.name) / "links.db"
        self.store = LinkStore(self.path)

    def test_generated_key_and_reopen(self):
        saved = self.store.create("https://docs.python.org/3/?view=all#intro")
        self.assertEqual(len(saved["key"]), 8)
        self.assertTrue(all(c in ALPHABET for c in saved["key"]))
        self.assertEqual(LinkStore(self.path).lookup(saved["key"]), saved["destination"])

    def test_bad_destinations(self):
        for value in (None, "", "javascript:alert(1)", "file:///tmp/x",
                      "//example.com", "https:///path", "https://u:p@example.com",
                      "https://example.com:99999", "https://example.com:0",
                      "https://example.com/\r\nX:yes", "https://example.com/a b",
                      "https://example.com\\@evil.test", "https://%65vil.test"):
            with self.subTest(value=value):
                with self.assertRaises(ValueError):
                    validate_destination(value)
        self.assertEqual(validate_destination("http://localhost:8080/x"),
                         "http://localhost:8080/x")

    def test_collision_retries(self):
        keys = iter(["AAAAAAAA", "AAAAAAAA", "BBBBBBBB"])
        store = LinkStore(self.path, lambda: next(keys))
        first = store.create("https://example.com/one")
        second = store.create("https://example.com/two")
        self.assertEqual(first["key"], "AAAAAAAA")
        self.assertEqual(second["key"], "BBBBBBBB")
        self.assertEqual(store.lookup(first["key"]), first["destination"])

    def test_exhaustion_and_missing(self):
        store = LinkStore(self.path, lambda: "AAAAAAAA")
        store.create("https://example.com")
        with self.assertRaises(RuntimeError):
            store.create("https://example.org")
        with self.assertRaises(LookupError):
            store.lookup("BBBBBBBB")
        with self.assertRaises(ValueError):
            store.lookup("invalid!")

    def test_invalid_generator(self):
        store = LinkStore(self.path, lambda: "bad-key!")
        with self.assertRaises(ValueError):
            store.create("https://example.com")


if __name__ == "__main__":
    unittest.main()
bash
python3 -m unittest -v
python3 -c "from shortener import LinkStore; s=LinkStore('links.db'); x=s.create('https://docs.python.org/3/'); print(x); print(s.lookup(x['key']))"

Expect five passing tests. The collision tests use predictable keys to exercise a rare branch reliably. Do not attempt to prove uniqueness by generating thousands of random keys and hoping none collide: the database constraint is the final arbiter. A failed allocation leaves earlier records intact.

Milestone 3: specify a lookup interface

Design POST /api/links to accept a destination and return a key with status 201. Design GET /api/links/{key} to return JSON containing the saved destination, with 404 for absence and 400 for malformed keys. Returning JSON makes review easier than immediately navigating a browser. Explore these payloads in the API playground.

A later trusted redirect adapter could look up the key and send a redirect response, but must never construct the destination from an arbitrary query parameter. Choose temporary versus permanent redirects intentionally: browser caching complicates revocation. Do not bolt this module onto a public anonymous endpoint and describe it as safe merely because it rejects javascript URLs.

Failure, security, and deployment decisions

HTTP destinations can still host phishing, malware, or deceptive content. The policy accepts localhost and private network addresses because this exercise never fetches destinations. If you add previews, screenshots, or metadata extraction, you create an SSRF problem requiring a separate network policy, redirect handling, and DNS analysis. A validation function is not a substitute for those controls.

A production study-group service needs verified creators, abuse controls, revocation, retention rules, and operational logging without exposing secret-bearing query strings. Ownership and authorization belong on the server; read authentication before adding private collections. Treat keys as shareable identifiers, not proof of permission.

Deliver the business module locally. For a future Render service, persistent disks retain only data under the mount path and impose single-instance constraints. An ephemeral application filesystem cannot preserve the SQLite catalog across replacement. Public serving also needs a production HTTP stack rather than the repair desk's local demonstration adapter. Consult deployment and test backup restoration before trusting saved links.

Review and interview practice

Award two points each for URL policy, generated-key handling, durable lookup, meaningful failure tests, and a realistic security boundary. Require all unsafe-scheme tests to pass regardless of total score. Ask why key length is a design tradeoff, why collisions require a constraint, and why HTTPS does not mean trustworthy content. Explain whether duplicate creation should be idempotent and how revocation interacts with cached redirects.

Continue with the job tracker for owner-scoped CRUD. Compare this domain with the repair-desk backend, adapt its full-stack milestone only after defining your own routes, and use the team workflow to review a focused extension.

Course navigation

Course overview · Previous lesson · Next lesson

Section navigation