feat: add new encoder and restructure project

This commit is contained in:
2025-07-22 21:55:45 +00:00
parent 83d828c935
commit ee67589393
20 changed files with 143 additions and 75 deletions

View File

@@ -1,4 +1,18 @@
DATABASE_URL=postgresql://user:password@localhost:5432/mydatabase
# [dev, stage, prod]
ENVIRONMENT=dev
ENVIRONMENT=dev
### use the below to generate a good salt
# import secrets
# print(secrets.token_urlsafe(32))
###
HASHIDS_SALT=default-insecure-salt
### use the below to generate alphabet for encoder
# import random
# base61 = list("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789")
# random.shuffle(base61)
# print("".join(base61))
###
ENCODER_ALPHABET=CnArvIseYhld2BtZipybguVKaMx4QFkcR71DTLJEP65jUGzqmw9fSoXW83HNO

2
.gitignore vendored
View File

@@ -140,4 +140,4 @@ dmypy.json
# FastAPI specifics
# .env files used for storing environment variables should not be committed to version control
# `db` folder might be used for database files such as SQLite which should not be checked into version control
db/
# db/

View File

@@ -1,5 +1,5 @@
### redirect_to_original_url
GET http://localhost:8000/
GET http://localhost:8000/J7aDVn
### create_url_shortcode
POST http://localhost:8000/shorten

2
app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# app/__init__.py
"""App package initializer. Required for Python imports."""

16
app/config.py Normal file
View File

@@ -0,0 +1,16 @@
from pydantic_settings import BaseSettings
from typing import Literal
class Settings(BaseSettings):
database_url: str
environment: Literal["dev", "stage", "prod"]
hashids_salt: str
encoder_alphabet: str
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# Singleton instance of settings
settings = Settings()

1
app/db/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .database import database, Base, engine

View File

@@ -1,15 +1,12 @@
import os
from app.config import settings
from databases import Database
from sqlalchemy import create_engine, MetaData
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL")
# for Alembic migrations and sync operations
engine = create_engine(DATABASE_URL)
engine = create_engine(settings.database_url)
# to use with async requests
database = Database(DATABASE_URL)
database = Database(settings.database_url)
metadata = MetaData()
Base = declarative_base(metadata=metadata)

20
app/main.py Normal file
View File

@@ -0,0 +1,20 @@
from fastapi import FastAPI
from app.config import settings
from app.db import database, Base, engine
from app.routes import shortener_router
app = FastAPI()
@app.on_event("startup")
async def startup():
await database.connect()
# only create missing tables in dev environment
if settings.environment == "dev":
Base.metadata.create_all(bind=engine)
@app.on_event("shutdown")
async def shutdown():
await database.disconnect()
app.include_router(shortener_router)

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .url import Url

View File

@@ -1,8 +1,8 @@
from sqlalchemy import Column, Integer, String
from dependencies.database import Base
from app.db.database import Base
class UrlMapping(Base):
__tablename__ = "url_mapping"
class Url(Base):
__tablename__ = "urls"
id = Column(Integer, primary_key=True, index=True)
url = Column(String, nullable=False)

1
app/routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .shortener import router as shortener_router

47
app/routes/shortener.py Normal file
View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.exc import IntegrityError
from app.db import database
from app.models import Url
from app.schemas import UrlPayload
from app.services import encoder
router = APIRouter()
@router.get("/{shortcode}")
async def redirect_to_original_url(shortcode: str):
if not encoder.is_valid(shortcode):
raise HTTPException(status_code=400, detail="Shortcode is not valid")
query = Url.__table__.select().where(Url.shortcode == shortcode)
url_result = await database.fetch_one(query)
if url_result:
return RedirectResponse(url=url_result.url, status_code=302)
else:
raise HTTPException(status_code=404, detail="Shortcode not found")
@router.post("/shorten")
async def create_url_shortcode(url_payload: UrlPayload):
original_url = str(url_payload.url)
query = "SELECT nextval('urls_id_seq')"
next_id = await database.fetch_val(query)
shortcode = encoder.encode(next_id)
try:
query = Url.__table__.insert().values({
"id": next_id,
"url": original_url,
"shortcode": shortcode
})
await database.execute(query)
return {"shortcode": shortcode, "original_url": original_url}
except IntegrityError as e:
if 'shortcode' in str(e):
raise HTTPException(status_code=409, detail="Shortcode already in use")
else:
raise HTTPException(status_code=500, detail="Internal Server Error")

1
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .url import UrlPayload

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .encoder import encoder

27
app/services/encoder.py Normal file
View File

@@ -0,0 +1,27 @@
from app.config import settings
from hashids import Hashids
class ShortCodeEncoder:
def __init__(self, salt=None, alphabet=None, min_length=6):
self.salt = salt or settings.hashids_salt
self.alphabet = alphabet or settings.encoder_alphabet
self.hashids = Hashids(salt=self.salt, min_length=min_length, alphabet=self.alphabet)
self.min_length = min_length
def encode(self, id: int) -> str:
return self.hashids.encode(id)
def decode(self, shortcode: str) -> int:
decoded = self.hashids.decode(shortcode)
return decoded[0] if decoded else None
def is_valid(self, shortcode: str) -> bool:
if len(shortcode) < self.min_length:
return False
if any(char not in self.alphabet for char in shortcode):
return False
return True
# Singleton instance of encoder
encoder = ShortCodeEncoder()

57
main.py
View File

@@ -1,57 +0,0 @@
import os
from sqlalchemy.exc import IntegrityError
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from dependencies.models import UrlMapping
from dependencies.database import database, Base, engine
from schemas import UrlPayload
from utils import generate_shortcode
app = FastAPI()
@app.on_event("startup")
async def startup():
await database.connect()
Base.metadata.create_all(bind=engine) # TODO: create all tables if not present (ONLY DEVELOPMENT)
@app.on_event("shutdown")
async def shutdown():
await database.disconnect()
@app.get("/{shortcode}")
async def redirect_to_original_url(shortcode: str):
# TODO: do validation check for valid shortcode
if not shortcode:
raise HTTPException(status_code=400, detail="Shortcode is not valid")
query = UrlMapping.__table__.select().where(UrlMapping.shortcode == shortcode)
url_mapping = await database.fetch_one(query)
if url_mapping:
return RedirectResponse(url=url_mapping.url, status_code=302)
else:
raise HTTPException(status_code=404, detail="Shortcode not found")
@app.post("/shorten")
async def create_url_shortcode(url_payload: UrlPayload):
original_url = str(url_payload.url) # convert to string from HttpUrl
shortcode = generate_shortcode()
try:
query = UrlMapping.__table__.insert().values({
"url": original_url,
"shortcode": shortcode
})
await database.execute(query)
return {"shortcode": shortcode, "original_url": original_url}
except IntegrityError as e:
if 'shortcode' in str(e):
raise HTTPException(status_code=409, detail="Shortcode already in use")
else:
raise HTTPException(status_code=500, detail="Internal Server Error")

0
minxadb-dev.session.sql Normal file
View File

View File

@@ -7,12 +7,15 @@ databases==0.9.0
fastapi==0.116.1
greenlet==3.2.3
h11==0.16.0
hashids==1.3.1
idna==3.10
Mako==1.3.10
MarkupSafe==3.0.2
psycopg2-binary==2.9.10
pydantic==2.11.7
pydantic-settings==2.10.1
pydantic_core==2.33.2
python-dotenv==1.1.1
sniffio==1.3.1
SQLAlchemy==2.0.41
starlette==0.47.2

View File

@@ -1,6 +0,0 @@
import string
import random
def generate_shortcode(length=6) -> str:
characters = string.ascii_letters + string.digits
return ''.join(random.choice(characters) for _ in range(length))