Пишем сервис для сокращения ссылок на Django, DRF

Привет, меня зовут Денис. Это моя первая статья на habr. Не знаю, что из этого получится, но думаю, если все будет совсем плохо, модераторы позаботятся о том, чтобы ее никто не увидел.

Итак, на днях я получил тестовое задание от потенциального работодателя и решил убить двух зайцев сразу: выполнить тестовое задание и написать статью, в которой подробно описать весь процесс.

Задание

Итак, что мы имеем:

Ваша задачa — Реализовать сервис для сокращения ссылок

Требования к сервису:

— Страница с полем для ввода ссылки и кнопкой или API для генерации
— Страница со статистикой переходов по коротким ссылкам
— Переход по сокращенной ссылке перенаправляет на оригинальный URL
— Токен короткой ссылки должен быть длиной 6 символов и состоять из букв (разного регистра) и цифр
— Реализация на Django или Django Rest Framework
— Ссылка на гит-репозиторий с кодом сервиса

То есть грубо говоря нам необходимо создать сервис, который будет принимать url, например «https://habr.com/ru/company/southbridge/blog/714358/», генерировать и возвращать пользователю уникальную короткую ссылку (из 6 цифр и букв разного регистра), например «tR5Gq0». И при обращении по короткому url (в нашем случае https://my_domain.ru/tR5Gq0) браузер будет перенаправлять нас на https://habr.com/ru/company/southbridge/blog/714358/.

Я выбрал вариант с написание API, так как он мне значительно ближе.

Для начала опишу предполагаемую механику работы приложения. Условно, весь функционал можно разделить на две части:

  1. Генерация короткой ссылки:

    1. Получить post запрос с url адресом, который необходимо сократить («full_url»).

    2. Сгенерировать уникальную короткую ссылка и создать запись в базе данных.

    3. Вернуть короткую ссылку (со всеми необходимыми данными) пользователю. 

  2. Редирект по короткой ссылке на необходимый url.

Теперь давайте определимся с терминологией. Я не стал заморачиваться и для себя решил, что пара (‘full_url’ → ‘short_url’) будет называться токеном. 

Погнали!

Предварительная подготовка

Я начинаю любой проект с создания репозитория в Git

Создаем репозиторий, клонируем его на локальный компьютер. 

git clone https://github.com//

Открываем рабочую папку.

Создаем и активируем виртуальное окружение:

python -m venv venv
source venv/bin/activate

Устанавливаем Django в виртуальное окружение (я поставил версию 3.2, хотя в нашем случае подошла бы наверное почти любая)

pip install Django==3.2

Рекомендую после установки каждого пакета обновлять файл requirements.txt:

pip freeze > requirements.txt

Создаем новый Django-проект:

django-admin startproject link_shortner

Для проверки можем попробовать запустить проект на локальном сервере, выполнив из рабочей папки команду:

python manage.py runserver

На экране появится что-то типа:

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 17 unapplied migration (s). Your project may not work properly until you apply the migrations for app (s): admin, auth, contenttypes, sessions.

Run 'python manage.py migrate' to apply them.
March 05, 2021 — 04:27:33
Django version 2.2.19, using settings 'link_shortner.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C. 

Если открыть в брайзере http://127.0.0.1:8000 то увидите примерно такую картинку:

d27a829652383e5d368aaec3fb82f539.png

Так и должно быть, мы убедились что все ок, и идем дальше.

Внутри проекта создаем приложение API:

python3 manage.py startapp api

Регистрируем приложение в файле настроек settings.py:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api'  # дописываем в список наше приложение
]

Готово! Все приготовления выполнены! Теперь можно непосредственно переходить к созданию приложения. 

Описание ORM модели

Создадим ORM модель данных для хранения наших токенов.
Открываем файл link_shorter/api/models.py и заполняем его следующим образом:

import random  # импортируем модуль random для генерации короткой ссылки

from django.conf import settings  # подтягиваем значение констант из фала настроек
from django.db import models

class Tokens(models.Model):
    """Модель для хранения токенов"""
    full_url = models.URLField(unique=True)
    short_url = models.CharField(
        max_length=20,
        unique=True,
        db_index=True,
        blank=True
    )
    requests_count = models.IntegerField(default=0)
    created_date = models.DateTimeField(auto_now_add=True)
    is_active = models.BooleanField(default=True)

    class Meta:
        ordering = ('-created_date',)  # сортировка по дате создания токена

    def save(self, *args, **kwargs):
        """
        При создании токена достаточно только полной ссылки,
        короткая ссылка генерируется автоматически.
        Перед сохранением объекта токен проверяется на уникальность
        """
        if not self.short_url:
            while True:  # цикл будет повторять до тех пор пока не сгенерирует уникальную ссылку
                self.short_url = ''.join(
                    random.choices(
                        settings.CHARACTERS,  # алфавит для генерации короткой ссылки мы будем хранить в файле настроек
                        k=settings.TOKEN_LENGTH  # длину короткой ссылки тоже
                    )
                )
                if not Tokens.objects.filter(  # проверка на уникальность
                    short_url=self.short_url
                ).exists():
                    break
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        return f'{self.short_url} -> {self.full_url}'

Разберем детально некоторые строки:

full_url

full_url — полная ссылка. Указываем URLField чтобы при отправки чего-то иного запрос не проходил валидацию.

short_url

short_url — короткая ссылка. CharField потому что в нашем случае это не весь короткий url, а лишь та его часть которая будет идти после доменного имени сервиса. Разберем подробнее параметры поля:

max_length=20, # максимальная длина 20 символов. Таких ограничений в ТЗ не было, но я подумал что вряд ли нам понадобиться длина больше 20 символов. Исходя из того, что даже в наших условиях (6 цифр или букв разного регистра) мы можем хранить 56 800 235 584 url-ов в нашей базе.

unique=True, # тут все понятно, короткий url должен быть уникальным

db_index=True, # этот параметр позволяет реализовать ускоренный поиск по уникальному полю в данном случае полю short_url

blank=True # поле short_url может быть пустым, так как мы уже говорили о том что предполагается, что наш сервис будет сам генерить короткие ссылки.

requests_count

requests_count — счетчик обращений к конкретной короткой ссылке

created_date

created_date — автозаполняемая дата создания токена. Необходимости в ней тоже не было, но мы не знаем как дальше решим масштабировать наш проект и в случае если мы решим ограничить жизненный цикл ссылки каким-то определенным временем, нам будет необходимо знать дату создания токена.

is_active

is_active — является ли токен активным или нет. Принимает значения True или False.

Далее рассмотрим метод save.

def save(self, *args, **kwargs):
        """
        При создании токена достаточно только полной ссылки,
        короткая ссылка генерируется автоматически.
        Перед сохранением объекта токен проверяется на уникальность
        """
        if not self.short_url:
            while True:  # цикл будет повторять до тех пор пока не сгенерирует уникальную ссылку
                self.short_url = ''.join(
                    random.choices(
                        settings.CHARACTERS,  # алфавит для генерации короткой ссылки мы будем хранить в файле настроек
                        k=settings.TOKEN_LENGTH  # длину короткой ссылки тоже
                    )
                )
                if not Tokens.objects.filter(  # проверка на уникальность
                    short_url=self.short_url
                ).exists():
                    break
        super().save(*args, **kwargs)

Он обеспечивает генерацию короткой ссылки, в случае если пользователь самостоятельно не указал ее в теле запроса. 

С моделью вроде закончили. Осталось добавить константы в файл settings.py. Открываем файл settings.py и дописываем где-нибудь внизу:

# Параметры Токена(короткой ссылки)
CHARACTERS = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz234567890'
TOKEN_LENGTH = 6

Сохранили. Готово.

Настройки админки

Настроим панель администратора. По умолчанию Django уже настроил обработку урла admin/, нам остается только указать как мы хотим чтобы отображалась наша админка. Для этого открываем файл admin.py в папке api и вписываем туда следующий код:

from django.contrib import admin

from .models import Tokens  # импортируем модель Tokens


@admin.register(Tokens)
class TokenAdmin(admin.ModelAdmin):
    """Настройки админки для модели Токенов"""
    list_display = (  # указываем список отображаемых полей
        'full_url',
        'short_url',
        'requests_count',
        'created_date',
        'is_active'
    )
    search_fields = ('full_url', 'short_url')  # поля по которым можно искать нужный токен
    ordering = ('-created_date',)  # все токены отсортируем по дате создания так чтобы последние были сверху

Все пояснения я написал в комментариях к коду.

Осталось сделать миграции. Из папки с файлом manage.py выполняем команды:

python manage.py makemigrations

python manage.py migrate

Готово! БД создана, админка настроена. Для проверки можно создать суперпользователя, запустить приложение на локальном сервере и войти в админку:

Из папки с файлом manage.py выполняем команды:

python manage.py createsuperuser

Заполняем все необходимые данные (username, email, password)

Запускаем сервер:

python manage.py runserver

Открываем в браузере ссылку http://127.0.0.1:8000/admin/

Вуаля.

TTD (Test-Driven Development)

Теперь давайте перейдем к описанию непосредственной механики работы приложения, но как евангелист TTD (Test-Driven Development) предлагаю сначала описать в тестах, что же мы в итоге хотим получить от нашего приложения.

В папке API cоздаем файл tests.py со следующим содержанием:

from django.test import TestCase
from rest_framework.test import APITestCase

from .models import Tokens


class TestAPI(APITestCase):
    """Тестируем POST запросы"""
    url = '/api/tokens/'

    def setUp(self) -> None:
        Tokens.objects.create(
            full_url='https://ya.ru/',
            short_url='aEdj01',
        )

    def test_token_get(self):
        """Проверка получения уже существующего токена"""
        existing_data = {
            'full_url': 'https://ya.ru/'
        }
        response_get = self.client.post(self.url, data=existing_data)
        result_get = response_get.json()

        self.assertEqual(response_get.status_code, 200)
        self.assertEqual(Tokens.objects.all().count(), 1)
        self.assertEqual(result_get['full_url'], 'https://ya.ru/')
        self.assertEqual(result_get['short_url'], 'aEdj01')
        self.assertIsInstance(result_get, dict)
        self.assertEqual(len(result_get), 6)

    def test_token_create(self):
        """
        Проверка создания токена без явного указания короткой ссылки
        и с явным указанием короткой ссылки
        """
        creation_data = {
            'full_url': 'http://post.url.test.ru'
        }
        creation_full_data = {
            'full_url': 'http://test.ru',
            'short_url': 'Q2We34'
        }
        response_create = self.client.post(self.url, data=creation_data)
        result_create = response_create.json()

        response_create_with_short_url = self.client.post(
            self.url, data=creation_full_data
        )
        result_create_with_short_url = response_create_with_short_url.json()

        self.assertEqual(response_create.status_code, 201)
        self.assertEqual(result_create['full_url'], 'http://post.url.test.ru')
        self.assertEqual(len(result_create['short_url']), 6)
        self.assertIsInstance(result_create, dict)
        self.assertEqual(Tokens.objects.all().count(), 3)
        self.assertEqual(len(result_create), 6)
        self.assertEqual(
            result_create_with_short_url['full_url'],
            'http://test.ru',
            msg='Ошибка full_url'
        )
        self.assertEqual(
            result_create_with_short_url['short_url'],
            'Q2We34',
            msg='Ошибка short_url'
        )


class TestRedirection(TestCase):
    """Тестируем GET запросы"""
    active_url = '/aEdj01'
    deactive_url = '/q2Nb23'

    def setUp(self) -> None:
        Tokens.objects.create(
            full_url='https://ya.ru/',
            short_url='aEdj01',
        )

        Tokens.objects.create(
            full_url='https://stackoverflow.com/',
            short_url='q2Nb23',
            is_active=False
        )

    def test_redirection(self):
        """Тестируем переадресацию"""
        response = self.client.get(self.active_url)

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response.url, 'https://ya.ru/')

    def test_response_counter(self):
        """Тестируем счетчик запросов токена"""
        self.assertEqual(
            Tokens.objects.get(short_url='aEdj01').requests_count, 0
        )
        self.client.get(self.active_url)
        self.assertEqual(
            Tokens.objects.get(short_url='aEdj01').requests_count, 1
        )
        self.assertEqual(
            Tokens.objects.get(short_url='q2Nb23').requests_count, 0
        )
        self.client.get(self.deactive_url)
        self.assertEqual(
            Tokens.objects.get(short_url='q2Nb23').requests_count, 0
        )

    def test_deactive_url(self):
        """Тестируем неактивные токены"""
        response = self.client.get(self.deactive_url)
        self.assertEqual(response.content, b'Token is no longer available')

В комментариях я постарался описать то, что тот или иной тест проверяет, поэтому детально останавливаться на этом не будем, все таки эта статья не про тестирование.

Итак, тесты готовы, пора бы написать то, что они должны проверять.

Как мы решили в самом начале, наше приложение будет принимать POST запрос формата:

{
	"full_url”: "https://ya.ru”
}

или 

{
	"full_url”: "https://ya.ru”
	"short_url”: "Q54gP2”
}

А в ответ возвращать данные токена. Примерно такие:

{
    "id": 34,
    "full_url": "https://ya.ru/",
    "short_url": "PQbOuy",
    "requests_count": 0,
    "created_date": "2023-02-03T09:26:16.421135Z",
    "is_active": true
}

Сериализатор

Для того чтобы преобразовать данные из запроса в объекты Python, нам нужно написать сериализатор. Создаем в папке api файл serializers.py:

from rest_framework import serializers, status

from .models import Tokens


class TokenSerializer(serializers.ModelSerializer):
    """
    Сериализатор для обработки запросов на создание токенов:

    В сериализаторе убрана валидация full_url,
    но она осталась на уровне модели.
    Это сделано для того чтобы is_valid пропускал данные
    и можно было сериализовать их при уже существующем токене.
    """

    class Meta:
        model = Tokens
        fields = '__all__'
        extra_kwargs = {'full_url': {'validators': []}}

    def create(self, validated_data):
        """
        Переопределенный метод create:
        возвращает существующий токен
        или создает новый и возвращает его.
        """
        full_url = validated_data['full_url']
        token, created = Tokens.objects.get_or_create(full_url=full_url)
        if created:
            status_code = status.HTTP_201_CREATED
        else:
            status_code = status.HTTP_200_OK
        return token, status_code

В комментариях я попытался описать все максимально подробно (все таки код писался для потенциального работодателя). Могу лишь в общих чертах описать что сериализатор мы наследуем от ModelSerializer, то есть нам почти ничего не придется писать самим все будет работать из коробки, от нас лишь требуется указать модель и поля для сериализации. Про отключение валидации я написал в комментариях к стерилизатору, но если что готов ответить на вопросы в комментариях.

View-класс

Итак, с сериализацией вроде разобрались теперь давайте напишем непосредственно функцию обработчик полученных данных. Открывает файл views.py:  

from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView

from .serializers import TokenSerializer


class TokenAPIView(APIView):
    """Вьюха для работы с токенами"""

    def post(self, request):
        serializer = TokenSerializer(data=request.data)
        if serializer.is_valid(raise_exception=True):
            token, status_code = serializer.create(
                validated_data=serializer.validated_data
            )
            return Response(TokenSerializer(token).data, status=status_code)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Здесь мы описали View-класс наследованный от APIView. Так как по нашей логике эндпоинт api/tokens/ будет обрабатывать только POST запросы, опишем во view-классе метод post. Он принимает сериализованные данные и если они проходят валидацию, он вызывает описанный выше метод create (создает или достает токен из бд) и в ответе возвращает параметры созданного токена и http-статус ответа. Обработчик готов.

URLconf

Теперь нам надо описать маршруты в файлах urls.py.

В моем случае энпоинтом для создания токенов будет /api/tokens/ (вы разумеется можете придумать свою точку). То есть при правильном POST запросе на https://my_domain.ru/api/tokens/ будет создаваться токен с парой («full_url» → «short_url»).

Давайте это опишем в коде. Все запросы изначально обрабатываются файлом urls.py находящемся в папке /link_shorter/link_shorter (там где лежит файл settings.py), поэтому начнем с него:

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),     
    path('api/', include('api.urls')),  
]

# запрос https://my_domain.ru/admin/ переадресует нас в админку

# все запросы начинающиеся с https://my_domain.ru/api/… будут перенаправлены в файл urls.py приложения api. Который мы тоже сейчас отредактируем. 

Открываем или создаем файл urls.py в папке api:

from django.urls import path

from .views import TokenAPIView

app_name = 'api'


urlpatterns = [
    path('tokens/', TokenAPIView.as_view()),
]

Как я уже писал выше, все запросы начинающиеся с https://my_domain.ru/api/… будут перенаправлены сюда, следовательно эту часть пути нам указывать не нужно. Вызов метода as_view () возвращает нам функцию view (), которую Django будет вызывать при совпадении адреса с паттерном 'tokens/'.

Таким образов path('tokens/', TokenAPIView.as_view()) указывает на то, что запросы на https://my_domain.ru/api/tokens будет обрабатывать TokenAPIView который мы с вами уже написали.

Функционал создания токена готов, описать алгоритм переадресации, когда по уже созданной короткой ссылке пользователю необходимо перейти на реальный сайт.

То есть нам чтобы по запросу на короткий url (например: https://my_domain.ru/Th45Qw) наше приложение проверяла в базе наличие токена и в случае если все ок и такой токен есть, перенаправляла пользователя на соответствующий полный url.

Переадресация

Для этого давайте создадим отдельный файл для обработки переадресации и счетчика статистики переходов по ссылке. Назовем его service.py

from django.http import HttpResponse
from django.shortcuts import redirect

from .models import Tokens


def get_full_url(url: str) -> str:
    """
    Достаем полную ссылку по short_url
    Если ссылки нет в базе или она не активна
    возвращаем ошибку.
    Если все ок, то добавляем к счетчику статистики 1
    и возвращаем полную ссылку.
    """
    try:  # Пробуем достать токен, если его нет райзим ошибку, если он есть но не активен - райзим ошибку.
        token = Tokens.objects.get(short_url__exact=url)
        if not token.is_active:
            raise KeyError('Token is no longer available')
    except Tokens.DoesNotExist:
        raise KeyError('Try another url. No such urls in DB')
    token.requests_count += 1  # добавляем 1 к счетчику в случае удачного извлечения токена
    token.save() # сохраняем изменный экземпляр токена в БД 
    return token.full_url


def redirection(request, short_url):
    """Перенаправляем пользователя по ссылке"""
    try:
        full_link = get_full_url(short_url)  # получает полный адрес по короткой ссылке
        return redirect(full_link)  # перенаправляем пользователя по ссылке
    except Exception as e:
        return HttpResponse(e.args)  # если что-то не так, райзим ошибку

Готово. Осталось описать шаблон пути, по которому наше приложение будет принимать запросы на переадресацию. Для этого нам необходимо дописать пару строк в головной файл urls.py (который находится в папке вместе с файлами settings.py и wsgi.py):

from api import services  # новая строка
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
    path('', services.redirection),  # новая строка
]

Теперь все запросы пришедшие на https://my_domain.ru/??? будут обработаны функцией redirection, а она в свою очередь будет либо перекидывать пользователя на нужный сайт либо выдавать ошибку (если ссылки нет в базе или она по той или иной причине уже не активна).

Тестирование и запуск

Запускаем тесты: python manage.py test. Путь к тестовому файлу указывать не нужно, python сам найдет все файлы начинающиеся со слова test и запустит их.

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.034s
OK
Destroying test database for alias 'default'...

Тесты прошли успешно. Чтобы получить более детальную информацию можно выполнить команду python manage.py test -v2

Проверяем работу программы в POSTMAN.

Отправляем POST запрос на эндпоинт /api/tokens/ в теле которого указываем url который хотим сократить:

33134f780c7903958e28eb47c8c577b9.png

Как видим в ответ пришел короткий url и остальные параметры созданного токена.

Обратите внимание на код статуса ответа сервера, сейчас он 201 (created), но если мы повторно направим пост запрос с этим же урлом, то получим те же данные (так как токен уже создан), но статус код уже будет 200 (ok).

afb6d4e601a02faf7cfade75a9595f68.png

и сразу давайте отправим второй запрос с указанием короткой ссылки:

edca20ddef1bf362335883f1f127172f.png

Как видим, у нас получилось создать токен с явным указанием короткой ссылки. Проверяем работу переадресации. Вводим в адресной строке: https://my_domain.ru/R3u2N1 нажимаем ввод и отправляемся на сайт Бегового сообщества (https://runc.run).

На этом все, уверен, есть более элегантные способы написания подобного сервиса, поэтому с удовольствием получу ваши замечания и предложения в комментариях к посту. Повторюсь, это мой первый пост и первое реальное тестовое задание, поэтому я открыт к любой критике:) Спасибо.

Ссылка на GitHub-репозиторий с проектом

© Habrahabr.ru