diff --git a/.gitignore b/.gitignore index 4504ea0..fe0b367 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,5 @@ example.db # cache **/*.ruff_cache +**/engineering_notes.md +**/*.db diff --git a/Makefile b/Makefile index 353ef11..f10f03f 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,13 @@ .PHONY: help install test lint fmt format typecheck precommit precommit-all help: - @echo "Targets: install, test, lint, fmt, typecheck, ci, activate" + @echo "Targets: help install test lint fmt format typecheck precommit precommit-all" activate: @echo 'To activate virtual env, run the following in your shell:' - @echo 'eval "$$(make -s activate_venv)"' + @echo 'eval "$$(make -s env)"' -activate_venv: +venv: poetry env activate install: @@ -29,14 +29,16 @@ typecheck: poetry run pre-commit run -v mypy precommit: lint format typecheck test - @echo "precommit checks passed" + @echo "✅ ruff-lint checks passed" + @echo "✅ ruff-format checks passed" + @echo "✅ mypy-typecheck checks passed" + @echo "✅ pytest-test checks passed" + @echo "✅ precommit(--stage-files-only) checks passed" -precommit-all: lint format typecheck test +precommit-all: poetry run pre-commit run --all-files - -# Docker -up: - docker compose up - -down: - docker compose down + @echo "✅ ruff-lint checks passed" + @echo "✅ ruff-format checks passed" + @echo "✅ mypy-typecheck checks passed" + @echo "✅ pytest-test checks passed" + @echo "✅ precommit(--stage-files-only) checks passed" diff --git a/compose.yaml b/docker-compose.yaml similarity index 100% rename from compose.yaml rename to docker-compose.yaml diff --git a/src/app/db/db.py b/src/app/db/db.py new file mode 100644 index 0000000..bc31760 --- /dev/null +++ b/src/app/db/db.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import os + +from sqlmodel import create_engine +from sqlmodel import SQLModel + + +def db_engine_factory(db_url: str, dev_mode=False): + try: + if db_url is None: + raise ValueError("A URL path is required.") + db_engine = create_engine(db_url, echo=dev_mode) + if db_engine is None: + raise ValueError( + "Failed to create DB ENGINE. Check the URL argument. Check sys/admin/dev logs for more details!" + ) + return db_engine + except ValueError as valErr: + print("url-path-error:", valErr) # TODO: Logger! + return None + + +if __name__ == "__main__": + DATABASE_URL = os.getenv("DATABASE_URL") + DEV_ENV: bool = os.getenv("DEV_ENV", "False") == "True" + if DATABASE_URL: + db_engine = db_engine_factory(db_url=DATABASE_URL, dev_mode=DEV_ENV) + if db_engine: + SQLModel.metadata.create_all( + db_engine + ) # will create a DB without any table diff --git a/src/app/db/models/models.py b/src/app/db/models/models.py new file mode 100644 index 0000000..2f888d1 --- /dev/null +++ b/src/app/db/models/models.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from sqlmodel import Field +from sqlmodel import SQLModel + + +class ShortiLink(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + shorti_key: str = Field(index=True) + shorti_url: str = Field(index=True) + brand: str | None = Field(default=None) diff --git a/src/app/main.py b/src/app/main.py index 2e30848..114dc01 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -1,53 +1,38 @@ from __future__ import annotations +import logging +import os +from collections.abc import Sequence + from app.alnumgen import alnum_generator +from app.db.db import db_engine_factory +from app.db.models.models import ShortiLink +from app.schemas.schemas import GetURLRequestModel +from app.schemas.schemas import GetUrlResponseModel +from app.schemas.schemas import NewUrlSubmissionModel from fastapi import APIRouter from fastapi import FastAPI from fastapi import HTTPException -from models.schemas import URLRequestModel -from models.schemas import UrlResponseModel -from pydantic import AnyUrl -# from app.constants import KEY_MAX +from sqlmodel import select +from sqlmodel import Session +from sqlmodel import SQLModel + +logger = logging.getLogger(__name__) + +DATABASE_URL = "sqlite:///src/app/db/SHORTIES_DATABASE.db" +DEV_ENV: bool = os.getenv("DEV_ENV", "False") == "True" +db_engine = db_engine_factory(db_url=DATABASE_URL, dev_mode=DEV_ENV) +if db_engine: + SQLModel.metadata.create_all(db_engine) app = FastAPI(title="Shorties App") api_router = APIRouter() -api_version = "/v1" -shorti_links: dict = { - "scap": {"brand": "scap", "url": "https://www.scapital.com"}, - "goog": {"brand": "goog", "url": "https://www.google.com"}, - "meta": {"brand": "meta", "url": "https://www.facebook.com"}, - "twit": {"brand": "twit", "url": "https://www.twitter.com"}, - "link": {"brand": "link", "url": "https://www.linkedin.com"}, - "gith": {"brand": "gith", "url": "https://www.github.com"}, - "redd": {"brand": "redd", "url": "https://www.reddit.com"}, - "yout": {"brand": "yout", "url": "https://www.youtube.com"}, - "inst": {"brand": "inst", "url": "https://www.instagram.com"}, - "micr": {"brand": "micr", "url": "https://www.microsoft.com"}, - "appl": {"brand": "appl", "url": "https://www.apple.com"}, - "amaz": {"brand": "amaz", "url": "https://www.amazon.com"}, - "netf": {"brand": "netf", "url": "https://www.netflix.com"}, - "spof": {"brand": "spof", "url": "https://www.spotify.com"}, - "slac": {"brand": "slac", "url": "https://www.slack.com"}, - "drop": {"brand": "drop", "url": "https://www.dropbox.com"}, - "adob": {"brand": "adob", "url": "https://www.adobe.com"}, - "uber": {"brand": "uber", "url": "https://www.uber.com"}, - "airb": {"brand": "airb", "url": "https://www.airbnb.com"}, - "tesl": {"brand": "tesl", "url": "https://www.tesla.com"}, - "nyti": {"brand": "nyti", "url": "https://www.nytimes.com"}, - "wash": {"brand": "wash", "url": "https://www.washingtonpost.com"}, - "bbc": {"brand": "bbc", "url": "https://www.bbc.com"}, - "cnn": {"brand": "cnn", "url": "https://www.cnn.com"}, - "espn": {"brand": "espn", "url": "https://www.espn.com"}, - "pint": {"brand": "pint", "url": "https://www.pinterest.com"}, - "tumblr": {"brand": "tumblr", "url": "https://www.tumblr.com"}, - "quor": {"brand": "quor", "url": "https://www.quora.com"}, - "yaho": {"brand": "yaho", "url": "https://www.yahoo.com"}, - "ebay": {"brand": "ebay", "url": "https://www.ebay.com"}, - "payp": {"brand": "payp", "url": "https://www.paypal.com"}, - "tikt": {"brand": "tikt", "url": "https://www.tiktok.com"}, -} -click_event: dict = {} +api_version = os.getenv("API_VERSION") + + +if not api_version: + api_version = "/v1" @api_router.get(f"{api_version}/healthz/") @@ -56,42 +41,56 @@ def healthz() -> dict: @api_router.get(f"{api_version}/display/") -def display_all(max_results: int | None = None) -> dict: - store = shorti_links - if max_results is None or max_results <= 0 or max_results >= len(store): - return store - else: - results = {} - for i, (k, v) in enumerate(store.items()): - if i >= max_results: - break - else: - results[k] = v - return results - - -@api_router.get(f"{api_version}/redirect/") -def get_url(key: str) -> dict: - store = shorti_links +def display_all() -> Sequence: + try: + if not db_engine: + raise ValueError("Error with DB Engine!") + with Session(db_engine) as session: + select_statement = select(ShortiLink) + return session.exec(statement=select_statement).all() + except Exception as err: + print("Error:", err) + return [] + + +@api_router.get(f"{api_version}/redirect/", status_code=301) +def get_url(shorti_key: str | dict | GetURLRequestModel) -> GetUrlResponseModel | dict: try: - if not key: + print(shorti_key) + if not shorti_key: raise ValueError("Invalid, user did not provide a key.") - if not isinstance(key, str): - raise TypeError( - "The key provided is not of a valid type. Key must be a str.", - ) - if key in store: - return { - "key": f"{key}", - "url": store.get(key), - "status": "success", - "message": f'success: url matching "{key}" was found!', - } - else: - raise HTTPException( - status_code=404, - detail="failure: this key does not match our records. Verify the key and try again.", + + if ( + not isinstance(shorti_key, str) + and not isinstance(shorti_key, GetURLRequestModel) + and not isinstance(shorti_key, dict) + ): + raise TypeError("The key provided is not of a valid type.") + + with Session(db_engine) as current_session: + if isinstance(shorti_key, GetURLRequestModel): + shorti_key = shorti_key.key + elif isinstance(shorti_key, dict): + shorti_key = shorti_key["shorti_key"] + + select_statement = select(ShortiLink).where( + ShortiLink.shorti_key == shorti_key ) + result = current_session.exec(statement=select_statement).all() + if result: + new_shorti = result[0] + return GetUrlResponseModel( + key=new_shorti.shorti_key, + brand=new_shorti.brand, + url=new_shorti.shorti_url, + message=f'success: url matching "{shorti_key}" was found!', + status="Success!", + ) + else: + raise HTTPException( + status_code=404, + detail=f"message: Request-Failed: 'This key {shorti_key} does not match our records. Verify the key and try again.", + ) except HTTPException as err: # todo: log error, send failure message, suggestions for retrying. @@ -101,7 +100,7 @@ def get_url(key: str) -> dict: # todo: log error, send failure message, suggestions for retrying. print(f"invalid-data-error: {err}") return { - "key": f"{key}", + "key": f"{shorti_key}", "url": None, "status": "failure", "message": f"value-not-found-error: {err}", @@ -110,7 +109,7 @@ def get_url(key: str) -> dict: # todo: log error, send failure message, suggestions for retrying. print(f"invalid-data-error: {err}") return { - "key": f"{key}", + "key": f"{shorti_key}", "url": None, "status": "failure", "message": f"type-validity-error: {err}", @@ -118,42 +117,60 @@ def get_url(key: str) -> dict: @api_router.post(f"{api_version}/create/") -def create_url(url_item: URLRequestModel) -> UrlResponseModel: - store = shorti_links - key = alnum_generator() - print(url_item) - try: - while key in store: - key = alnum_generator() - - new_brand = url_item.brand - new_url = url_item.url - store[key] = {"brand": new_brand, "url": new_url} - new_url_item = UrlResponseModel( - key=key, - brand=store[key]["brand"], - url=AnyUrl(store[key]["url"]), - status="success", - message="A key was successfully generated", +def create_url(url_item: NewUrlSubmissionModel) -> Sequence[GetUrlResponseModel]: + if url_item is None or not len(url_item.url): + raise HTTPException( + status_code=404, + detail="Invalid submission, either url or both url and brand are missing!", ) - if key in store: - return new_url_item - - # failed = UrlResponseModel( - # key=key, - # brand="", - # url="", - # status="failure", - # message="We could not create a new key at this moment. Please try again later!", - # ) - # return failed + + if url_item and len(url_item.url) <= 3: raise HTTPException( status_code=404, - detail="status='failure', message='We could not create a new key at this moment!'", + detail="Invalid submission, missing url. We cannot create a new shorti without a valid url input.", ) - except HTTPException as err: - print(err) # todo: logg - raise err + + key: str = alnum_generator() + new_shorties: list[GetUrlResponseModel] = [] + try: + with Session(db_engine) as session: + select_statement = select(ShortiLink).where(ShortiLink.shorti_key == key) + result = session.exec(statement=select_statement).all() + while result: + key = alnum_generator() + select_statement = select(ShortiLink).where( + ShortiLink.shorti_key == key + ) + result = session.exec(statement=select_statement).all() + + new_shorti = ShortiLink( + shorti_key=key, shorti_url=url_item.url, brand=url_item.brand + ) + session.add(new_shorti) + session.commit() + + select_statement = select(ShortiLink).where(ShortiLink.shorti_key == key) + shorties: Sequence = session.exec(statement=select_statement).all() + if shorties: + new_shorties = [ + GetUrlResponseModel( + key=shorti.shorti_key, + brand=shorti.brand, + url=shorti.shorti_url, + status="success", + message="A key was successfully generated. We stored your url! You can start using your new short url immediately.", + ) + for shorti in shorties + ] + else: + raise HTTPException( + status_code=404, + detail="status='failure', message='We could not create a new key at this moment!'", + ) + return new_shorties + except HTTPException as httpErr: + logger.exception("HTTPException while creating shorti: %s", httpErr) + return new_shorties app.include_router(api_router) diff --git a/src/models/schemas.py b/src/app/schemas/schemas.py similarity index 80% rename from src/models/schemas.py rename to src/app/schemas/schemas.py index 4176b1d..cc4a968 100644 --- a/src/models/schemas.py +++ b/src/app/schemas/schemas.py @@ -6,7 +6,7 @@ from pydantic import BaseModel -class URLRequestModel(BaseModel): +class NewUrlSubmissionModel(BaseModel): brand: str | None = None url: AnyUrl | str expires_on: str | None = None @@ -17,12 +17,12 @@ class URLRequestModel(BaseModel): def __str__(self): return f"brand: {self.brand}, url: {self.url}" - # @classmethod - # def __dict__(cls): - # return {"brand": cls.brand, "url": cls.url} +class GetURLRequestModel(BaseModel): + key: str -class UrlResponseModel(BaseModel): + +class GetUrlResponseModel(BaseModel): key: str | None = None brand: str | None = None url: AnyUrl | str diff --git a/src/models/__init__.py b/src/models/__init__.py deleted file mode 100644 index e69de29..0000000