mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-24 19:23:44 +03:00
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:
parent
223b5aba2d
commit
21c02df7f0
|
@ -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
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
REDIS_CACHE=rediscache://localhost:6379/1
|
||||
|
|
|
@ -16,6 +16,7 @@ $ python3 manage.py migrate
|
|||
```shell
|
||||
$ python3 manage.py runserver
|
||||
$ celery -A config.celery_app worker --loglevel=info
|
||||
$ uvicorn redirect.app:app --reload
|
||||
```
|
||||
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ if [ -z "${POSTGRES_USER}" ]; then
|
|||
base_postgres_image_default_user='postgres'
|
||||
export POSTGRES_USER="${base_postgres_image_default_user}"
|
||||
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
|
||||
import sys
|
||||
|
|
962
poetry.lock
generated
962
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -94,6 +94,9 @@ django-location-field = "^2.7.0"
|
|||
pydantic = "^2.0.2"
|
||||
channels-redis = "^4.1.0"
|
||||
django-ipware = "^5.0.0"
|
||||
fastapi = {extras = ["all"], version = "^0.101.0"}
|
||||
sqlalchemy = "^2.0.19"
|
||||
pydantic-settings = "^2.0.2"
|
||||
|
||||
|
||||
[build-system]
|
||||
|
|
1
redirect/__init__.py
Normal file
1
redirect/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Service to redirect users from short links."""
|
77
redirect/app.py
Normal file
77
redirect/app.py
Normal 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
12
redirect/db/__init__.py
Normal 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
41
redirect/db/curd.py
Normal 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
10
redirect/db/dependency.py
Normal 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
18
redirect/db/models.py
Normal 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
19
redirect/settings.py
Normal 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
18
redirect/util.py
Normal 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
|
Loading…
Reference in New Issue
Block a user