Створення сервісу для зберігання файлів з Flask, RethinkDB та Vue.js, ч. 1

01 вересня 2016 12:10 OlegWock 1890 7

Створення простого сервісу для зберігання файлів з використанням Flask, RethinkDB та Vue.js, частина перша

В цьому туторіалі я покажу вам як написати простий сервіс для збереження файлів. Ми будемо використовувати VueJS для фронтенду, Flask для бекенду та RethinkDB для збереження файлів.

В першій частині ми напишемо бекенд для нашого додатку. Пізніше, я розкажу про реалізацію деяких принципів розробки для Python-розробника, Flask-розробника або й розробника взагалі.

Проектування API

Почати розробку слід з проектування нашого API. Використовуючи наш сервіс, користувач повинен мати змогу:

  1. Створити аккаунт
  2. Ввійти в нього
  3. Створювати та керувати директоріями та суб-директоріями
  4. Завантажувати файли в директорію
  5. Отримати властивості файлу
  6. Редагувати та видаляти файли

Для API ми створимо такі методи:

  • POST /api/v1/auth/login — для авторизації користувачів
  • POST /api/v1/auth/register — для реєстрації користувачів
  • GET /api/v1/user/<user_id>/files/ — для отримання списку всіх файлів користувача з id user_id
  • POST /api/v1/user/<user_id>/files/ — для створення нового файлу користувача user_id
  • GET /api/v1/user/<user_id>/files/<file_id> — отримання файлу з id file_id
  • PUT /api/v1/user/<user_id>/files/<file_id> — для редагування файлу з id file_id
  • DELETE /api/v1/user/<user_id>/files/<file_id> — для видалення файлу з id file_id

Тепер, коли ми спроектували те, що хочемо написати, можемо приступити до самої розробки.

Початок розробки

Почати розробку слід з створення потрібної структури директорій. Я рекомендую таку:

— /api
    — /controllers
    — /utils
    — models.py
    — __init__.py
— /templates
— /static
    — /lib
    — /js
    — /css
    — /img
— index.html
— config.py
— run.py

Модулі та пакунки для API будуть зберігатися в директорії /api, де моделі зберігаються в models.py, а контролери (в основному для маршрутизації) будуть зберігатися як модулі в директорії /controllers.

Ми додамо функцію створення наших маршрутів та додатку в /api/__init__.py. Тоді ми зможемо використовувати функцію create_app() для створення декількох екземплярів додатку з різними конфігураціями. Це дуже зручно для написання тестів.

from flask import Flask, Blueprint
from flask_restful import Api

from config import config

def create_app(env):
    app = Flask(__name__)
    app.config.from_object(config[env])

    api_bp = Blueprint('api', __name__)
    api = Api(api_bp)

    # Код для додавання Flask RESTful ресурсів йде сюди

    app.register_blueprint(api_bp, url_prefix="/api/v1")

    return app

Як ви бачите, ми створили функцію create_app(), що приймає параметр env, що має одне з цих значень: development, production чи testing. В залежності від переданого значення ми будемо завантажувати різні конфігурації, що зберігаються в файлі config.py в вигляді ієрархії класів.

class Config(object):
    DEBUG = True
    TESTING = False
    DATABASE_NAME = "papers"

class DevelopmentConfig(Config):
    SECRET_KEY = "S0m3S3cr3tK3y"

config = {
    'development': DevelopmentConfig,
    'testing': DevelopmentConfig,
    'production': DevelopmentConfig
}

Наразі ми додали декілька параметрів: DEBUG, що вказує Flask працювати в режимі відлагодження чи ні, DATABASE_NAME, що вказує на назву БД, яку ми будемо використовувати в своїх моделях та SECRET_KEY, що використовується для генерації JWT Token. Зараз ми використовуємо для всіх середових один конфіг.

Як ви бачите, ми використовуємо Flask Blueprint для розмежування версій нашого API. Це робиться для того, щоб в випадку зміни в API, це не вплинуло на роботу вже готових додатків. Також ми створили api, об'єкт Flask-RESTful API. Пізніше я покажу як додати нові маршрути за допомогою цього об'єкту.

Наступним кроком нам потрібно написати код запуску серверу (файл run.py в корінній директорії). Ми будемо використовувати Flask-Script щоб додати додаткові CLI-команди для нашого додатку.

from flask_script import Manager

from api import create_app

app = create_app('development')

manager = Manager(app)

@manager.command
def migrate():
    # Migration script
    pass

if __name__ == '__main__':
    manager.run()

Тут ми використовуємо клас flask_script.Manager щоб абстрагуватися від конкретного серверу і зробити процес створення нових команд легшим. migrate() буде використовуватися для автоматичного створення потрібних таблиць. Поки що там стоїть заглушка, ми повернемося до неї потім.

Тепер ви можете перейти в консоль та запустити наш сервер командою python run.py runserver, він запуститься на порту 5000.

Модель користувача

Настав час створити наші моделі. Для нашого додатку нам знадобляться всього дві моделі. Але зараз ми створимо лише модель для користувачів.

Почнемо з з'єднання з RethinkDB.

import rethinkdb as r

from flask import current_app

conn = r.connect(db="papers")

class RethinkDBModel(object):
    pass

Ми використовуємо те саме ім'я БД, що й в конфігу. В flask є змінна current_app, що зберігає екземпляр поточного додатку.

Чому ми створили пустий клас RethinkDBModel? Може бути багато речей, які ви захочете зробити загальними для всіх моделей. Завдяки тому, що RethinkDBModel є батьківським класом для всіх моделей, це зробити буде дуже просто: слід лише додати потрібні дані чи методи до цього класу.

Наш клас User успадковує порожній базовий клас. В ньому ми оголосимо декілька функцій для взаємодії з БД з контролерів.

Ми почнемо з написання функції create(). Вона буде створювати новий документ в БД, що описує користувача.

class User(RethinkDBModel):
    _table = 'users'

    @classmethod
    def create(cls, **kwargs):
        fullname = kwargs.get('fullname')
        email = kwargs.get('email')
        password = kwargs.get('password')
        password_conf = kwargs.get('password_conf')
        if password != password_conf:
            raise ValidationError("Password and Confirm password need to be the same value")
        password = cls.hash_password(password)
        doc = {
            'fullname': fullname,
            'email': email,
            'password': password,
            'date_created': datetime.now(r.make_timezone('+01:00')),
            'date_modified': datetime.now(r.make_timezone('+01:00'))
        }
        r.table(cls._table).insert(doc).run(conn)

Тут ми використовуємо декоратор classmethod, що дає доступ до класу поточного об'єкту з тіла методу. Ми будемо використовувати клас для доступу до властивості _table, що зберігає ім'я таблиці для нашої моделі.

Також ми перевіряємо щоб параметри password та password_conf були однаковими, якщо ні, то викидаємо ValidationError. Наші виключення будуть зберігатися в модулі /api/utils/errors.py. А ValidationError виглядає так:

class ValidationError(Exception):
    pass

Ми використовуємо виключення з власними іменами тому що їх легше відслідковувати.

Слід зауважити, що ми використовуємо datetime.now(r.make_timezone('+01:00')) а не datetime.now(). RethinkDB потребує обов'язкового вказання часового поясу. Так як Python не вказує його автоматично, це робимо ми, явно вказуючи часовий пояс.

Якщо все йде по плану і ніяких виключень не викинуто, то ми викликаємо метод insert() на об'єкті таблиці, що повертається функцією r.table(table_name). Цей метод приймає словник, що зберігає в собі дані. Вони будуть збережені в таблиці як новий документ.

В нашому коді ми викликаємо метод hash_password(). Цей метод використовує модуль hash.pbkdf2_sha256 пакунку passlib для генерації хешу паролю. Також нам потрібно написати метод, що буде перевіряти пароль за хешем.

from passlib.hash import pbkdf2_sha256

class User(RethinkDBModel):
    _table = 'users'

    @classmethod
    def create(cls, **kwargs):
        fullname = kwargs.get('fullname')
        email = kwargs.get('email')
        password = kwargs.get('password')
        password_conf = kwargs.get('password_conf')
        if password != password_conf:
            raise ValidationError("Password and Confirm password need to be the same value")
        password = cls.hash_password(password)
        doc = {
            'fullname': fullname,
            'email': email,
            'password': password,
            'date_created': datetime.now(r.make_timezone('+01:00')),
            'date_modified': datetime.now(r.make_timezone('+01:00'))
        }
        r.table(cls._table).insert(doc).run(conn)

    @staticmethod
    def hash_password(password):
        return pbkdf2_sha256.encrypt(password, rounds=200000, salt_size=16)

    @staticmethod
    def verify_password(password, _hash):
        return pbkdf2_sha256.verify(password, _hash)

В метод pbkdf2_sha256.encrypt() передаються пароль та значення rounds та salt_size. Детальніше можна почитати тут. А щоб аргументувати, чому ми використовуємо саме PBKDF2:

З точки зору безпеки, зараз PBKDF2 - один з найнадійніших алгоритмів свого класу, що не має відомих проблем с безпекою. — документація passlib

Наступним кроком буде написання методу validate(). Цей метод буде викликатися при аутентифікації (логіні) користувача. Вона приймає параметри email та password, перевіряє чи існує користувач з таким email і зрівнює переданий пароль з хешем в таблиці.

Також ми будемо використовувати JWT (JSON Web Token) для token-based аутентифікації. Ми будемо генерувати токен якщо користувач передасть валідні дані. В кінці файл models.py буде мати такий вигляд:

import os
import rethinkdb as r
from jose import jwt
from datetime import datetime
from passlib.hash import pbkdf2_sha256

from flask import current_app

from api.utils.errors import ValidationError

conn = r.connect(db="papers")

class RethinkDBModel(object):
    pass


class User(RethinkDBModel):
    _table = 'users'

    @classmethod
    def create(cls, **kwargs):
        fullname = kwargs.get('fullname')
        email = kwargs.get('email')
        password = kwargs.get('password')
        password_conf = kwargs.get('password_conf')
        if password != password_conf:
            raise ValidationError("Password and Confirm password need to be the same value")
        password = cls.hash_password(password)
        doc = {
            'fullname': fullname,
            'email': email,
            'password': password,
            'date_created': datetime.now(r.make_timezone('+01:00')),
            'date_modified': datetime.now(r.make_timezone('+01:00'))
        }
        r.table(cls._table).insert(doc).run(conn)

    @classmethod
    def validate(cls, email, password):
        docs = list(r.table(cls._table).filter({'email': email}).run(conn))

        if not len(docs):
            raise ValidationError("Could not find the e-mail address you specified")

        _hash = docs[0]['password']

        if cls.verify_password(password, _hash):
            try:
                token = jwt.encode({'id': docs[0]['id']}, current_app.config['SECRET_KEY'], algorithm='HS256')
                return token
            except JWTError:
                raise ValidationError("There was a problem while trying to create a JWT token.")
        else:
            raise ValidationError("The password you inputted was incorrect.")

    @staticmethod
    def hash_password(password):
        return pbkdf2_sha256.encrypt(password, rounds=200000, salt_size=16)

    @staticmethod
    def verify_password(password, _hash):
        return pbkdf2_sha256.verify(password, _hash)

Слід додатково звернути увагу на метод validate(). Спочатку ми викликаємо метод filter() нашої таблиці. Він приймає словник, що містить критерії пошуку. Також вона може приймати предикат, що може бути функцією (лямбда чи звичайною), аналогічною до тієї, що використовується в стандартному пітоновому filter(). Метод повертає курсор, що дає доступ до знайдених документів. Курсор є ітератором, тому може використовуватися в циклі for .. in. Але в даному випадку ми, для більшої зручності, конвертуємо його в список.

В короткому підсумку, ми робимо тут дві дії. Перевіряємо чи є користувач з таким мейлом, для цього ми просто рахуємо кількість об'єктів в вибірці, якщо вона порожня — викидаємо виключення. Потім ми порівнюємо пароль з хешем, використовуючи метод verify_password(), якщо вони не збігаються — ми викидаємо виключення.

Також зауважте, що для генерації JWT ми використовуємо функцію jwt.encode(). Цей метод досить простий, документацію можна знайти тут.

З моделями ми закінчили, слід переходити до контроллерів. В цих моделях ми дотримувалися принципу великі моделі, маленькі контроллери. Тепер більшість логіки міститься в моделях, а контроллери фокусуються лише на маршрутизації та звітуванні про помилки.

Контроллер аутентифікації

Щоб створити контроллер для аутентифікації нам потрібно написати власний клас, вказавши наслідування класу Resource з Flask RESTful. Це чимось схоже на створення контроллерів в Django. Ваш клас повинен містити методи, що відповідають HTTP-запитам. Наприклад, якщо ми хочемо реалізувати GET-запит, потрібно створити метод get(). В кінці обов'язково слід зв'язати URL з ресурсом за допомогою api.add_resource().

Давайте створимо два класи, що будуть приймати POST-запити: для реєстрації та авторизації.

Спочатку напишемо каркас для наших класів. Вони будуть знаходитися в файлі /api/controllers/auth.py.

from flask_restful import Resource

class AuthLogin(Resource):
    def post(self):
        pass

class AuthRegister(Resource):
    def post(self):
        pass

Зв'яжемо їх з URL. Для цього помістимо наступний код в /api/__init__.py

from flask import Flask, Blueprint
from flask_restful import Api

from api.controllers import auth
from config import config

def create_app(env):
    app = Flask(__name__)
    app.config.from_object(config[env])

    api_bp = Blueprint('api', __name__)
    api = Api(api_bp)

    api.add_resource(auth.AuthLogin, '/auth/login')
    api.add_resource(auth.AuthRegister, '/auth/register')

    app.register_blueprint(api_bp, url_prefix="/api/v1")

    return app

Тепер напишемо логіку. Тут буде небагато коду, так як основну роботу за нас роблять моделі.

from flask_restful import reqparse, abort, Resource

from api.models import User
from api.utils.errors import ValidationError

class AuthLogin(Resource):
    def post(self):
        parser = reqparse.RequestParser()
        parser.add_argument('email', type=str, help='You need to enter your e-mail address', required=True)
        parser.add_argument('password', type=str, help='You need to enter your password', required=True)

        args = parser.parse_args()

        email = args.get('email')
        password = args.get('password')

        try:
            token = User.validate(email, password)
            return {'token': token}
        except ValidationError as e:
            abort(400, message='There was an error while trying to log you in -> {}'.format(e.message))

class AuthRegister(Resource):
    def post(self):
        parser = reqparse.RequestParser()
        parser.add_argument('fullname', type=str, help='You need to enter your full name', required=True)
        parser.add_argument('email', type=str, help='You need to enter your e-mail address', required=True)
        parser.add_argument('password', type=str, help='You need to enter your chosen password', required=True)
        parser.add_argument('password_conf', type=str, help='You need to enter the confirm password field', required=True)

        args = parser.parse_args()

        email = args.get('email')
        password = args.get('password')
        password_conf = args.get('password_conf')
        fullname = args.get('fullname')

        try:
            User.create(
                email=email,
                password=password,
                password_conf=password_conf,
                fullname=fullname
            )
            return {'message': 'Successfully created your account.'}
        except ValidationError as e:
            abort(400, message='There was an error while trying to create your account -> {}'.format(e.message))

Тут ми створили AuthLogin, реалізували метод post(), що приймає адресу e-mail та пароль, здійснюємо валідацію за допомогою reqparse та викликаємо User.validate(), що перевіряє дані та повертає токен. Якщо ж станеться помилка, ми відловимо її та відправимо клієнту повідомлення про помилку.

Те ж саме відбувається в AuthRegister, з тією різницею, що викликаємо метод User.create().

Наступним кроком буде написання моделей для наших файлів.

Моделі для файлів та директорій

В нашому додатку файли будуть зберігатися в файловій системі, де кожен користувач має свою директорію, але всі дані про них та структуру — в БД. Завдяки цьому ми будемо виконувати мінімум звернень до файлової системи (які є досить повільними — прим. пер.).

Модель файлу

Створимо каркас для моделей в файлі /api/models.py

class File(RethinkDBModel):
    _table = 'files'

class Folder(File):
    pass

Почнемо з створення методу create() для нашої моделі файлу. Він буди викликатися при POST-запиті до /users/<user_id>/files/<file_id>.

@classmethod
def create(cls, **kwargs):
    name = kwargs.get('name')
    size = kwargs.get('size')
    uri = kwargs.get('uri')
    parent = kwargs.get('parent')
    creator = kwargs.get('creator')

    # Direct parent ID
    parent_id = '0' if parent is None else parent['id']

    doc = {
        'name': name,
        'size': size,
        'uri': uri,
        'parent_id': parent_id,
        'creator': creator,
        'is_folder': False,
        'status': True,
        'date_created': datetime.now(r.make_timezone('+01:00')),
        'date_modified': datetime.now(r.make_timezone('+01:00'))
    }

    res = r.table(cls._table).insert(doc).run(conn)
    doc['id'] = res['generated_keys'][0]

    if parent is not None:
        Folder.add_object(parent, doc['id'])

    return doc

Тут ми спочатку отримуємо потрібні дані з словника, що містить передані аргументи. Ми приймаємо параметр parent, що містить id директорії, в якій зберігається файл. Якщо він не переданий (None), то ми використовуємо id=0, що відповідає корневій директорії.

Коли ми зібрали потрібні дані, ми створюємо словник з ними і додаємо його в базу даних, викликавши метод insert(). Цей метод повертає словник, що містить id доданих документів. Їх ми передаємо назад клієнту.

В останніх трьох стрічках ми перевіряємо чи вказаний parent і викликаємо метод, що додає файл до директорії.

Давайте на трохи відірвемося від файлів і папок і повернемося до класу RethinkDBModel, де напишемо декілька корисних методів.

class RethinkDBModel(object):
    @classmethod
    def find(cls, id):
        return r.table(cls._table).get(id).run(conn)

    @classmethod
    def filter(cls, predicate):
        return list(r.table(cls._table).filter(predicate).run(conn))

    @classmethod
    def update(cls, id, fields):
        status = r.table(cls._table).get(id).update(fields).run(conn)
        if status['errors']:
            raise DatabaseProcessError("Could not complete the update action")
        return True

    @classmethod
    def delete(cls, id):
        status = r.table(cls._table).get(id).delete().run(conn)
        if status['errors']:
            raise DatabaseProcessError("Could not complete the delete action")
        return True

Ми створили обгортки для методів RethinkDB get(), filter(), update() та delete(). Тепер ми можемо використовувати їх в наших моделях, замість того, щоб кожен раз писати їх знову.

Продовжимо написання функціоналу файлів. В нас ще немає методу для переміщення файлу між папками, давайте напишемо його.

@classmethod
def move(cls, obj, to):
    previous_folder_id = obj['parent_id']
    previous_folder = Folder.find(previous_folder_id)
    Folder.remove_object(previous_folder, obj['id'])
    Folder.add_object(to, obj['id'])

Логіка дуже проста. Ми просто видаляємо файл з старої директорії і додаємо до нової.

На цьому з методами для роботи з файлами ми закінчили, всі базові операції реалізовані. Час переходити до методів директорій.

Модель директорії

@classmethod
def create(cls, **kwargs):
    name = kwargs.get('name')
    parent = kwargs.get('parent')
    creator = kwargs.get('creator')

    # Direct parent ID
    parent_id = '0' if parent is None else parent['id']

    doc = {
        'name': name,
        'parent_id': parent_id,
        'creator': creator,
        'is_folder': True,
        'last_index': 0,
        'status': True,
        'objects': None,
        'date_created': datetime.now(r.make_timezone('+01:00')),
        'date_modified': datetime.now(r.make_timezone('+01:00'))
    }

    res = r.table(cls._table).insert(doc).run(conn)
    doc['id'] = res['generated_keys'][0]

    if parent is not None:
        cls.add_object(parent, doc['id'], True)

    cls.tag_folder(parent, doc['id'])

    return doc

@classmethod
def tag_folder(cls, parent, id):
    tag = id if parent is None else '{}#{}'.format(parent['tag'], parent['last_index'])
    cls.update(id, {'tag': tag})

Метод create() дуже схожий на той, що ми написали для файлу. Першою відмінністю є те, що нам потрібні лише імя та творець, щоб створити папку. Друга відмінність це поле is_folder, яке для файлів дорівнює Fasle, а для папок — True.

Також ви могли помітити, що ми викликаємо метод tag_folder(). Він знадобиться нам пізніше, коли нам знадобиться переміщати директорії. Папки позначені тегами відповідно до їх розташування в дереві файлів. Кожна папка, що зберігається в кореневій директорії матиме тег виду <id>. Папка, що зберігається рівнем нижче матиме тег <id>-n, де n це число, що збільшується. Згодом вкладені папки будуть слідувати тій же структурі і матимуть id виду <id>-n-m. З додаванням директорій n буде зростати. Зберігати необхідні для цього дані в полі last_index з значенням за умовчуванням 0. При додаванні папок до цієї директорії, ми будемо інкрементувати last_index. Метод tag_folder() потурбується про все це.

Тепер переоголосимо метод find(), щоб оптимізувати його для папок.

@classmethod
def find(cls, id, listing=False):
    file_ref = r.table(cls._table).get(id).run(conn)
    if file_ref is not None:
        if file_ref['is_folder'] and listing and file_ref['objects'] is not None:
            file_ref['objects'] = list(r.table(cls._table).get_all(r.args(file_ref['objects'])).run(conn))
    return file_ref

Спочатку ми отримуємо об'єкт за id. Також ми опціонально показуємо вміст директорії, якщо виконуються три умови:

  • listing встановлено значення True. Ми використовуємо цю змінну щоб знати, потрібно нам відправляти вміст папки чи ні.

  • об'єкт file_ref є директорією

  • В директорії є хоча б один об'єкт

Якщо виконуються всі три умови, ми отримуємо вкладені об'єкти за допомогою методу get_all. Цей метод приймає декілька ключів і повертає підхожі об'єкти. Ми використовуємо метод r.args що конвертує список об'єктів в множинні аргументи для RethinkDB. Також ми заміняємо поле objects документу на отриманий список. Цей список містить детальну інформацію про всі вкладені файли та папки.

Також нам потрібно реалізувати метод move() для папок. Він буде дуже схожий на той метод, що ми писали для файлів, але зі змінами для роботи з тегами і перевіркою чи можемо ми перемістити папку.

@classmethod
def move(cls, obj, to):
    if to is not None:
        parent_tag = to['tag']
        child_tag = obj['tag']

        parent_sections = parent_tag.split("#")
        child_sections = child_tag.split("#")

        if len(parent_sections) > len(child_sections):
            matches = re.match(child_tag, parent_tag)
            if matches is not None:
                raise Exception("You can't move this object to the specified folder")

    previous_folder_id = obj['parent_id']
    previous_folder = cls.find(previous_folder_id)
    cls.remove_object(previous_folder, obj['id'])

    if to is not None:
        cls.add_object(to, obj['id'], True)

Тут ми спочатку переконуємося, що папку для переміщення вказано і вона не дорівнює None. Також ми отримуємо її тег. Потім ми перевіряємо кількість секцій в ньому, так можна визначити рівень папки в файловому дереві. Є тільки один випадок, коли переміщення неможливе: коли батьківських секцій більше ніж дочірніх. (Батьківські секції в даному випадку це секції теки, куди ми намагаємось перемістити). Ми можемо переміщувати папку до іншої папки на її рівні та вище. Але якщо parent_sections більше child_sections, то можлива ситуація, коли папка-призначення буде вкладеною в папку призначену для переміщення.

Також слід створити методи add_object() та remove_object(), про які я згадував раніше.

@classmethod
def remove_object(cls, folder, object_id):
    update_fields = folder['objects'] or []
    while object_id in update_fields:
        update_fields.remove(object_id)
    cls.update(folder['id'], {'objects': update_fields})

@classmethod
def add_object(cls, folder, object_id, is_folder=False):
    p = {}
    update_fields = folder['objects'] or []
    update_fields.append(object_id)
    if is_folder:
        p['last_index'] = folder['last_index'] + 1
    p['objects'] = update_fields
    cls.update(folder['id'], p)

Як я згадував вище, ми реалізуємо додавання та видалення модифікуючи поле objects. Коли ми додаємо вкладену папку, ми інкрементуємо її last_index.

На цьому з моделями завершено, повертаємось до контроллерів.

Контроллер файлів

Цей контроллер буде використовуватися для роботи і з файлами, і з папками. Логіки тут буде більше, ніж в минулих контроллерах. Почнемо ми з створення шаблону в модулі /api/controllers/files.py.

import os

from flask import request, g
from flask_restful import reqparse, abort, Resource
from werkzeug import secure_filename

from api.models import File

BASE_DIR = os.path.abspath(
    os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
)


class CreateList(Resource):
    def get(self, user_id):
        pass

    def post(self, user_id):
        pass

class ViewEditDelete(Resource):
    def get(self, user_id, file_id):
        pass

    def put(self, user_id, file_id):
        pass

    def delete(self, user_id, file_id):
        pass

Як можна здогадатися з назви, CreateList буде використовуватися для створення та лістингу файлів для авторизованих користувачів, а ViewEditDelete для перегляду, редагування та видалення файлів. Оголошені методи співвідносяться з методами HTTP.

Почнемо ми з написання декількох корисних декораторів. Краще виділити їх в окремий файл /api/utils/decorators.py.

from jose import jwt
from jose.exceptions import JWTError
from functools import wraps

from flask import current_app, request, g
from flask_restful import abort

from api.models import User, File

def login_required(f):
    '''
    Цей декоратор перевіряє заголовки на вміст валідного токену
    '''
    @wraps(f)
    def func(*args, **kwargs):
        try:
            if 'authorization' not in request.headers:
                abort(404, message="You need to be logged in to access this resource")
            token = request.headers.get('authorization')
            payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
            user_id = payload['id']
            g.user = User.find(user_id)
            if g.user is None:
               abort(404, message="The user id is invalid")
            return f(*args, **kwargs)
        except JWTError as e:
            abort(400, message="There was a problem while trying to parse your token -> {}".format(e.message))
    return func

def validate_user(f):
    '''
    Цей декоратор перевіряє чи авторизований користувач і користувач, яким ми оперуємо один і той же
    '''
    @wraps(f)
    def func(*args, **kwargs):
        user_id = kwargs.get('user_id')
        if user_id != g.user['id']:
            abort(404, message="You do not have permission to the resource you are trying to access")
        return f(*args, **kwargs)
    return func

def belongs_to_user(f):
    '''
    Цей декоратор перевіряє чи файли належать користувачу
    '''
    @wraps(f)
    def func(*args, **kwargs):
        file_id = kwargs.get('file_id')
        user_id = kwargs.get('user_id')
        file = File.find(file_id, True)
        if not file or file['creator'] != user_id:
            abort(404, message="The file you are trying to access was not found")
        g.file = file
        return f(*args, **kwargs)
    return func
  • login_required перед викликом методів перевіряє чи користувач авторизований. Ми використовуємо його щоб прикрити певні маршрути. Він приймає токен і перевіряє його. Ми отримуємо id з токену та отримуємо об'єкт користувача. Також ми зберігаємо його в g.user для подальшого використання.

  • validate_user перевіряє чи немає інших авторизованих користувачів, що намагаються отримати доступ до URL, маркованого іншим користувачем.

  • belong_to_user перевіряє чи файл належить поточному користувачу.

class CreateList(Resource):
    @login_required
    @validate_user
    @marshal_with(file_array_serializer)
    def get(self, user_id):
        try:
            return File.filter({'creator': user_id, 'parent_id': '0'})
        except Exception as e:
            abort(500, message="There was an error while trying to get your files —> {}".format(e.message))

    @login_required
    @validate_user
    @marshal_with(file_serializer)
    def post(self, user_id):
        try:
            parser = reqparse.RequestParser()
            parser.add_argument('name', type=str, help="This should be the folder name if creating a folder")
            parser.add_argument('parent_id', type=str, help='This should be the parent folder id')
            parser.add_argument('is_folder', type=bool, help="This indicates whether you are trying to create a folder or not")

            args = parser.parse_args()

            name = args.get('name', None)
            parent_id = args.get('parent_id', None)
            is_folder =  args.get('is_folder', False)

            parent = None

            # Ми додаємо файл до папки?
            if parent_id is not None:
                parent = File.find(parent_id)
                if parent is None:
                    raise Exception("This folder does not exist")
                if not parent['is_folder']:
                    raise Exception("Select a valid folder to upload to")

            # Ми створюємо папку?
            if is_folder:
                if name is None:
                    raise Exception("You need to specify a name for this folder")

                return Folder.create(
                    name=name,
                    parent=parent,
                    is_folder=is_folder,
                    creator=user_id
                )
            else:
                files = request.files['file']

                if files and is_allowed(files.filename):
                    _dir = os.path.join(BASE_DIR, 'upload/{}/'.format(user_id))

                    if not os.path.isdir(_dir):
                        os.mkdir(_dir)

                    filename = secure_filename(files.filename)
                    to_path = os.path.join(_dir, filename)
                    files.save(to_path)
                    fileuri = os.path.join('upload/{}/'.format(user_id), filename)
                    filesize = os.path.getsize(to_path)

                    return File.create(
                        name=filename,
                        uri=fileuri,
                        size=filesize,
                        parent=parent,
                        creator=user_id
                    )
                raise Exception("You did not supply a valid file in your request")
        except Exception as e:
            abort(500, message="There was an error while processing your request —> {}".format(e.message))

Метод лістингу дуже простий, ми просто отримуємо файли користувача в кореневій директорії. Ми повертаємо отримані дані, і викидаємо виключення, якщо сталися якісь помилки.

А от при створенні файлу чи папки дій стає більше. Як я писав вище, ми використовуємо один маршрут для створенні і файлів і папок, тому в процесі створення ми робимо специфічні перевірки.

Ми звантажуємо в директорію, унікальну для кожного користувача. Ми використовуємо патерн /upload/<user_id>. Після завантаження ми отримуємо деякі дані про файл і додаємо їх до таблиці використовуючи відповідний метод: File.create() або Folder.create().

Зауважте, що ми використовуємо декоратор marshal_with доступний в Flask-RESTful. Цей декоратор використовується для форматування відповіді сервера. А ось так виглядають file_array_serializer та file_serializer:

file_array_serializer = {
    'id': fields.String,
    'name': fields.String,
    'size': fields.Integer,
    'uri': fields.String,
    'is_folder': fields.Boolean,
    'parent_id': fields.String,
    'creator': fields.String,
    'date_created': fields.DateTime(dt_format=  'rfc822'),
    'date_modified': fields.DateTime(dt_format='rfc822'),
}

file_serializer = {
    'id': fields.String,
    'name': fields.String,
    'size': fields.Integer,
    'uri': fields.String,
    'is_folder': fields.Boolean,
    'objects': fields.Nested(file_array_serializer, default=[]),
    'parent_id': fields.String,
    'creator': fields.String,
    'date_created': fields.DateTime(dt_format='rfc822'),
    'date_modified': fields.DateTime(dt_format='rfc822'),
}

Цей код можна додати в /api/controllers/files.py або в окремий файл /api/utils/serializers.py.

Різниця між два серіалізаторами в тому, что file_serializer містить масив об'єктів. Ми використовуємо file_array_serializer для списку об'єктів.

Також ми використовуємо функцію is_allowed() щоб перевірити чи підтримується файл нашим сервісом. Ми створили множину ALLOWED_EXTENSIONS, що містить допустимі розширення файлів.

def is_allowed(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1] in ALLOWED_EXTENSIONS

Й нарешті ми створимо ресурс ViewEditDelete в /api/controllers/files.py.

class ViewEditDelete(Resource):
    @login_required
    @validate_user
    @belongs_to_user
    @marshal_with(file_serializer)
    def get(self, user_id, file_id):
        try:
            should_download = request.args.get('download', False)
            if should_download == 'true':
                parts = os.path.split(g.file['uri'])
                return send_from_directory(directory=parts[0], filename=parts[1])
            return g.file
        except Exception as e:
            abort(500, message="There was an while processing your request —> {}".format(e.message))

    @login_required
    @validate_user
    @belongs_to_user
    @marshal_with(file_serializer)
    def put(self, user_id, file_id):
        try:
            update_fields = {}
            parser = reqparse.RequestParser()

            parser.add_argument('name', type=str, help="New name for the file/folder")
            parser.add_argument('parent_id', type=str, help="New parent folder for the file/folder")

            args = parser.parse_args()

            name = args.get('name', None)
            parent_id = args.get('parent_id', None)

            if name is not None:
                update_fields['name'] = name

            if parent_id is not None and g.file['parent_id'] != parent_id:
                if parent_id != '0'
                    folder_access = Folder.filter({'id': parent_id, 'creator': user_id})
                    if not folder_access:
                        abort(404, message="You don't have access to the folder you're trying to move this object to")

                if g.file['is_folder']:
                    update_fields['tag'] = g.file['id'] if parent_id == '0' else '{}#{}'.format(folder_access['tag'], folder['last_index'])
                    Folder.move(g.file, folder_access)
                else:
                    File.move(g.file, folder_access)

                update_fields['parent_id'] = parent_id

            if g.file['is_folder']:
                Folder.update(file_id, update_fields)
            else:
                File.update(file_id, update_fields)

            return File.find(file_id)
        except Exception as e:
            abort(500, message="There was an while processing your request —> {}".format(e.message))

    @login_required
    @validate_user
    @belongs_to_user
    def delete(self, user_id, file_id):
        try:
            hard_delete = request.args.get('hard_delete', False)
            if not g.file['is_folder']:
                if hard_delete == 'true':
                    os.remove(g.file['uri'])
                    File.delete(file_id)
                else:
                    File.update(file_id, {'status': False})
            else:
                if hard_delete == 'true':
                    folders = Folder.filter(lambda folder: folder['tag'].startswith(g.file['tag']))
                    for folder in folders:
                        files = File.filter({'parent_id': folder['id'], 'is_folder': False })
                        File.delete_where({'parent_id': folder['id'], 'is_folder': False })
                        for f in files:
                            os.remove(f['uri'])
                else:
                    File.update(file_id, {'status': False})
                    File.update_where({'parent_id': file_id}, {'status': False})
            return "File has been deleted successfully", 204
        except:
            abort(500, message="There was an error while processing your request —> {}".format(e.message)) 

Ми створили метод get() що повертає інформацію про файл по його id, для папок також повертає їх вміст. Також в нас є параметр should_download, що вказує чи слід завантажити файл.

Метод put() дбає про оновлення інформації про файли та папки, що включає і їх переміщення.

Метод delete() приймає параметр hard_delete, що визначає видалити файл повністю: з диску та з БД, чи лише встановити його status=False.

Також ми створили нові методи в RethinkDBModel для оновлення та видалення певних документів в таблиці.

@classmethod
def update_where(cls, predicate, fields):
    status = r.table(cls._table).filter(predicate).update(fields).run(conn)
    if status['errors']:
        raise DatabaseProcessError("Could not complete the update action")
    return True

@classmethod
def delete_where(cls, predicate):
    status = r.table(cls._table).filter(predicate).delete().run(conn)
    if status['errors']:
        raise DatabaseProcessError("Could not complete the delete action")
    return True

На цьому написання API завершене! Ви можете запустити його і протестувати. Наступна чатина туторіалу буде описувати написання фронтенд до нашого сервісу з використанням VueJS.

Репозитарій з кодом знаходиться тут.

Джерело перекладу

1890 9

Схожі матеріали:

Коментарі:

yevhene

02 Вер 2016 00:00

Выглядит грустно.

OlegWock

02 Вер 2016 15:49

Почему?

Leonid Prokopchuk

03 Вер 2016 06:22

От що є дійсно сумним — так це отримання під своїм матеріалом коментаря, який водночас має і нульове сенсове навантаження, і негативне емоційне забарвлення.

Невже так конче необхідно було відправити саме ці два слова? Є ж купа альтернатив — наприклад, "автор молодець", чи "корисна стаття".

Завжди варто притримати запасних 5-10 хвилин на критично-повчальні коментарі. Бо критика, наповнена змістом, йде на користь й автору, й спільноті. А коментарі на зразок "виглядіт грустна" — лише збільшують ентропію всесвіту.
(Даруйте за негативний відтінок коментаря, та бомбануло ж люто, їй-бо)

Simon

03 Вер 2016 23:26

Цікаво було б прочитати, як до такого проекту прикрутити білінг.

Дякую за статтю.

sergiko

05 Вер 2016 11:54

дуже дякую за статтю.

vabue

03 Жов 2016 23:48

А чого RethinkDB? Вона ж якось більше для чатиків та стрімів.

OlegWock

10 Жов 2016 17:47

З цим питанням до автора, я лише переклав. А взагалі вона дуже няшна

Авторизуйтесь, щоб залишити коментар.