feat: add frontend project to repo

This commit is contained in:
2025-07-23 04:24:47 +00:00
parent 3c161bca51
commit 7abed7e634
44 changed files with 18490 additions and 143 deletions

127
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,127 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template before PyInstaller builds the exe,
# so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, you may want to ignore -lock files containing production packages.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Profiling data
.profiling/
# 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/

2
backend/__init__.py Normal file
View File

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

15
backend/config.py Normal file
View File

@@ -0,0 +1,15 @@
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
backend/db/__init__.py Normal file
View File

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

14
backend/db/database.py Normal file
View File

@@ -0,0 +1,14 @@
from databases import Database
from sqlalchemy import create_engine, MetaData
from sqlalchemy.orm import declarative_base
from backend.config import settings
# for Alembic migrations and sync operations
engine = create_engine(settings.database_url)
# to use with async requests
database = Database(settings.database_url)
metadata = MetaData()
Base = declarative_base(metadata=metadata)

8
backend/exceptions.py Normal file
View File

@@ -0,0 +1,8 @@
class ShortcodeConflict(Exception):
pass
class ShortcodeNotFound(Exception):
pass
class InvalidShortcode(Exception):
pass

21
backend/main.py Normal file
View File

@@ -0,0 +1,21 @@
from fastapi import FastAPI
from backend.config import settings
from backend.db import database, Base, engine
from backend.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)

View File

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

10
backend/models/url.py Normal file
View File

@@ -0,0 +1,10 @@
from sqlalchemy import Column, Integer, String
from backend.db.database import Base
class Url(Base):
__tablename__ = "urls"
id = Column(Integer, primary_key=True, index=True)
url = Column(String, nullable=False)
shortcode = Column(String, index=True, unique=True, nullable=False)

View File

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

View File

@@ -0,0 +1,34 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import RedirectResponse
from backend.db import database
from backend.exceptions import ShortcodeNotFound, ShortcodeConflict
from backend.utils import encoder
from backend.schemas import UrlPayload
from backend.services import UrlService
router = APIRouter()
url_service = UrlService(db=database)
@router.get("/{shortcode}")
async def redirect_to_original_url(shortcode: str):
if not encoder.is_valid(shortcode):
raise HTTPException(status_code=400, detail=f"Shortcode '{shortcode}' is not valid")
try:
url = await url_service.get_url_by_shortcode(shortcode)
return RedirectResponse(url=url, status_code=302)
except ShortcodeNotFound as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.post("/")
async def create_url_shortcode(url_payload: UrlPayload):
url = str(url_payload.url)
try:
return await url_service.generate_shortcode_and_save(url)
except ShortcodeConflict as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except:
raise HTTPException(status_code=500, detail="Internal Server Error") from None

View File

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

4
backend/schemas/url.py Normal file
View File

@@ -0,0 +1,4 @@
from pydantic import BaseModel, HttpUrl
class UrlPayload(BaseModel):
url: HttpUrl

View File

@@ -0,0 +1 @@
from .url_service import UrlService

View File

@@ -0,0 +1,36 @@
from sqlalchemy import select, insert
from sqlalchemy.exc import IntegrityError
from backend.utils import encoder
from backend.models import Url
from backend.exceptions import ShortcodeConflict, ShortcodeNotFound
class UrlService:
def __init__(self, db):
self.db = db
async def get_url_by_shortcode(self, shortcode: str) -> str:
query = select(Url.url).where(Url.shortcode == shortcode)
result = await self.db.fetch_val(query)
if result is None:
raise ShortcodeNotFound(f"Shortcode '{shortcode}' not found")
return result
async def generate_shortcode_and_save(self, url: str):
try:
# Get the next ID from the sequence
next_id = await self.db.fetch_val("SELECT nextval('urls_id_seq')")
shortcode = encoder.encode(next_id)
# Insert the new URL entry
insert_stmt = insert(Url).values(id=next_id, url=url, shortcode=shortcode)
await self.db.execute(insert_stmt)
return {"shortcode": shortcode, "url": url}
except IntegrityError as e:
if 'shortcode' in str(e):
raise ShortcodeConflict(f"Shortcode '{shortcode}' already in use") from None
raise

View File

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

28
backend/utils/encoder.py Normal file
View File

@@ -0,0 +1,28 @@
from hashids import Hashids
from backend.config import settings
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, url_id: int) -> str:
return self.hashids.encode(url_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()