mirror of
				https://github.com/Alexander-D-Karpov/akarpov
				synced 2025-11-04 03:27:24 +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