bh-urlshortener 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ .git
2
+ .gitignore
3
+ .env
4
+ .env.*
5
+ __pycache__
6
+ *.pyc
7
+ *.egg-info
8
+ .pytest_cache
9
+ .mypy_cache
10
+ .coverage
11
+ .venv
12
+ venv
13
+ dist
14
+ build
15
+ tests
@@ -0,0 +1,14 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ groups:
8
+ actions:
9
+ patterns: ["*"]
10
+
11
+ - package-ecosystem: pip
12
+ directory: /
13
+ schedule:
14
+ interval: weekly
@@ -0,0 +1,80 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*.*.*"]
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ container:
13
+ image: python:${{ matrix.python-version }}-slim
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ include:
18
+ - python-version: "3.11"
19
+ toxenv: py311
20
+ - python-version: "3.12"
21
+ toxenv: py312
22
+ steps:
23
+ - uses: actions/checkout@v6
24
+ with:
25
+ fetch-depth: 0
26
+ - uses: actions/cache@v5
27
+ with:
28
+ path: ~/.cache/pip
29
+ key: pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}
30
+ restore-keys: pip-${{ matrix.python-version }}-
31
+ - run: pip install tox
32
+ - run: tox -e ${{ matrix.toxenv }}
33
+
34
+ mypy:
35
+ runs-on: ubuntu-latest
36
+ container:
37
+ image: python:3.12-slim
38
+ steps:
39
+ - uses: actions/checkout@v6
40
+ with:
41
+ fetch-depth: 0
42
+ - uses: actions/cache@v5
43
+ with:
44
+ path: ~/.cache/pip
45
+ key: pip-mypy-${{ hashFiles('pyproject.toml') }}
46
+ restore-keys: pip-mypy-
47
+ - run: pip install tox
48
+ - run: tox -e mypy
49
+
50
+ docker:
51
+ if: startsWith(github.ref, 'refs/tags/v')
52
+ needs: [test, mypy]
53
+ runs-on: ubuntu-latest
54
+ permissions:
55
+ contents: read
56
+ steps:
57
+ - uses: actions/checkout@v6
58
+ - uses: docker/setup-buildx-action@v4
59
+ - uses: docker/metadata-action@v6
60
+ id: meta
61
+ with:
62
+ images: ${{ secrets.DOCKERHUB_USERNAME }}/urlshortener
63
+ tags: |
64
+ type=semver,pattern={{version}}
65
+ type=semver,pattern={{major}}.{{minor}}
66
+ type=semver,pattern={{major}}
67
+ - uses: docker/login-action@v4
68
+ with:
69
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
70
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
71
+ - uses: docker/build-push-action@v7
72
+ with:
73
+ context: .
74
+ push: true
75
+ tags: ${{ steps.meta.outputs.tags }}
76
+ labels: ${{ steps.meta.outputs.labels }}
77
+ cache-from: type=gha
78
+ cache-to: type=gha,mode=max
79
+ provenance: true
80
+ sbom: true
@@ -0,0 +1,56 @@
1
+ name: Release
2
+
3
+ # Tag a release with vX.Y.Z (matching the version in pyproject.toml) to
4
+ # build and publish to PyPI. Requires a PyPI Trusted Publisher configured
5
+ # for this repo/workflow — no API token stored here.
6
+ on:
7
+ push:
8
+ tags: ["v*.*.*"]
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ include:
16
+ - python-version: "3.11"
17
+ toxenv: py311
18
+ - python-version: "3.12"
19
+ toxenv: py312
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+ - uses: actions/setup-python@v6
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ - run: pip install tox
26
+ - run: tox -e ${{ matrix.toxenv }}
27
+
28
+ build:
29
+ needs: test
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v6
33
+ with:
34
+ fetch-depth: 0
35
+ - uses: actions/setup-python@v6
36
+ with:
37
+ python-version: "3.12"
38
+ - run: pip install build
39
+ - run: python -m build
40
+ - uses: actions/upload-artifact@v7
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+
45
+ publish:
46
+ needs: build
47
+ runs-on: ubuntu-latest
48
+ environment: pypi
49
+ permissions:
50
+ id-token: write
51
+ steps:
52
+ - uses: actions/download-artifact@v8
53
+ with:
54
+ name: dist
55
+ path: dist/
56
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .venv/
12
+ venv/
13
+ .env
14
+ .env.*
15
+ .DS_Store
16
+ .tox/
@@ -0,0 +1,42 @@
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # Build stage
4
+ FROM python:3.12-slim AS build
5
+
6
+ ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
7
+ WORKDIR /build
8
+
9
+ COPY pyproject.toml README.md ./
10
+ COPY src ./src
11
+
12
+ RUN python -m pip install --upgrade pip build \
13
+ && python -m build --wheel --outdir /dist
14
+
15
+ # Runtime stage
16
+ FROM python:3.12-slim AS runtime
17
+
18
+ LABEL org.opencontainers.image.title="urlshortener" \
19
+ org.opencontainers.image.description="A simple URL shortener service." \
20
+ org.opencontainers.image.version="0.1.0"
21
+
22
+ ENV PYTHONUNBUFFERED=1 \
23
+ PYTHONDONTWRITEBYTECODE=1 \
24
+ PIP_NO_CACHE_DIR=1 \
25
+ URLSHORTENER_HOST=0.0.0.0 \
26
+ URLSHORTENER_PORT=8000
27
+
28
+ # Run as an unprivileged user (rootless).
29
+ RUN groupadd --gid 1000 app \
30
+ && useradd --uid 1000 --gid 1000 --no-create-home --shell /usr/sbin/nologin app
31
+
32
+ WORKDIR /app
33
+ COPY --from=build /dist/*.whl /tmp/
34
+ RUN python -m pip install /tmp/*.whl && rm -f /tmp/*.whl
35
+
36
+ USER app
37
+ EXPOSE 8000
38
+
39
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
40
+ CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status==200 else 1)"
41
+
42
+ ENTRYPOINT ["urlshortener", "serve"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abin M
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: bh_urlshortener
3
+ Version: 0.0.4
4
+ Summary: A simple URL shortener service.
5
+ Author: Abin M
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: fastapi>=0.110
11
+ Requires-Dist: uvicorn[standard]>=0.49.0
12
+ Requires-Dist: pydantic>=2.6
13
+ Requires-Dist: pydantic-settings>=2.2
14
+ Requires-Dist: sqlalchemy>=2.0.51
15
+ Requires-Dist: psycopg[binary]>=3.1
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=9.1.0; extra == "test"
18
+ Requires-Dist: pytest-cov>=7.1.0; extra == "test"
19
+ Requires-Dist: mypy>=2.1.0; extra == "test"
20
+ Requires-Dist: httpx>=0.27; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ # urlshortener
24
+
25
+ A self-hosted URL shortener. Shorten a URL, get a redirect, track visit counts. That's it.
26
+
27
+
28
+
29
+ ## API
30
+
31
+ | Method | Path | Description |
32
+ |--------|------|-------------|
33
+ | `POST` | `/shorten` | Create a short link |
34
+ | `GET` | `/{code}` | Redirect to original URL (307) |
35
+ | `GET` | `/stats/{code}` | Visit count + original URL |
36
+ | `GET` | `/healthz` | Liveness + DB connectivity check |
37
+
38
+ ```bash
39
+ # shorten
40
+ curl -X POST http://localhost:8000/shorten \
41
+ -H "Content-Type: application/json" \
42
+ -d '{"url": "https://example.com/some/long/path"}'
43
+ # {"code": "aB3kR7z", "short_url": "http://localhost:8000/aB3kR7z"}
44
+
45
+ # stats
46
+ curl http://localhost:8000/stats/aB3kR7z
47
+ # {"code": "aB3kR7z", "url": "https://example.com/some/long/path", "visits": 4}
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Running locally
53
+
54
+ **Prerequisites:** Python 3.11, a running PostgreSQL instance.
55
+
56
+ ```bash
57
+ pip install -e .
58
+
59
+ # point at your DB (or rely on the default below)
60
+ export URLSHORTENER_DATABASE_URL="postgresql+psycopg://user:pass@localhost:5432/urlshortener"
61
+
62
+ urlshortener serve
63
+ # listening on http://127.0.0.1:8000
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Docker Compose
69
+
70
+ Spins up Postgres + app together:
71
+
72
+ ```bash
73
+ docker compose up -d
74
+ ```
75
+
76
+
77
+ ---
78
+
79
+ ## Configuration
80
+
81
+ All settings are environment variables with the `URLSHORTENER_` prefix.
82
+
83
+ | Variable | Default | Description |
84
+ |----------|---------|-------------|
85
+ | `URLSHORTENER_DATABASE_URL` | `postgresql+psycopg://urlshortener:urlshortener@localhost:5432/urlshortener` | SQLAlchemy connection string |
86
+ | `URLSHORTENER_BASE_URL` | `http://localhost:8000` | Public base URL used when building short links |
87
+ | `URLSHORTENER_HOST` | `127.0.0.1` | Bind address |
88
+ | `URLSHORTENER_PORT` | `8000` | Bind port |
89
+ | `URLSHORTENER_CODE_LENGTH` | `7` | Length of generated short codes (4–32) |
90
+ | `URLSHORTENER_LOG_LEVEL` | `INFO` | Root log level |
91
+
92
+ ---
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ pip install -e ".[test]"
98
+
99
+ pytest # runs tests + coverage
100
+ mypy src # strict type checking
101
+ ```
102
+
103
+ > Tests use an in-memory fake store
104
+
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,86 @@
1
+ # urlshortener
2
+
3
+ A self-hosted URL shortener. Shorten a URL, get a redirect, track visit counts. That's it.
4
+
5
+
6
+
7
+ ## API
8
+
9
+ | Method | Path | Description |
10
+ |--------|------|-------------|
11
+ | `POST` | `/shorten` | Create a short link |
12
+ | `GET` | `/{code}` | Redirect to original URL (307) |
13
+ | `GET` | `/stats/{code}` | Visit count + original URL |
14
+ | `GET` | `/healthz` | Liveness + DB connectivity check |
15
+
16
+ ```bash
17
+ # shorten
18
+ curl -X POST http://localhost:8000/shorten \
19
+ -H "Content-Type: application/json" \
20
+ -d '{"url": "https://example.com/some/long/path"}'
21
+ # {"code": "aB3kR7z", "short_url": "http://localhost:8000/aB3kR7z"}
22
+
23
+ # stats
24
+ curl http://localhost:8000/stats/aB3kR7z
25
+ # {"code": "aB3kR7z", "url": "https://example.com/some/long/path", "visits": 4}
26
+ ```
27
+
28
+ ---
29
+
30
+ ## Running locally
31
+
32
+ **Prerequisites:** Python 3.11, a running PostgreSQL instance.
33
+
34
+ ```bash
35
+ pip install -e .
36
+
37
+ # point at your DB (or rely on the default below)
38
+ export URLSHORTENER_DATABASE_URL="postgresql+psycopg://user:pass@localhost:5432/urlshortener"
39
+
40
+ urlshortener serve
41
+ # listening on http://127.0.0.1:8000
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Docker Compose
47
+
48
+ Spins up Postgres + app together:
49
+
50
+ ```bash
51
+ docker compose up -d
52
+ ```
53
+
54
+
55
+ ---
56
+
57
+ ## Configuration
58
+
59
+ All settings are environment variables with the `URLSHORTENER_` prefix.
60
+
61
+ | Variable | Default | Description |
62
+ |----------|---------|-------------|
63
+ | `URLSHORTENER_DATABASE_URL` | `postgresql+psycopg://urlshortener:urlshortener@localhost:5432/urlshortener` | SQLAlchemy connection string |
64
+ | `URLSHORTENER_BASE_URL` | `http://localhost:8000` | Public base URL used when building short links |
65
+ | `URLSHORTENER_HOST` | `127.0.0.1` | Bind address |
66
+ | `URLSHORTENER_PORT` | `8000` | Bind port |
67
+ | `URLSHORTENER_CODE_LENGTH` | `7` | Length of generated short codes (4–32) |
68
+ | `URLSHORTENER_LOG_LEVEL` | `INFO` | Root log level |
69
+
70
+ ---
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ pip install -e ".[test]"
76
+
77
+ pytest # runs tests + coverage
78
+ mypy src # strict type checking
79
+ ```
80
+
81
+ > Tests use an in-memory fake store
82
+
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,20 @@
1
+ services:
2
+ db:
3
+ image: postgres:16-alpine
4
+ env_file: .env
5
+ ports:
6
+ - "5432:5432"
7
+ healthcheck:
8
+ test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"]
9
+ interval: 5s
10
+ timeout: 3s
11
+ retries: 5
12
+
13
+ app:
14
+ build: .
15
+ depends_on:
16
+ db:
17
+ condition: service_healthy
18
+ env_file: .env
19
+ ports:
20
+ - "8000:8000"
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "setuptools-scm>=8", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bh_urlshortener"
7
+ dynamic = ["version"]
8
+ description = "A simple URL shortener service."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Abin M" }]
13
+ dependencies = [
14
+ "fastapi>=0.110",
15
+ "uvicorn[standard]>=0.49.0",
16
+ "pydantic>=2.6",
17
+ "pydantic-settings>=2.2",
18
+ "sqlalchemy>=2.0.51",
19
+ "psycopg[binary]>=3.1",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ test = [
24
+ "pytest>=9.1.0",
25
+ "pytest-cov>=7.1.0",
26
+ "mypy>=2.1.0",
27
+ "httpx>=0.27",
28
+ ]
29
+
30
+ [project.scripts]
31
+ urlshortener = "urlshortener.__main__:main"
32
+
33
+ [tool.setuptools_scm]
34
+ fallback_version = "0.0.0"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.mypy]
40
+ python_version = "3.11"
41
+ strict = true
42
+
43
+ [tool.pytest.ini_options]
44
+ addopts = "--cov=urlshortener --cov-report=term-missing"
45
+ testpaths = ["tests"]
46
+
47
+ [tool.black]
48
+ line-length = 88
49
+ target-version = ["py311"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: bh_urlshortener
3
+ Version: 0.0.4
4
+ Summary: A simple URL shortener service.
5
+ Author: Abin M
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: fastapi>=0.110
11
+ Requires-Dist: uvicorn[standard]>=0.49.0
12
+ Requires-Dist: pydantic>=2.6
13
+ Requires-Dist: pydantic-settings>=2.2
14
+ Requires-Dist: sqlalchemy>=2.0.51
15
+ Requires-Dist: psycopg[binary]>=3.1
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=9.1.0; extra == "test"
18
+ Requires-Dist: pytest-cov>=7.1.0; extra == "test"
19
+ Requires-Dist: mypy>=2.1.0; extra == "test"
20
+ Requires-Dist: httpx>=0.27; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ # urlshortener
24
+
25
+ A self-hosted URL shortener. Shorten a URL, get a redirect, track visit counts. That's it.
26
+
27
+
28
+
29
+ ## API
30
+
31
+ | Method | Path | Description |
32
+ |--------|------|-------------|
33
+ | `POST` | `/shorten` | Create a short link |
34
+ | `GET` | `/{code}` | Redirect to original URL (307) |
35
+ | `GET` | `/stats/{code}` | Visit count + original URL |
36
+ | `GET` | `/healthz` | Liveness + DB connectivity check |
37
+
38
+ ```bash
39
+ # shorten
40
+ curl -X POST http://localhost:8000/shorten \
41
+ -H "Content-Type: application/json" \
42
+ -d '{"url": "https://example.com/some/long/path"}'
43
+ # {"code": "aB3kR7z", "short_url": "http://localhost:8000/aB3kR7z"}
44
+
45
+ # stats
46
+ curl http://localhost:8000/stats/aB3kR7z
47
+ # {"code": "aB3kR7z", "url": "https://example.com/some/long/path", "visits": 4}
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Running locally
53
+
54
+ **Prerequisites:** Python 3.11, a running PostgreSQL instance.
55
+
56
+ ```bash
57
+ pip install -e .
58
+
59
+ # point at your DB (or rely on the default below)
60
+ export URLSHORTENER_DATABASE_URL="postgresql+psycopg://user:pass@localhost:5432/urlshortener"
61
+
62
+ urlshortener serve
63
+ # listening on http://127.0.0.1:8000
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Docker Compose
69
+
70
+ Spins up Postgres + app together:
71
+
72
+ ```bash
73
+ docker compose up -d
74
+ ```
75
+
76
+
77
+ ---
78
+
79
+ ## Configuration
80
+
81
+ All settings are environment variables with the `URLSHORTENER_` prefix.
82
+
83
+ | Variable | Default | Description |
84
+ |----------|---------|-------------|
85
+ | `URLSHORTENER_DATABASE_URL` | `postgresql+psycopg://urlshortener:urlshortener@localhost:5432/urlshortener` | SQLAlchemy connection string |
86
+ | `URLSHORTENER_BASE_URL` | `http://localhost:8000` | Public base URL used when building short links |
87
+ | `URLSHORTENER_HOST` | `127.0.0.1` | Bind address |
88
+ | `URLSHORTENER_PORT` | `8000` | Bind port |
89
+ | `URLSHORTENER_CODE_LENGTH` | `7` | Length of generated short codes (4–32) |
90
+ | `URLSHORTENER_LOG_LEVEL` | `INFO` | Root log level |
91
+
92
+ ---
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ pip install -e ".[test]"
98
+
99
+ pytest # runs tests + coverage
100
+ mypy src # strict type checking
101
+ ```
102
+
103
+ > Tests use an in-memory fake store
104
+
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,24 @@
1
+ .dockerignore
2
+ .gitignore
3
+ Dockerfile
4
+ LICENSE
5
+ README.md
6
+ compose.yaml
7
+ pyproject.toml
8
+ tox.ini
9
+ .github/dependabot.yml
10
+ .github/workflows/ci.yml
11
+ .github/workflows/release.yml
12
+ src/bh_urlshortener.egg-info/PKG-INFO
13
+ src/bh_urlshortener.egg-info/SOURCES.txt
14
+ src/bh_urlshortener.egg-info/dependency_links.txt
15
+ src/bh_urlshortener.egg-info/entry_points.txt
16
+ src/bh_urlshortener.egg-info/requires.txt
17
+ src/bh_urlshortener.egg-info/top_level.txt
18
+ src/urlshortener/__init__.py
19
+ src/urlshortener/__main__.py
20
+ src/urlshortener/app.py
21
+ src/urlshortener/config.py
22
+ src/urlshortener/storage.py
23
+ tests/__init__.py
24
+ tests/test_app.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ urlshortener = urlshortener.__main__:main
@@ -0,0 +1,12 @@
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.49.0
3
+ pydantic>=2.6
4
+ pydantic-settings>=2.2
5
+ sqlalchemy>=2.0.51
6
+ psycopg[binary]>=3.1
7
+
8
+ [test]
9
+ pytest>=9.1.0
10
+ pytest-cov>=7.1.0
11
+ mypy>=2.1.0
12
+ httpx>=0.27
File without changes
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import uvicorn
7
+
8
+ from .app import create_app
9
+ from .config import Settings
10
+
11
+
12
+ def _build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="urlshortener",
15
+ description="URL shortener service.",
16
+ )
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+
19
+ serve = sub.add_parser("serve", help="Start the HTTP server.")
20
+ serve.add_argument(
21
+ "--host",
22
+ default=os.environ.get("URLSHORTENER_HOST", "127.0.0.1"),
23
+ help="Bind address (env: URLSHORTENER_HOST).",
24
+ )
25
+ serve.add_argument(
26
+ "--port",
27
+ type=int,
28
+ default=int(os.environ.get("URLSHORTENER_PORT", "8000")),
29
+ help="Bind port (env: URLSHORTENER_PORT).",
30
+ )
31
+ return parser
32
+
33
+
34
+ def main() -> None:
35
+ args = _build_parser().parse_args()
36
+
37
+ if args.command == "serve":
38
+ settings = Settings(host=args.host, port=args.port)
39
+ logging.basicConfig(
40
+ level=settings.log_level.upper(),
41
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
42
+ )
43
+ app = create_app(settings)
44
+ uvicorn.run(app, host=settings.host, port=settings.port)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import secrets
5
+ import string
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.responses import RedirectResponse
11
+ from pydantic import BaseModel, HttpUrl
12
+ from sqlalchemy import Engine, create_engine
13
+
14
+ from .config import Settings
15
+ from .storage import CodeExistsError, LinkNotFoundError, LinkStore
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _ALPHABET = string.ascii_letters + string.digits
20
+ _MAX_ATTEMPTS = 5
21
+
22
+
23
+ class ShortenRequest(BaseModel):
24
+ url: HttpUrl
25
+
26
+
27
+ class LinkResponse(BaseModel):
28
+ code: str
29
+ short_url: str
30
+
31
+
32
+ def _make_code(length: int) -> str:
33
+ return "".join(secrets.choice(_ALPHABET) for _ in range(length))
34
+
35
+
36
+ def create_app(settings: Settings, store: LinkStore | None = None) -> FastAPI:
37
+ """``store`` exists for test injection; production builds it from settings."""
38
+ engine: Engine | None = None
39
+ if store is None:
40
+ engine = create_engine(settings.database_url)
41
+ store = LinkStore(engine)
42
+
43
+ @asynccontextmanager
44
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
45
+ if engine is not None:
46
+ store.init_schema()
47
+ logger.info("Application started")
48
+ yield
49
+ if engine is not None:
50
+ engine.dispose()
51
+ logger.info("Application stopped")
52
+
53
+ app = FastAPI(title="URL Shortener", version="0.1.0", lifespan=lifespan)
54
+
55
+ @app.get("/healthz")
56
+ def healthz() -> dict[str, str]:
57
+ try:
58
+ store.ping()
59
+ except Exception as exc: # noqa: BLE001 - report as unhealthy
60
+ raise HTTPException(
61
+ status_code=503, detail="database unavailable") from exc
62
+ return {"status": "ok"}
63
+
64
+ @app.post("/shorten", response_model=LinkResponse, status_code=201)
65
+ def shorten(payload: ShortenRequest) -> LinkResponse:
66
+ for _ in range(_MAX_ATTEMPTS):
67
+ code = _make_code(settings.code_length)
68
+ try:
69
+ store.create(code, str(payload.url))
70
+ except CodeExistsError:
71
+ continue
72
+ return LinkResponse(
73
+ code=code, short_url=f"{settings.base_url.rstrip('/')}/{code}"
74
+ )
75
+ raise HTTPException(
76
+ status_code=500, detail="could not generate a unique code")
77
+
78
+ @app.get("/stats/{code}")
79
+ def stats(code: str) -> dict[str, object]:
80
+ try:
81
+ url, visits = store.get_stats(code)
82
+ except LinkNotFoundError as exc:
83
+ raise HTTPException(
84
+ status_code=404, detail="code not found") from exc
85
+ return {"code": code, "url": url, "visits": visits}
86
+
87
+ @app.get("/{code}")
88
+ def redirect(code: str) -> RedirectResponse:
89
+ try:
90
+ url = store.resolve_and_count(code)
91
+ except LinkNotFoundError as exc:
92
+ raise HTTPException(
93
+ status_code=404, detail="code not found") from exc
94
+ return RedirectResponse(url=url, status_code=307)
95
+
96
+ return app
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import Field
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+
9
+ model_config = SettingsConfigDict(
10
+ env_prefix="URLSHORTENER_",
11
+ frozen=True,
12
+ extra="ignore",
13
+ )
14
+
15
+ database_url: str = Field(
16
+ default="postgresql+psycopg://urlshortener:urlshortener@localhost:5432/urlshortener",
17
+ description="PostgreSQL connection string (SQLAlchemy URL, psycopg3 driver).",
18
+ )
19
+ host: str = Field(default="127.0.0.1", description="Bind address.")
20
+ port: int = Field(default=8000, ge=1, le=65535, description="Bind port.")
21
+ base_url: str = Field(
22
+ default="http://localhost:8000",
23
+ description="Public base URL used when building short links.",
24
+ )
25
+ code_length: int = Field(
26
+ default=7, ge=4, le=32, description="Length of generated short codes."
27
+ )
28
+ log_level: str = Field(default="INFO", description="Root log level.")
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from datetime import datetime
5
+
6
+ from sqlalchemy import BigInteger, Engine, String, func, select, update
7
+ from sqlalchemy.exc import IntegrityError
8
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Base(DeclarativeBase):
14
+ pass
15
+
16
+
17
+ class Link(Base):
18
+ __tablename__ = "links"
19
+
20
+ code: Mapped[str] = mapped_column(String, primary_key=True)
21
+ url: Mapped[str] = mapped_column(String, nullable=False)
22
+ visits: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
23
+ created_at: Mapped[datetime] = mapped_column(server_default=func.now())
24
+
25
+
26
+ class CodeExistsError(Exception):
27
+ pass
28
+
29
+
30
+ class LinkNotFoundError(Exception):
31
+ pass
32
+
33
+
34
+ class LinkStore:
35
+ """Data access for short links over a SQLAlchemy engine."""
36
+
37
+ def __init__(self, engine: Engine) -> None:
38
+ self._engine = engine
39
+ self._session_factory = sessionmaker(bind=engine)
40
+
41
+ def init_schema(self) -> None:
42
+ Base.metadata.create_all(self._engine)
43
+ logger.info("Schema initialised")
44
+
45
+ def create(self, code: str, url: str) -> None:
46
+ """Raises CodeExistsError on duplicate code."""
47
+ with self._session_factory() as session:
48
+ session.add(Link(code=code, url=url))
49
+ try:
50
+ session.commit()
51
+ except IntegrityError as exc:
52
+ session.rollback()
53
+ raise CodeExistsError(code) from exc
54
+
55
+ def get_stats(self, code: str) -> tuple[str, int]:
56
+ """Raises LinkNotFoundError if code absent."""
57
+ with self._session_factory() as session:
58
+ link = session.get(Link, code)
59
+ if link is None:
60
+ raise LinkNotFoundError(code)
61
+ return link.url, link.visits
62
+
63
+ def resolve_and_count(self, code: str) -> str:
64
+ """Raises LinkNotFoundError if code absent."""
65
+ with self._session_factory() as session:
66
+ row = session.execute(
67
+ update(Link)
68
+ .where(Link.code == code)
69
+ .values(visits=Link.visits + 1)
70
+ .returning(Link.url)
71
+ ).first()
72
+ session.commit()
73
+ if row is None:
74
+ raise LinkNotFoundError(code)
75
+ return str(row[0])
76
+
77
+ def ping(self) -> None:
78
+ """Raises if DB unreachable."""
79
+ with self._session_factory() as session:
80
+ session.execute(select(1))
File without changes
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pytest
5
+ from fastapi.testclient import TestClient
6
+
7
+ from urlshortener.app import create_app
8
+ from urlshortener.config import Settings
9
+ from urlshortener.storage import CodeExistsError, LinkNotFoundError
10
+
11
+
12
+ class FakeStore:
13
+ """In-memory stand-in for LinkStore."""
14
+
15
+ def __init__(self) -> None:
16
+ self._data: dict[str, list[object]] = {} # code -> [url, visits]
17
+
18
+ def create(self, code: str, url: str) -> None:
19
+ if code in self._data:
20
+ raise CodeExistsError(code)
21
+ self._data[code] = [url, 0]
22
+
23
+ def get_stats(self, code: str) -> tuple[str, int]:
24
+ if code not in self._data:
25
+ raise LinkNotFoundError(code)
26
+ url, visits = self._data[code]
27
+ return str(url), int(visits) # type: ignore[arg-type]
28
+
29
+ def resolve_and_count(self, code: str) -> str:
30
+ if code not in self._data:
31
+ raise LinkNotFoundError(code)
32
+ self._data[code][1] = int(self._data[code][1]) + \
33
+ 1 # type: ignore[arg-type]
34
+ return str(self._data[code][0])
35
+
36
+ def ping(self) -> None:
37
+ return None
38
+
39
+
40
+ @pytest.fixture()
41
+ def client() -> TestClient:
42
+ settings = Settings(base_url="http://testserver", code_length=6)
43
+ app = create_app(settings, store=FakeStore()) # type: ignore[arg-type]
44
+ with TestClient(app) as test_client:
45
+ yield test_client
46
+
47
+
48
+ def test_healthz_returns_ok(client: TestClient) -> None:
49
+ resp = client.get("/healthz")
50
+ assert resp.status_code == 200
51
+ assert resp.json() == {"status": "ok"}
52
+
53
+
54
+ def test_shorten_returns_code_of_configured_length(client: TestClient) -> None:
55
+ resp = client.post("/shorten", json={"url": "https://example.com"})
56
+ assert resp.status_code == 201
57
+ body = resp.json()
58
+ assert len(body["code"]) == 6
59
+ assert body["short_url"].endswith(body["code"])
60
+
61
+
62
+ def test_shorten_then_redirect_round_trips(client: TestClient) -> None:
63
+ code = client.post(
64
+ "/shorten", json={"url": "https://example.com"}).json()["code"]
65
+ redirect = client.get(f"/{code}", follow_redirects=False)
66
+ assert redirect.status_code == 307
67
+ assert redirect.headers["location"] == "https://example.com/"
68
+
69
+
70
+ def test_stats_reflects_visit_count(client: TestClient) -> None:
71
+ code = client.post(
72
+ "/shorten", json={"url": "https://example.com"}).json()["code"]
73
+ client.get(f"/{code}", follow_redirects=False)
74
+ client.get(f"/{code}", follow_redirects=False)
75
+ assert client.get(f"/stats/{code}").json()["visits"] == 2
76
+
77
+
78
+ def test_unknown_code_returns_404(client: TestClient) -> None:
79
+ assert client.get("/nope").status_code == 404
80
+ assert client.get("/stats/nope").status_code == 404
81
+
82
+
83
+ def test_invalid_url_returns_422(client: TestClient) -> None:
84
+ assert client.post(
85
+ "/shorten", json={"url": "not-a-url"}).status_code == 422
86
+
87
+
88
+ def test_settings_read_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
89
+ for key in list(os.environ):
90
+ if key.startswith("URLSHORTENER_"):
91
+ monkeypatch.delenv(key, raising=False)
92
+ monkeypatch.setenv("URLSHORTENER_PORT", "9999")
93
+ assert Settings().port == 9999
94
+
95
+
96
+ # --- CLI tests ---
97
+
98
+ def test_cli_serve_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
99
+ monkeypatch.delenv("URLSHORTENER_HOST", raising=False)
100
+ monkeypatch.delenv("URLSHORTENER_PORT", raising=False)
101
+ from urlshortener.__main__ import _build_parser
102
+ args = _build_parser().parse_args(["serve"])
103
+ assert args.host == "127.0.0.1"
104
+ assert args.port == 8000
105
+
106
+
107
+ def test_cli_serve_flags_override_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
108
+ monkeypatch.delenv("URLSHORTENER_HOST", raising=False)
109
+ monkeypatch.delenv("URLSHORTENER_PORT", raising=False)
110
+ from urlshortener.__main__ import _build_parser
111
+ args = _build_parser().parse_args(
112
+ ["serve", "--host", "0.0.0.0", "--port", "9000"])
113
+ assert args.host == "0.0.0.0"
114
+ assert args.port == 9000
115
+
116
+
117
+ def test_cli_serve_env_as_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
118
+ monkeypatch.setenv("URLSHORTENER_HOST", "0.0.0.0")
119
+ monkeypatch.setenv("URLSHORTENER_PORT", "9000")
120
+ from urlshortener.__main__ import _build_parser
121
+ args = _build_parser().parse_args(["serve"])
122
+ assert args.host == "0.0.0.0"
123
+ assert args.port == 9000
@@ -0,0 +1,11 @@
1
+ [tox]
2
+ envlist = py311, py312, mypy
3
+ isolated_build = true
4
+
5
+ [testenv]
6
+ extras = test
7
+ commands = pytest
8
+
9
+ [testenv:mypy]
10
+ extras = test
11
+ commands = mypy src/urlshortener