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.
- bh_urlshortener-0.0.4/.dockerignore +15 -0
- bh_urlshortener-0.0.4/.github/dependabot.yml +14 -0
- bh_urlshortener-0.0.4/.github/workflows/ci.yml +80 -0
- bh_urlshortener-0.0.4/.github/workflows/release.yml +56 -0
- bh_urlshortener-0.0.4/.gitignore +16 -0
- bh_urlshortener-0.0.4/Dockerfile +42 -0
- bh_urlshortener-0.0.4/LICENSE +21 -0
- bh_urlshortener-0.0.4/PKG-INFO +108 -0
- bh_urlshortener-0.0.4/README.md +86 -0
- bh_urlshortener-0.0.4/compose.yaml +20 -0
- bh_urlshortener-0.0.4/pyproject.toml +49 -0
- bh_urlshortener-0.0.4/setup.cfg +4 -0
- bh_urlshortener-0.0.4/src/bh_urlshortener.egg-info/PKG-INFO +108 -0
- bh_urlshortener-0.0.4/src/bh_urlshortener.egg-info/SOURCES.txt +24 -0
- bh_urlshortener-0.0.4/src/bh_urlshortener.egg-info/dependency_links.txt +1 -0
- bh_urlshortener-0.0.4/src/bh_urlshortener.egg-info/entry_points.txt +2 -0
- bh_urlshortener-0.0.4/src/bh_urlshortener.egg-info/requires.txt +12 -0
- bh_urlshortener-0.0.4/src/bh_urlshortener.egg-info/top_level.txt +1 -0
- bh_urlshortener-0.0.4/src/urlshortener/__init__.py +0 -0
- bh_urlshortener-0.0.4/src/urlshortener/__main__.py +48 -0
- bh_urlshortener-0.0.4/src/urlshortener/app.py +96 -0
- bh_urlshortener-0.0.4/src/urlshortener/config.py +28 -0
- bh_urlshortener-0.0.4/src/urlshortener/storage.py +80 -0
- bh_urlshortener-0.0.4/tests/__init__.py +0 -0
- bh_urlshortener-0.0.4/tests/test_app.py +123 -0
- bh_urlshortener-0.0.4/tox.ini +11 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
urlshortener
|
|
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
|