Implement short link redirect service (#180)

* Implement short link redirect service

* Silence linter where imports needs to be below setup

* Improve unknown link error

* Provide session to worker to convert it to user id

* Remove main entrypoint in favor of uvicorn command

* Add base path for relative URLs

* Add docs on launching redirect service to README.md

* Fix wrong task argument name

* Lint service code
This commit is contained in:
Damir Modyarov 2023-08-08 13:57:32 +03:00 committed by GitHub
parent 223b5aba2d
commit 21c02df7f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 896 additions and 270 deletions

View File

@ -1,4 +1,4 @@
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/akarpov DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/akarpov
CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_BROKER_URL=redis://localhost:6379/0
REDIS_URL=redis://localhost:6379/1 REDIS_URL=redis://localhost:6379/1
REDIS_CACHE=rediscache://localhost:6379/1 REDIS_CACHE=rediscache://localhost:6379/1

View File

@ -16,6 +16,7 @@ $ python3 manage.py migrate
```shell ```shell
$ python3 manage.py runserver $ python3 manage.py runserver
$ celery -A config.celery_app worker --loglevel=info $ celery -A config.celery_app worker --loglevel=info
$ uvicorn redirect.app:app --reload
``` ```

View File

@ -14,7 +14,7 @@ if [ -z "${POSTGRES_USER}" ]; then
base_postgres_image_default_user='postgres' base_postgres_image_default_user='postgres'
export POSTGRES_USER="${base_postgres_image_default_user}" export POSTGRES_USER="${base_postgres_image_default_user}"
fi fi
export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
python << END python << END
import sys import sys

962
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -94,6 +94,9 @@ django-location-field = "^2.7.0"
pydantic = "^2.0.2" pydantic = "^2.0.2"
channels-redis = "^4.1.0" channels-redis = "^4.1.0"
django-ipware = "^5.0.0" django-ipware = "^5.0.0"
fastapi = {extras = ["all"], version = "^0.101.0"}
sqlalchemy = "^2.0.19"
pydantic-settings = "^2.0.2"
[build-system] [build-system]

1
redirect/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Service to redirect users from short links."""

77
redirect/app.py Normal file
View File

@ -0,0 +1,77 @@
import os
from typing import Annotated
import django
from fastapi import Cookie, Depends, FastAPI, Header
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from starlette.requests import Request
from redirect.db.curd import LinkNotFoundException, get_link_by_slug
from redirect.db.dependency import get_db
from redirect.settings import settings
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
django.setup()
app = FastAPI()
from akarpov.tools.shortener.tasks import ( # noqa: This has to be imported strictly AFTER django setup
save_view_meta,
)
@app.exception_handler(LinkNotFoundException)
async def unicorn_exception_handler(request: Request, exc: LinkNotFoundException):
return HTMLResponse(
status_code=404,
# language=HTML
content="""
<html lang="en">
<head>
<title>Unknown Link</title>
<style>
h1 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", ui-system, sans-serif;
font-weight: bold;
font-size: medium;
text-align: center;
}
</style>
</head>
<body>
<h1>Such link doesn't exist or has been revoked</h1>
</body>
</html>
""",
)
@app.get("/{slug}")
def redirect(
slug: str,
request: Request,
db: Session = Depends(get_db),
user_agent: Annotated[str | None, Header()] = None,
sessionid: Annotated[str | None, Cookie()] = None,
) -> RedirectResponse:
"""Main route that redirects to a page based on the slug."""
if "+" in slug:
return RedirectResponse(url=f'/tools/shortener/p/{slug.replace("+", "")}')
link_id, link_target = get_link_by_slug(db, slug)
save_view_meta.apply_async(
kwargs={
"pk": link_id,
"ip": request.client.host,
"user_agent": user_agent,
"user_id": sessionid,
},
)
return RedirectResponse(
url=(settings.relative_base + link_target)
if link_target.startswith("/")
else link_target
)

12
redirect/db/__init__.py Normal file
View File

@ -0,0 +1,12 @@
from sqlalchemy import create_engine, make_url
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from redirect.settings import settings
SQLALCHEMY_DATABASE_URL = make_url(settings.database_url)
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

41
redirect/db/curd.py Normal file
View File

@ -0,0 +1,41 @@
from sqlalchemy.orm import Session
from redirect.db import models
from redirect.util import slug_to_link_id
class LinkNotFoundException(Exception):
def __init__(self, slug: str):
self.slug = slug
def get_link(db: Session, link_id: int):
"""Gets short link metdata by its id.
:param db Open connection to a database
:param link_id id of the link
:return link metadata"""
return db.query(models.Link).filter(models.Link.id == link_id).first()
_get_link_by_slug_cache = {}
def get_link_by_slug(db: Session, slug: str) -> tuple[int, str]:
"""Converts slug to id and gets related link target.
:param db Open connection to a database
:param slug of the link
:raise LinkNotFoundException if link is not found
:return target link id & url"""
if slug in _get_link_by_slug_cache:
return _get_link_by_slug_cache[slug]
link = get_link(db, slug_to_link_id(slug))
if link is None or not link.enabled:
raise LinkNotFoundException(slug)
_get_link_by_slug_cache[slug] = (link.id, link.source)
return link.id, link.source

10
redirect/db/dependency.py Normal file
View File

@ -0,0 +1,10 @@
from redirect.db import SessionLocal
def get_db():
"""Route dependency that provides a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()

18
redirect/db/models.py Normal file
View File

@ -0,0 +1,18 @@
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from redirect.db import Base
class Link(Base):
"""Model of a short link that defines slug and target of a redirect."""
__tablename__ = "short_link"
id = Column(Integer, primary_key=True, index=True)
created = Column(DateTime)
modified = Column(DateTime)
source = Column(String)
slug = Column(String, index=True)
enabled = Column(Boolean)
viewed = Column(Integer)
creator_id = Column(Integer, index=True)

19
redirect/settings.py Normal file
View File

@ -0,0 +1,19 @@
from pydantic_settings import BaseSettings
class AppSettings(BaseSettings):
"""General configuration of the app."""
redirect_slug_cutoff: int = 3
relative_base: str = "http://127.0.0.1:8000"
database_url: str = "postgresql://postgres:postgres@127.0.0.1:5432/akarpov"
class Config:
env_prefix = ""
env_file = ".env"
env_file_encoding = "utf-8"
extra = "allow"
settings = AppSettings()

18
redirect/util.py Normal file
View File

@ -0,0 +1,18 @@
import string
from redirect.settings import settings
URL_CHARACTERS = list(string.ascii_letters + string.digits + ";,:@&-_.!~*'()#")
SLUG_CUTOFF = settings.redirect_slug_cutoff
def slug_to_link_id(slug: str) -> int:
"""Converts given slug to an id of a link."""
link_id = 0
try:
for i, ch in enumerate(slug[:SLUG_CUTOFF - 1:-1]): # fmt: skip
value = URL_CHARACTERS.index(ch)
link_id += value * len(URL_CHARACTERS) ** i
except ValueError:
pass
return link_id