diff --git a/.env.example b/.env.example index dbd349c..ac55775 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,18 @@ DATABASE_URL=postgresql://user:password@localhost:5432/mydatabase # [dev, stage, prod] -ENVIRONMENT=dev \ No newline at end of file +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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index bb03fb6..5d878ee 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.vscode/http/url.rest b/.vscode/http/url.rest index 9b91b74..a315fa4 100644 --- a/.vscode/http/url.rest +++ b/.vscode/http/url.rest @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f1a1f42 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +# app/__init__.py +"""App package initializer. Required for Python imports.""" \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e93e63d --- /dev/null +++ b/app/config.py @@ -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() \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..92038d9 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +from .database import database, Base, engine \ No newline at end of file diff --git a/dependencies/database.py b/app/db/database.py similarity index 69% rename from dependencies/database.py rename to app/db/database.py index be7aa32..f92e202 100644 --- a/dependencies/database.py +++ b/app/db/database.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..62b70a4 --- /dev/null +++ b/app/main.py @@ -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) \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..73498c3 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +from .url import Url \ No newline at end of file diff --git a/dependencies/models.py b/app/models/url.py similarity index 69% rename from dependencies/models.py rename to app/models/url.py index 0e7478b..117e247 100644 --- a/dependencies/models.py +++ b/app/models/url.py @@ -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) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..ceb89c4 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +from .shortener import router as shortener_router \ No newline at end of file diff --git a/app/routes/shortener.py b/app/routes/shortener.py new file mode 100644 index 0000000..43d0f66 --- /dev/null +++ b/app/routes/shortener.py @@ -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") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..40e2263 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +from .url import UrlPayload \ No newline at end of file diff --git a/schemas.py b/app/schemas/url.py similarity index 100% rename from schemas.py rename to app/schemas/url.py diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..57df460 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +from .encoder import encoder \ No newline at end of file diff --git a/app/services/encoder.py b/app/services/encoder.py new file mode 100644 index 0000000..0f2f766 --- /dev/null +++ b/app/services/encoder.py @@ -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() \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index d644a9e..0000000 --- a/main.py +++ /dev/null @@ -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") - \ No newline at end of file diff --git a/minxadb-dev.session.sql b/minxadb-dev.session.sql new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index 50786b0..8fe4d6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/utils.py b/utils.py deleted file mode 100644 index 9767ef5..0000000 --- a/utils.py +++ /dev/null @@ -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)) \ No newline at end of file