mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-22 00:26:36 +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
|
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
|
||||||
|
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
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"
|
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
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