diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dbd349c --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL=postgresql://user:password@localhost:5432/mydatabase + +# [dev, stage, prod] +ENVIRONMENT=dev \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0b61b4a..bb03fb6 100644 --- a/.gitignore +++ b/.gitignore @@ -131,7 +131,11 @@ dmypy.json .profiling/ # vscode settings -.vscode/ +.vscode/* +!.vscode/extensions.json +!.vscode/rest-client.env.json +!.vscode/http +!.vscode/sql # FastAPI specifics # .env files used for storing environment variables should not be committed to version control diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..84c9aa1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.debugpy", + "mtxr.sqltools", + "mtxr.sqltools-driver-pg", + "genieai.chatgpt-vscode", + "humao.rest-client" + ], + "unwantedRecommendations": [ + ] +} \ No newline at end of file diff --git a/.vscode/http/url.rest b/.vscode/http/url.rest new file mode 100644 index 0000000..9b91b74 --- /dev/null +++ b/.vscode/http/url.rest @@ -0,0 +1,10 @@ +### redirect_to_original_url +GET http://localhost:8000/ + +### create_url_shortcode +POST http://localhost:8000/shorten +Content-Type: application/json + +{ + "url": "https://google.com/" +} \ No newline at end of file diff --git a/.vscode/sql/minxadb-dev.session.sql b/.vscode/sql/minxadb-dev.session.sql new file mode 100644 index 0000000..c67d008 --- /dev/null +++ b/.vscode/sql/minxadb-dev.session.sql @@ -0,0 +1 @@ +SELECT * FROM url_mapping \ No newline at end of file diff --git a/dependencies/database.py b/dependencies/database.py new file mode 100644 index 0000000..be7aa32 --- /dev/null +++ b/dependencies/database.py @@ -0,0 +1,16 @@ +import os +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) + +# to use with async requests +database = Database(DATABASE_URL) + +metadata = MetaData() +Base = declarative_base(metadata=metadata) + diff --git a/dependencies/models.py b/dependencies/models.py new file mode 100644 index 0000000..0e7478b --- /dev/null +++ b/dependencies/models.py @@ -0,0 +1,9 @@ +from sqlalchemy import Column, Integer, String +from dependencies.database import Base + +class UrlMapping(Base): + __tablename__ = "url_mapping" + + id = Column(Integer, primary_key=True, index=True) + url = Column(String, nullable=False) + shortcode = Column(String, index=True, unique=True, nullable=False) diff --git a/main.py b/main.py index 095f618..d644a9e 100644 --- a/main.py +++ b/main.py @@ -1,27 +1,57 @@ -from fastapi import FastAPI -from .models import UrlPayload -from .utils import generate_shortcode +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() -# temporary data store -url_mapping = {} +@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 get_original_url(shortcode: str): - if shortcode in url_mapping: - return {"original_url": url_mapping[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_short_url(url_payload: UrlPayload): - original_url = url_payload.url +@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() - if shortcode in url_mapping: - raise HTTPException(status_code=409, detail="Shortcode already in use") + try: + query = UrlMapping.__table__.insert().values({ + "url": original_url, + "shortcode": shortcode + }) + await database.execute(query) - url_mapping[shortcode] = str(original_url) - return {"shortcode": shortcode, "original_url": original_url} \ No newline at end of file + 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/requirements.txt b/requirements.txt index c3ae263..50786b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,20 @@ +alembic==1.16.4 annotated-types==0.7.0 anyio==4.9.0 +asyncpg==0.30.0 click==8.2.1 +databases==0.9.0 fastapi==0.116.1 +greenlet==3.2.3 h11==0.16.0 idna==3.10 +Mako==1.3.10 +MarkupSafe==3.0.2 +psycopg2-binary==2.9.10 pydantic==2.11.7 pydantic_core==2.33.2 sniffio==1.3.1 +SQLAlchemy==2.0.41 starlette==0.47.2 typing-inspection==0.4.1 typing_extensions==4.14.1 diff --git a/models.py b/schemas.py similarity index 100% rename from models.py rename to schemas.py