Skip to content

Commit d62754b

Browse files
authored
Merge pull request #259 from realpython/fastapi-url-shortener
Add FastAPI URL Shortener
2 parents dd8ecb6 + be46338 commit d62754b

File tree

33 files changed

+930
-0
lines changed

33 files changed

+930
-0
lines changed

fastapi-url-shortener/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# FastAPI URL Shortener
2+
3+
## Install the Project
4+
5+
1. Create a Python virtual environment
6+
7+
```sh
8+
$ python -m venv venv
9+
$ source venv/bin/activate
10+
(venv) $
11+
```
12+
13+
2. Install the requirements
14+
15+
```
16+
(venv) $ python -m pip install -r requirements.txt
17+
```
18+
19+
## Run the Project
20+
21+
You can run the project with this command in your terminal:
22+
23+
```sh
24+
(venv) $ uvicorn shortener_app.main:app --reload
25+
```
26+
27+
Your server will reload automacially when you change a file.
28+
29+
## Verify Your Environment Variables
30+
31+
The project provides default environment settings in [`shortener_app/config.py`](shortener_app/config.py).
32+
While you can use the default settings, [it's recommended](https://12factor.net/config) to create a `.env` file to store your settings outside of your production code. E.g.:
33+
34+
```config
35+
# .env
36+
ENV_NAME="Development"
37+
BASE_URL="http://url.shortener"
38+
DB_URL="sqlite:///./test_database.db"
39+
```
40+
41+
With an `.env` file that contains the `ENV_NAME` variable with the value `"Development"` you can verify if your external `.env` file loads correctly:
42+
43+
```pycon
44+
>>> from shortener_app.config import get_settings
45+
>>> get_settings().env_name
46+
... loading Settings
47+
'Development'
48+
```
49+
50+
To get an overview of the environment variables you can set, check the [`shortener_app/config.py`](shortener_app/config.py) file.
51+
52+
> ☝️ **Note:** You should never add the `.env` file to your version control system.
53+
54+
## Visit the Documentation
55+
56+
When the project is running you can visit the documentation in your browser:
57+
58+
- http://127.0.0.1:8000/docs
59+
- http://127.0.0.1:8000/redoc
60+
61+
## About the Author
62+
63+
Philipp Acsany - Email: [email protected]
64+
65+
## License
66+
67+
Distributed under the MIT license. See `LICENSE` in the root directory of this `materials` repo for more information.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# FastAPI URL Shortener
2+
3+
## Install the Project
4+
5+
1. Create a Python virtual environment
6+
7+
```sh
8+
$ python -m venv venv
9+
$ source venv/bin/activate
10+
(venv) $
11+
```
12+
13+
2. Install the requirements
14+
15+
```
16+
(venv) $ python -m pip install -r requirements.txt
17+
```
18+
19+
## Run the Project
20+
21+
You can run the project with this command in your terminal:
22+
23+
```sh
24+
(venv) $ uvicorn shortener_app.main:app --reload
25+
```
26+
27+
Your server will reload automacially when you change a file.
28+
29+
## Verify Your Environment Variables
30+
31+
The project provides default environment settings in [`shortener_app/config.py`](shortener_app/config.py).
32+
While you can use the default settings, [it's recommended](https://12factor.net/config) to create a `.env` file to store your settings outside of your production code. E.g.:
33+
34+
```config
35+
# .env
36+
ENV_NAME="Development"
37+
BASE_URL="http://url.shortener"
38+
DB_URL="sqlite:///./test_database.db"
39+
```
40+
41+
With an `.env` file that contains the `ENV_NAME` variable with the value `"Development"` you can verify if your external `.env` file loads correctly:
42+
43+
```pycon
44+
>>> from shortener_app.config import get_settings
45+
>>> get_settings().env_name
46+
... loading Settings
47+
'Development'
48+
```
49+
50+
To get an overview of the environment variables you can set, check the [`shortener_app/config.py`](shortener_app/config.py) file.
51+
52+
> ☝️ **Note:** You should never add the `.env` file to your version control system.
53+
54+
## Visit the Documentation
55+
56+
When the project is running you can visit the documentation in your browser:
57+
58+
- http://127.0.0.1:8000/docs
59+
- http://127.0.0.1:8000/redoc
60+
61+
## About the Author
62+
63+
Philipp Acsany - Email: [email protected]
64+
65+
## License
66+
67+
Distributed under the MIT license. See `LICENSE` in the root directory of this `materials` repo for more information.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
anyio==3.6.0
2+
asgiref==3.5.1
3+
click==8.1.3
4+
decorator==5.1.1
5+
fastapi==0.75.0
6+
h11==0.13.0
7+
idna==3.3
8+
pydantic==1.9.0
9+
python-dotenv==0.19.2
10+
six==1.16.0
11+
sniffio==1.2.0
12+
SQLAlchemy==1.4.32
13+
starlette==0.17.1
14+
typing_extensions==4.2.0
15+
uvicorn==0.17.6
16+
validators==0.18.2

fastapi-url-shortener/source_code_final/shortener_app/__init__.py

Whitespace-only changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from functools import lru_cache
2+
3+
from pydantic import BaseSettings
4+
5+
6+
class Settings(BaseSettings):
7+
env_name: str = "Local"
8+
base_url: str = "http://localhost:8000"
9+
db_url: str = "sqlite:///./shortener.db"
10+
11+
class Config:
12+
env_file = ".env"
13+
14+
15+
@lru_cache
16+
def get_settings() -> Settings:
17+
settings = Settings()
18+
print(f"Loading settings for: {settings.env_name}")
19+
return settings
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from sqlalchemy.orm import Session
2+
3+
from . import keygen, models, schemas
4+
5+
6+
def create_db_url(db: Session, url: schemas.URLBase) -> models.URL:
7+
key = keygen.create_unique_random_key(db)
8+
secret_key = f"{key}_{keygen.create_random_key(length=8)}"
9+
db_url = models.URL(
10+
target_url=url.target_url, key=key, secret_key=secret_key
11+
)
12+
db.add(db_url)
13+
db.commit()
14+
db.refresh(db_url)
15+
return db_url
16+
17+
18+
def get_db_url_by_key(db: Session, url_key: str) -> models.URL:
19+
return (
20+
db.query(models.URL)
21+
.filter(models.URL.key == url_key, models.URL.is_active)
22+
.first()
23+
)
24+
25+
26+
def get_db_url_by_secret_key(db: Session, secret_key: str) -> models.URL:
27+
return (
28+
db.query(models.URL)
29+
.filter(models.URL.secret_key == secret_key, models.URL.is_active)
30+
.first()
31+
)
32+
33+
34+
def update_db_clicks(db: Session, db_url: schemas.URL) -> models.URL:
35+
db_url.clicks += 1
36+
db.commit()
37+
db.refresh(db_url)
38+
return db_url
39+
40+
41+
def deactivate_db_url_by_secret_key(
42+
db: Session, secret_key: str
43+
) -> models.URL:
44+
db_url = get_db_url_by_secret_key(db, secret_key)
45+
if db_url:
46+
db_url.is_active = False
47+
db.commit()
48+
db.refresh(db_url)
49+
return db_url
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.ext.declarative import declarative_base
3+
from sqlalchemy.orm import sessionmaker
4+
5+
from .config import get_settings
6+
7+
engine = create_engine(
8+
get_settings().db_url, connect_args={"check_same_thread": False}
9+
)
10+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
11+
Base = declarative_base()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import secrets
2+
import string
3+
4+
from sqlalchemy.orm import Session
5+
6+
from . import crud
7+
8+
9+
def create_random_key(length: int = 5) -> str:
10+
chars = string.ascii_uppercase + string.digits
11+
return "".join(secrets.choice(chars) for _ in range(length))
12+
13+
14+
def create_unique_random_key(db: Session) -> str:
15+
key = create_random_key()
16+
while crud.get_db_url_by_key(db, key):
17+
key = create_random_key()
18+
return key
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import validators
2+
3+
from fastapi import Depends, FastAPI, HTTPException, Request
4+
from fastapi.responses import RedirectResponse
5+
from sqlalchemy.orm import Session
6+
from starlette.datastructures import URL
7+
8+
from . import crud, models, schemas
9+
from .database import SessionLocal, engine
10+
from .config import get_settings
11+
12+
app = FastAPI()
13+
models.Base.metadata.create_all(bind=engine)
14+
15+
16+
def get_db():
17+
db = SessionLocal()
18+
try:
19+
yield db
20+
finally:
21+
db.close()
22+
23+
24+
def get_admin_info(db_url: models.URL) -> schemas.URLInfo:
25+
base_url = URL(get_settings().base_url)
26+
admin_endpoint = app.url_path_for(
27+
"administration info", secret_key=db_url.secret_key
28+
)
29+
db_url.url = str(base_url.replace(path=db_url.key))
30+
db_url.admin_url = str(base_url.replace(path=admin_endpoint))
31+
return db_url
32+
33+
34+
def raise_bad_request(message):
35+
raise HTTPException(status_code=400, detail=message)
36+
37+
38+
def raise_not_found(request):
39+
message = f"URL '{request.url}' doesn't exist"
40+
raise HTTPException(status_code=404, detail=message)
41+
42+
43+
@app.get("/")
44+
def read_root():
45+
return "Welcome to the URL shortener API :)"
46+
47+
48+
@app.post("/url", response_model=schemas.URLInfo)
49+
def create_url(url: schemas.URLBase, db: Session = Depends(get_db)):
50+
if not validators.url(url.target_url):
51+
raise_bad_request(message="Your provided URL is not valid")
52+
53+
db_url = crud.create_db_url(db=db, url=url)
54+
return get_admin_info(db_url)
55+
56+
57+
@app.get("/{url_key}")
58+
def forward_to_target_url(
59+
url_key: str, request: Request, db: Session = Depends(get_db)
60+
):
61+
if db_url := crud.get_db_url_by_key(db=db, url_key=url_key):
62+
crud.update_db_clicks(db=db, db_url=db_url)
63+
return RedirectResponse(db_url.target_url)
64+
else:
65+
raise_not_found(request)
66+
67+
68+
@app.get(
69+
"/admin/{secret_key}",
70+
name="administration info",
71+
response_model=schemas.URLInfo,
72+
)
73+
def get_url_info(
74+
secret_key: str, request: Request, db: Session = Depends(get_db)
75+
):
76+
if db_url := crud.get_db_url_by_secret_key(db, secret_key=secret_key):
77+
return get_admin_info(db_url)
78+
else:
79+
raise_not_found(request)
80+
81+
82+
@app.delete("/admin/{secret_key}")
83+
def delete_url(
84+
secret_key: str, request: Request, db: Session = Depends(get_db)
85+
):
86+
if db_url := crud.deactivate_db_url_by_secret_key(
87+
db, secret_key=secret_key
88+
):
89+
message = (
90+
f"Successfully deleted shortened URL for '{db_url.target_url}'"
91+
)
92+
return {"detail": message}
93+
else:
94+
raise_not_found(request)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from sqlalchemy import Boolean, Column, Integer, String
2+
3+
from .database import Base
4+
5+
6+
class URL(Base):
7+
__tablename__ = "urls"
8+
9+
id = Column(Integer, primary_key=True)
10+
key = Column(String, unique=True, index=True)
11+
secret_key = Column(String, unique=True, index=True)
12+
target_url = Column(String, index=True)
13+
is_active = Column(Boolean, default=True)
14+
clicks = Column(Integer, default=0)

0 commit comments

Comments
 (0)