django

Nuxt + Django + GraphQL на примере

  • вторник, 17 марта 2020 г. в 00:27:05
https://habr.com/ru/post/492486/
  • Django
  • VueJS



Предисловие


Nuxt — "фреймворк над фреймворком Vue" или популярная конфигурация Vue-based приложений с использованием лучших практик разработки на Vue. Среди них: организация каталогов приложения; включение и преконфигурация самых популярных инструментов в виде Nuxt модулей; включение Vuex по-умолчанию в любую конфигурацию; готовый и преднастроенный SSR с hot-reloading'ом


Django — самый популярный веб-фреймворк на почти самом популярном языке программирования на сегодняшний день — Python. Сами разработчики позиционируют проект как "Веб-фреймворк для перфекционистов с дедлайнами". Представляет из себя решение "всё в одном" и позволяет в кратчайшие сроки построить MVP вашего веб-приложения.


GraphQL — язык запросов изначально созданный компанией Facebook. В статье будет говориться о конкретных реализациях протокола этого языка, а именно библиотек Apollo для фронтенда и graphene для бэкенда.


О чем и для кого эта статья


В этой статье вы сможете узнать как можно собрать dev-окружение современного SPA приложения с server side рендерингом, на основе фреймворков Django и Nuxt, а также их сообщения посредством GraphQL API.


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


Описание старался делать как можно более понятным, в том числе и новичкам в программировании (коим, буду честен, я считаю и себя), и приводить как можно больше ссылок.


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


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


Построение приложения будет вестись поэтапно, чтобы на каждом этапе можно было удостовериться что всё работает правильно.


Перед началом


Убедитесь, что у вас уже установлен node.js и интерпретатор python. В примере используются версии: 13.9 и 3.7 соответственно.


В качестве менеджера виртуального окружения python в статье будет использоваться pipenv.


Консольные команды в статье запускаются в оболочке bash. Если вы пользователь Windows, то вместо команд cd, mv, mkdir используйте аналоги, и благодаря кросс-платформенной природе python и node, всё остальное должно работать вне зависимости от ОС.


В качестве базы данных для простоты будет использоваться Sqlite, которая не требует дополнительной конфигурации.


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


Python библиотеки


Библиотека Версия
django 2.2
graphene-django 2.8.2
django-cors-headers 3.2

Javascript библиотеки


Библиотека Версия
Nuxt 2.11
nuxtjs/apollo 4.0.1
nuxtjs/vuetify 0.5.5
cookie-universal-nuxt 2.1.2
graphql-tag 2.10

Приступим


Django


Создание проекта и окружения


Для начала необходимо установить менеджер виртуального окружения. В примере я буду использовать pipenv. Для установки:


pip install pipenv

В некоторых операционных системах для этого действия могут потребоваться права суперпользователя. Также pipenv можно установить из репозитория вашей операционной системы.


Создадим директорию с проектом и инициализируем в ней окружение pipenv. В моем случае проект будет располагаться по пути ~/Documents/projects/todo-list. Создадим эту директорию и перейдем в неё.


mkdir ~/Documents/projects/todo-list
cd ~/Documents/projects/todo-list

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


pipenv install django==2.2.10 graphene_django

Библиотека graphene_django позволяет описывать схему GraphQL API на основе моделей Django ORM. Очень удобно, но как по мне, со связыванием таблиц БД и фронтом напрямую нужно быть очень осторожным.


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


pipenv shell  # активируем виртуальное окружение pipenv

Создаем проект Django.


django-admin createapp backend

Настройка


Перенос manage.py


Так как фронтенд и бэкенд нашего todo-листа будет находиться в одной директории, было бы неплохо иметь все управляющие файлы в корневой директории проекта. В Django управляющим файлом является manage.py, давайте вынесем его из директории backend на уровень повыше.


Для этого, из корневой директории проекта:


mv backend/manage.py .

После перемещения нужно исправить путь к файлу настроек внутри файла manage.py.


# manage.py
import os
import sys

if __name__ == "__main__":
    # укажите путь к файлу настроек вашего проекта
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.backend.settings")
    ...

Также в файле backend/backend/settings.py приведем следующие переменные к виду:


ROOT_URLCONF = 'backend.backend.urls'
WSGI_APPLICATION = 'backend.backend.wsgi.application'

Добавление graphene_django


В файле backend/backend/settings.py в переменную INSTALLED_APPS добавляем установленный ранее graphene_django:


# backend/backend/settings.py

INSTALLED_APPS = [
  ...,
  'graphene_django',
]

Проверяем работоспособность


python manage.py runserver

По-умолчанию сервер запускается на порту 8000. Переходим на http://localhost:8000/, он должен нас встречать следующей картиной:


Django hello


Настройка graphene


После изменений ниже http://localhost:8000/ уже не будет встречать нас ракетой. В файле backend/backend/urls.py


# backend/backend/urls.py
from django.contrib import admin
from django.urls import path
from graphene_django.views import GraphQLView
from django.conf import settings

urlpatterns = [
    path('admin/', admin.site.urls),
    # graphiql - мини IDE для разработки graphql запросов
    path('graphql/', GraphQLView.as_view(graphiql=settings.DEBUG))
]

Создадим пустую схему, например, в файле backend/backend/api.py


# backend/todo_list/api.py
import graphene
schema = graphene.Schema()

В файл настроек необходимо добавить переменную GRAPHENE, в которой мы укажем путь до нашей схемы:


# /backend/backend/settings.py
GRAPHENE = {
    'SCHEMA': 'backend.backend.api.schema',
}

Проверяем работоспособность. Запускаем сервер уже известной командой runserver:


python manage.py runserver

и переходим на http://localhost:8000/graphql/. Там нас должна встретить та самая мини "IDE" GrapiQL:


GraphiQL


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


Приложение todo_list


Создание приложения


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


cd backend
django-admin startapp todo_list

Скрипт django-admin не знает где находится корень нашего приложения, поэтому нам нужно немного подправить файл backend/todo_list/apps.py, чтобы он выглядит следующим образом:


from django.apps import AppConfig

class TodoListConfig(AppConfig):
    name = 'backend.todo_list'

Добавим наше новое приложение в INSTALLED_APPS, что находится в файле settings.py:


# backend/backend/settings.py
INSTALLED_APPS = [
    ...,
    'backend.todo_list',
    ...
]

Добавим модели Todo и Category в файл backend/todo_list/models.py:


models.py
# backend/todo_list/models.py
from datetime import timedelta

from django.db import models
from django.utils import timezone

class Category(models.Model):
    name = models.CharField(max_length=100, unique=True)

    class Meta:
        verbose_name = 'Категория'
        verbose_name_plural = 'Категории'

    def __str__(self):
        return self.name

def get_due_date():
    """ На выполнение задачи по-умолчанию даётся один день """
    return timezone.now() + timedelta(days=1)

class Todo(models.Model):
    title = models.CharField(max_length=250)
    text = models.TextField(blank=True)
    created_date = models.DateField(auto_now_add=True)
    due_date = models.DateField(default=get_due_date)
    category = models.ForeignKey(Category, related_name='todo_list', on_delete=models.PROTECT)
    done = models.BooleanField(default=False)

    class Meta:
        verbose_name = 'Задача'
        verbose_name_plural = 'Задачи'

    def __str__(self):
        return self.title

Для того, чтобы наши модели превратились в таблицы в БД, нужно выполнить следующее:


Создать файлы миграций, в которых будет описываться наша текущая схема:


python manage.py makemigrations

С примерно таким выводом:


Применить эти миграции командой migrate. Т.к. это первый запуск скрипта migrate, у нас также будут применяться миграции приложений Django:


python manage.py migrate

Вывод консоли


Создание GraphQL API


Опишем типы, создадим запросы и мутации для наших новых моделей. Для этого в директории приложения todo_list создадим файл schema.py, со следующим содержимым:


schema.py
# backend/todo_list/schema.py
import graphene
from graphene_django import DjangoObjectType

from backend.todo_list.models import Todo, Category

# С помощью graphene_django привязываем типы к моделям,
# что позволит ходить по всей вложенности базы данных как угодно,
# прямо из интерфейса GraphiQL.
# Однако будьте осторожны, связывание таблиц практически напрямую
# с фронтом может быть чревато при росте проекта. Думаю такой способ
# подходит преимущественно для небольших CRUD приложений.
class CategoryNode(DjangoObjectType):
    class Meta:
        model = Category

class TodoNode(DjangoObjectType):
    class Meta:
        model = Todo

class Query(graphene.ObjectType):
    """ Описываем запросы и возвращаемые типы данных """
    todo_list = graphene.List(TodoNode)
    categories = graphene.List(CategoryNode)

    def resolve_todo_list(self, info):
        return Todo.objects.all().order_by('-id')

    def resolve_categories(self, info):
        return Category.objects.all()

class Mutation(graphene.ObjectType):
    """ В мутации описываем типы запросов (простите за каламбур),
    типы возвращаемых данных и типы принимаемых переменных
    """
    add_todo = graphene.Field(TodoNode,
                              title=graphene.String(required=True),
                              text=graphene.String(),
                              due_date=graphene.Date(required=True),
                              category=graphene.String(required=True))
    remove_todo = graphene.Field(graphene.Boolean, todo_id=graphene.ID())
    toggle_todo = graphene.Field(TodoNode, todo_id=graphene.ID())

    def resolve_add_todo(self, info, **kwargs):
        category, _ = Category.objects.get_or_create(name=kwargs.pop('category'))
        return Todo.objects.create(category=category, **kwargs)

    def resolve_remove_todo(self, info, todo_id):
        try:
            Todo.objects.get(id=todo_id).delete()
        except Todo.DoesNotExist:
            return False
        return True

    def resolve_toggle_todo(self, info, todo_id):
        todo = Todo.objects.get(id=todo_id)
        todo.done = not todo.done
        todo.save()
        return todo

После создания классов мутации и запроса, их нужно добавить в нашу схему. Как вы, возможно, помните схему мы описывали в файле api.py:


# backend/backend/api.py
import graphene
from backend.todo_list.schema import Query, Mutation
schema = graphene.Schema(query=Query, mutation=Mutation)

Если хотите лучше понять происходящее, можете прочитать эту статью на Хабре, или обратиться к документации Graphene (англ.).


Проверка API


ID записей в примерах ниже могут различаться с ID ваших записей.


Запускаем сервер привычной командой runserver:


python manage.py runserver

Идем по пути http://localhost:8000/graphql/. Там нас должен встречать уже знакомый интерфейс graphiql. И как вы, возможно, заметили ошибка пропала.


Давайте проверим получившиеся запросы и мутации.


addTodo

Запрос


  mutation(
    $title: String!
    $text: String
    $dueDate: Date!
    $category: String!
  ) {
    addTodo(
      title: $title
      text: $text
      dueDate: $dueDate
      category: $category
    ) {
      todo {
        id
        title
        text
        done
        createdDate
        dueDate
        category {
          id
          name
        }
      }
    }
  }

Переменные


{
  "title": "First Todo",
  "text": "Just do it!",
  "dueDate": "2020-10-17",
  "category": "Работа"
}

Результат


В результате этой мутации у нас создалось две записи:


  • Todo т.к. собственно мутация для этого и написана;
  • Category, т.к. в базе не оказалось категорий с названием "Работа", а метод get_or_create говорит за себя сам.

todoList и categories

Запрос


{
  todoList {
    id
    title
    text
    createdDate
    dueDate
category {
      id
      name
    }
  }
  categories {
    id
    name
  }
}

Результат:


toggleTodo

Мутация


mutation ($todoId: ID) {
  toggleTodo(todoId: $todoId) {
    id
    title
    text
    createdDate
    dueDate
    category {
      id
      name
    }
    done
  }
}

Переменные


{
  "todoId": "1"
}

Результат:


removeTodo

Мутация


mutation ($todoId: ID) {
  removeTodo(todoId: $todoId)
}

Переменные можно оставить из предыдущей мутации.


Результат


Чтобы ближе познакомиться с синтаксисом и терминологией GraphQL, можете ознакомиться с этой статьей на Хабре.


Nuxt


Создание Nuxt приложения


Откройте консоль внутри корневой директории проекта и запустите скрипт установки Nuxt:


npx create-nuxt-app frontend

Запустится очень простой и понятный скрипт установки, который предложит указать описание проекта и предоставит на выбор для установки несколько библиотек. Можете выбрать, что захотите, но из рекомендуемых пунктов я бы посоветовал выбрать "Custom UI Framework: vuetify", т.к. в примере используется именно он, и "Rendering mode: Universal", т.к. в статье рассматривается пример именно с SSR.


Пример моей конфигурации:


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


cd frontend
npm run dev

и перейдем на http://localhost:3000. Там нас должна ждать страница приветствия Nuxt + Vuetify:


Nuxt + Vuetify


Перенос конфигурационных файлов


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


cd frontend
mv node_modules ..
mv nuxt.config.js ..
mv .gitignore ..
mv package-lock.json ..
mv package.json ..
mv .prettierrc ..
mv .eslintrc.js ..
mv .editorconfig ..
rm -rf .git

Затем в файле nuxt.config.js указываем корневую директорию приложения:


// nuxt.config.js
export default {
  ...,
  rootDir: 'frontend',
  ...
}

После этого желательно еще раз убедиться в работоспособности проекта, выполнив запуск dev-сервера уже из корневой директории:


npm run dev

Верстка функционального макета


Любой компонент с префиксом v- это компонент UI-toolkit'a Vuetify. У этой библиотеки отличная и подробная документация.


Если вы хотите подробнее узнать, что делает тот или иной компонент, смело вбивайте в гугл v-component-name. Только не забывайте, что в примере используется версия vuetify 1.5.


Приводим файл frontend/layouts/default.vue к виду:


<template>
  <v-app>
    <v-content>
      <v-container>
        <nuxt />
      </v-container>
    </v-content>
  </v-app>
</template>

Создадим компонент нового Todo по пути frontend/components/NewTodoForm.vue:


NewTodoForm.vue
<!-- frontend/components/NewTodoForm.vue -->
<template>
  <v-form ref="form" v-model="valid">
    <v-card>
      <v-card-text class="pt-0 mt-0">
        <v-layout row wrap>
          <v-flex xs8>
            <!-- Поле ввода имени задачи -->
            <v-text-field
              v-model="newTodo.title"
              :rules="[nonEmptyField]"
              label="Задача"
              prepend-icon="check_circle_outline"
            />
          </v-flex>
          <v-flex xs4>
            <!-- Поле выбора даты выполнения задачи -->
            <v-menu
              ref="menu"
              v-model="menu"
              :close-on-content-click="false"
              :nudge-right="40"
              :return-value.sync="newTodo.dueDate"
              lazy
              transition="scale-transition"
              offset-y
              full-width
              min-width="290px"
            >
              <template v-slot:activator="{ on }">
                <v-text-field
                  v-model="newTodo.dueDate"
                  :rules="[nonEmptyField]"
                  v-on="on"
                  label="Дата выполнения"
                  prepend-icon="event"
                  readonly
                />
              </template>
              <v-date-picker
                v-model="newTodo.dueDate"
                no-title
                scrollable
                locale="ru-ru"
                first-day-of-week="1"
              >
                <v-spacer />
                <v-btn @click="menu = false" flat color="primary">Отмена</v-btn>
                <v-btn
                  @click="$refs.menu.save(newTodo.dueDate)"
                  flat
                  color="primary"
                  >Выбрать</v-btn
                >
              </v-date-picker>
            </v-menu>
          </v-flex>
          <v-flex xs12>
            <v-textarea
              v-model="newTodo.text"
              :rules="[nonEmptyField]"
              label="Описание"
              prepend-icon="description"
              hide-details
              rows="1"
              class="py-0 my-0"
            />
          </v-flex>
        </v-layout>
      </v-card-text>
      <v-card-actions>
        <!-- Селектор категорий. Позволяет добавлять несуществующие позиции -->
        <v-combobox
          v-model="newTodo.category"
          :rules="[nonEmptyField]"
          :items="categories"
          hide-details
          label="Категория"
          class="my-0 mx-2 mb-2 pt-0"
          prepend-icon="category"
        />
        <v-spacer />
        <v-btn :disabled="!valid" @click="add" color="blue lighten-1" flat
          >Добавить</v-btn
        >
      </v-card-actions>
    </v-card>
  </v-form>
</template>

<script>
export default {
  name: 'NewTodoForm',
  data() {
    return {
      newTodo: null,
      categories: ['Дом', 'Работа', 'Семья', 'Учеба'],
      valid: false,
      menu: false,
      nonEmptyField: text =>
        text ? !!text.length : 'Поле не должно быть пустым'
    }
  },
  created() {
    this.clear()
  },
  methods: {
    add() {
      this.$emit('add', this.newTodo)
      this.clear()
      this.$refs.form.reset()
    },
    clear() {
      this.newTodo = {
        title: '',
        text: '',
        dueDate: '',
        category: ''
      }
    }
  }
}
</script>

Далее компонент существующего Todo, там же:


TodoItem.vue
<!-- frontend/components/TodoItem.vue -->
<template>
  <v-card>
    <v-card-title class="pb-1" style="overflow-wrap: break-word;">
      <b>{{ todo.title }}</b>
      <v-spacer />
      <v-btn
        @click="$emit('delete', todo.id)"
        flat
        small
        icon
        style="position: absolute; right: 0; top: 0"
      >
        <v-icon :disabled="$nuxt.isServer" small>close</v-icon>
      </v-btn>
    </v-card-title>
    <v-card-text class="py-1">
      <v-layout row justyfy-center align-center>
        <v-flex xs11 style="overflow-wrap: break-word;">
          {{ todo.text }}
        </v-flex>
        <v-flex xs1>
          <div style="text-align: right;">
            <v-checkbox
              v-model="todo.done"
              hide-details
              class="pa-0 ma-0"
              style="display: inline-block;"
              color="green lighten-1"
            />
          </div>
        </v-flex>
      </v-layout>
    </v-card-text>
    <v-card-actions>
      <span class="grey--text">
        Выполнить до <v-icon small>event</v-icon> {{ todo.dueDate }} | Создано
        <v-icon small>calendar_today</v-icon> {{ todo.createdDate }}
      </span>
      <v-spacer />
      <span class="grey--text">
        <v-icon small>category</v-icon>Категория: {{ todo.category }}
      </span>
    </v-card-actions>
  </v-card>
</template>

<script>
export default {
  name: 'TodoItem',
  props: {
    todo: {
      type: Object,
      default: () => ({})
    }
  }
}
</script>

И наконец вставим новые компоненты в index.vue, и добавим в него немного рыбы:


index.vue
<!-- frontend/pages/index.vue -->
<template>
  <v-layout row wrap justify-center>
    <v-flex xs8 class="pb-1">
      <new-todo-form @add="addTodo" />
    </v-flex>
    <v-flex v-for="todo of todoList" :key="todo.id" xs8 class="my-1">
      <todo-item :todo="todo" @delete="deleteTodo" />
    </v-flex>
  </v-layout>
</template>

<script>
import NewTodoForm from '../components/NewTodoForm'
import TodoItem from '../components/TodoItem'
export default {
  components: { TodoItem, NewTodoForm },
  data() {
    return {
      todoList: [
        {
          id: 1,
          title: 'TODO 1',
          text:
            'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
          dueDate: '2020-10-16',
          createdDate: '2020-03-09',
          done: false,
          category: 'Работа'
        },
        {
          id: 2,
          title: 'TODO 2',
          text:
            'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
          dueDate: '2020-10-16',
          createdDate: '2020-03-09',
          done: false,
          category: 'Работа'
        },
        {
          id: 3,
          title: 'TODO 3',
          text:
            'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
          dueDate: '2020-10-16',
          createdDate: '2020-03-09',
          done: false,
          category: 'Работа'
        },
        {
          id: 4,
          title: 'TODO 4',
          text:
            'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
          dueDate: '2020-10-16',
          createdDate: '2020-03-09',
          done: false,
          category: 'Работа'
        },
        {
          id: 5,
          title: 'TODO 5',
          text:
            'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
          dueDate: '2020-10-16',
          createdDate: '2020-03-09',
          done: false,
          category: 'Работа'
        },
        {
          id: 6,
          title: 'TODO 6',
          text:
            'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
          dueDate: '2020-10-16',
          createdDate: '2020-03-09',
          done: false,
          category: 'Работа'
        }
      ]
    }
  },
  methods: {
    addTodo(newTodo) {
      const id = this.todoList.length
        ? Math.max.apply(
            null,
            this.todoList.map(item => item.id)
          ) + 1
        : 1
      this.todoList.unshift({
        id,
        createdDate: new Date().toISOString().substr(0, 10),
        done: false,
        ...newTodo
      })
    },
    deleteTodo(todoId) {
      this.todoList = this.todoList.filter(item => item.id !== todoId)
    }
  }
}
</script>

После проделанной работы рекомендую проверить работоспособность получившегося макета. Запустите dev сервер и перейдите на http://localhost:3000/, там вы должны увидеть следующую картину:


Функциональный макет


Объединение фронтенда и бэкенда


Настройка CSRF-защиты Django + Apollo


В Django по-умолчанию используется CSRF защита.


Эта защита реализуется при помощи промежуточного слоя (middleware) — CsrfViewMiddleware. Посмотреть на него вы можете в файле settings.py в переменной MIDDLEWARE.


Принцип его работы очень прост: у любого POST-запроса к Django в заголовках должен иметься CSRF-токен. Если этот токен отсутствует, то сервер просто отклоняет этот запрос.


CSRF-токен в классическом django приложении приходит вместе с любым GET-запросом, после чего при необходимости добавляется в формы при рендеринге шаблона.


В нашем случае проблема в том, что вне зависимости от того, выполняется в Apollo мутация или запрос, метод их по-умолчанию всегда будет POST. Apollo позволяет изменить это поведение таким образом, чтобы для Query метод запроса был GET, а для Mutation — POST, но насколько я знаю, graphene на данный момент не поддерживает подобный режим работы.


Я поступил следующим образом: немного расширил логику стандартного CsrfViewMiddleware таким образом, чтобы он проверял тип GraphQL запроса, и уже на основе этого принимал или сбрасывал соединение.


Для этого добавим кастомную проверку CSRF, например, в уже знакомый нам файл api.py


api.py
# backend/backend/api.py
import json
import graphene
from django.middleware.csrf import CsrfViewMiddleware

from backend.todo_list.schema import Query, Mutation

schema = graphene.Schema(query=Query, mutation=Mutation)

class CustomCsrfMiddleware(CsrfViewMiddleware):
    def process_view(self, request, callback, callback_args, callback_kwargs):
        if getattr(request, 'csrf_processing_done', False):
            return None
        if getattr(callback, 'csrf_exempt', False):
            return None
        try:
            body = request.body.decode('utf-8')
            body = json.loads(body)
        # в любой непонятной ситуации передаём запрос оригинальному CsrfViewMiddleware
        except (TypeError, ValueError, UnicodeDecodeError):
            return super(CustomCsrfMiddleware, self).process_view(request, callback, callback_args, callback_kwargs)
        # проверка на list, т.к. клиент может отправлять "батченные" запросы
        # https://blog.apollographql.com/batching-client-graphql-queries-a685f5bcd41b
        if isinstance(body, list):
            for query in body:
                # если внутри есть хотя бы одна мутация, то отправляем запрос
                # к оригинальному CsrfViewMiddleware
                if 'mutation' in query:
                    break
            else:
                return self._accept(request)
        else:
            # принимаем любые query без проверки на csrf
            if 'query' in body and 'mutation' not in body:
                return self._accept(request)
        return super(CustomCsrfMiddleware, self).process_view(request, callback, callback_args, callback_kwargs)

Далее, в файле settings.py нужно заменить "оригинальный" CsrfViewMiddleware, на кастомный:


# settings.py
MIDDLEWARE = [
    ...,
    'backend.backend.api.CustomCsrfMiddleware',
    # 'django.middleware.csrf.CsrfViewMiddleware',
    ...,
]

Если уважаемый читатель знает более надежные и правильные способы CSRF-защиты в связке Django + Nuxt + Apollo, то призываю поделиться своим знанием в комментариях.


Django CORS Headers


Т.к. dev сервера бэкенда и фронтента у нас стоят на разных портах, то Django нужно оповестить, с каких хостов могут совершаться запросы, и какие заголовки ему разрешено обрабатывать. А поможет нам в этом библиотека django-cors-headers:


pipenv install "django-cors-headers>=3.2"

В settings.py добавим:


# backend/backend/settings.py
from corsheaders.defaults import default_headers

INSTALLED_APPS = [
    ...,
    'graphene_django',
    'backend.todo_list',
    'corsheaders',  # вот эту строку
]

CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = default_headers + ('cache-control', 'cookies')
CORS_ORIGIN_ALLOW_ALL = True  # не рекомендуется для production

# А также парочку middleware
MIDDLEWARE = [
    ...,
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    ...,
]

Установка и настройка Apollo


Для Nuxt существует собственный модуль apollo, который в свою очередь основан на библиотеке vue-apollo (которая в свою очередь основана на Apollo). Для его установки введите:


npm install --save @nuxtjs/apollo graphql-tag cookie-universal-nuxt

Также при конфигурации Apollo нам понадобится небольшая библиотека cookie-universal-nuxt для манипуляции куками при рендере на стороне сервера.
Добавим эти модули в nuxt.config.js. В зависимости от вашей изначальной конфигурации там уже может быть несоклько модулей. Как минимум там должен быть vuetify:


// nuxt.config.js
export default {
  ...,
  modules: [
    ...,
    '@nuxtjs/vuetify',
    '@nuxtjs/apollo',
    'cookie-universal-nuxt'
  ],
  apollo: {
    clientConfigs: {
      default: '~/plugins/apollo-client.js'
    }
  },
  ...
}

Настройка Apollo дело невсегда тривиальное. Постараемся обойтись минимальной конфигурацией. Создадим файл по указанному выше пути:


Конфигурируем клиент Apollo, а вместе с тем формируем цепочку обработчиков запросов.


apollo-client.js
// frontend/plugins/apollo-client.js
import { HttpLink } from 'apollo-link-http'
import { setContext } from 'apollo-link-context'
import { from, concat } from 'apollo-link'
import { InMemoryCache } from 'apollo-cache-inmemory'

// Если плагин является функцией, то в процессе инициализации Nuxt передаёт в неё контекст ctx
export default ctx => {
  /**
   * По-умолчанию при рендере со стороны сервера заголовки
   * в запросе к бэкенду не отправляются, так что "пробрасываем"
   * заголовки от клиента.
   */
  const ssrMiddleware = setContext((_, { headers }) => {
    if (process.client) return headers
    return {
      headers: {
        ...headers,
        connection: ctx.app.context.req.headers.connection,
        referer: ctx.app.context.req.headers.referer,
        cookie: ctx.app.context.req.headers.cookie
      }
    }
  })

  /**
   * Добавление CSRF-токена к запросу.
   * https://docs.djangoproject.com/en/2.2/ref/csrf/#ajax
   */
  const csrfMiddleware = setContext((_, { headers }) => {
    return {
      headers: {
        ...headers,
        'X-CSRFToken': ctx.app.$cookies.get('csrftoken') || null
      }
    }
  })
  const httpLink = new HttpLink({
    uri: 'http://localhost:8000/graphql/',
    credentials: 'include'
  })
  // Middleware в Apollo это примерно тоже самое что и middleware в Django, 
  // только на стороне клиента. Объединяем их в цепочку. Последовательность важна.
  const link = from([csrfMiddleware, ssrMiddleware, httpLink])
  // Инициализируем кэш. При должном усердии он может заменить Vuex,
  // но об этом как-нибудь в другой раз
  const cache = new InMemoryCache()

  return {
    link,
    cache,
    // без отключения стандартного apollo-module HttpLink'a в консоль сыпятся варнинги
    defaultHttpLink: false
  }
}

На этом этапе лучше еще раз удостовериться, что Nuxt собирается без ошибок, запустив dev сервер.


Оживляем приложение


И вот наконец настало время соединить фронт и бэк.


Для начала где-нибудь создадим файл, в котором будут храниться все запросы и мутации к бэкенду. В моем случае этот файл расположился по пути frontend/graphql.js с уже знакомым нам содержимым:


graphql.js
import gql from 'graphql-tag'

// т.к. внутренности записи Todo используются практически во всех запросах,
// то резонно вынести их в отдельный фрагмент
// https://www.apollographql.com/docs/react/data/fragments/
const TODO_FRAGMENT = gql`
  fragment TodoContents on TodoNode {
    id
    title
    text
    done
    createdDate
    dueDate
    category {
      id
      name
    }
  }
`

const ADD_TODO = gql`
  mutation(
    $title: String!
    $text: String
    $dueDate: Date!
    $category: String!
  ) {
    addTodo(
      title: $title
      text: $text
      dueDate: $dueDate
      category: $category
    ) {
      ...TodoContents
    }
  }
  ${TODO_FRAGMENT}
`

const TOGGLE_TODO = gql`
  mutation($todoId: ID) {
    toggleTodo(todoId: $todoId) {
      ...TodoContents
    }
  }
  ${TODO_FRAGMENT}
`

const GET_CATEGORIES = gql`
  {
    categories {
      id
      name
    }
  }
`

const GET_TODO_LIST = gql`
  {
    todoList {
      ...TodoContents
    }
  }
  ${TODO_FRAGMENT}
`

const REMOVE_TODO = gql`
  mutation($todoId: ID) {
    removeTodo(todoId: $todoId)
  }
`

export { ADD_TODO, TOGGLE_TODO, GET_CATEGORIES, GET_TODO_LIST, REMOVE_TODO }

Теперь нужно немного изменить уже существующий функционал фронтенда. Наконец пришло время добавить туда взаимодействие с бэкендом.


Изменим Vue компоненты:


index.vue
<!-- frontend/pages/index.vue -->
<template>
  <v-layout row wrap justify-center>
    <v-flex xs8 class="pb-1">
      <!-- emit'ы теперь нам не нужны -->
      <new-todo-form />
    </v-flex>
    <v-flex v-for="todo of todoList" :key="todo.id" xs8 class="my-1">
      <todo-item :todo="todo" />
    </v-flex>
  </v-layout>
</template>

<script>
import NewTodoForm from '../components/NewTodoForm'
import TodoItem from '../components/TodoItem'
// импортируем свеженаписанные запросы
import { GET_TODO_LIST } from '../graphql'

export default {
  components: { TodoItem, NewTodoForm },
  data() {
    return {
      todoList: []
    }
  },
  apollo: {
    // получаем список todoList. При таком объявлении запроса переменная todoList
    // должна записаться результатами запроса, однако запрос должен называться
    // аналогично с переменной
    todoList: { query: GET_TODO_LIST }
  }
}
</script>

NewTodoForm.vue
<!-- frontend/components/NewTodoForm.vue -->
<template>
  <v-form ref="form" v-model="valid">
    <v-card>
      <v-card-text class="pt-0 mt-0">
        <v-layout row wrap>
          <v-flex xs8>
            <v-text-field
              v-model="newTodo.title"
              :rules="[nonEmptyField]"
              label="Задача"
              prepend-icon="check_circle_outline"
            />
          </v-flex>
          <v-flex xs4>
            <v-menu
              ref="menu"
              v-model="menu"
              :close-on-content-click="false"
              :nudge-right="40"
              :return-value.sync="newTodo.dueDate"
              lazy
              transition="scale-transition"
              offset-y
              full-width
              min-width="290px"
            >
              <template v-slot:activator="{ on }">
                <v-text-field
                  v-model="newTodo.dueDate"
                  :rules="[nonEmptyField]"
                  v-on="on"
                  label="Дата выполнения"
                  prepend-icon="event"
                  readonly
                />
              </template>
              <v-date-picker
                v-model="newTodo.dueDate"
                no-title
                scrollable
                locale="ru-ru"
                first-day-of-week="1"
              >
                <v-spacer />
                <v-btn @click="menu = false" flat color="primary">Отмена</v-btn>
                <v-btn
                  @click="$refs.menu.save(newTodo.dueDate)"
                  flat
                  color="primary"
                  >Выбрать</v-btn
                >
              </v-date-picker>
            </v-menu>
          </v-flex>
          <v-flex xs12>
            <v-textarea
              v-model="newTodo.text"
              :rules="[nonEmptyField]"
              label="Описание"
              prepend-icon="description"
              hide-details
              rows="1"
              class="py-0 my-0"
            />
          </v-flex>
        </v-layout>
      </v-card-text>
      <v-card-actions>
        <v-combobox
          v-model="newTodo.category"
          :rules="[nonEmptyField]"
          :items="categories"
          hide-details
          label="Категория"
          class="my-0 mx-2 mb-2 pt-0"
          prepend-icon="category"
        />
        <v-spacer />
        <v-btn
          :disabled="!valid"
          :loading="loading"
          @click="add"
          color="blue lighten-1"
          flat
          >Добавить</v-btn
        >
      </v-card-actions>
    </v-card>
  </v-form>
</template>

<script>
// импортируем свеженаписанные запросы
import { ADD_TODO, GET_CATEGORIES, GET_TODO_LIST } from '../graphql'

export default {
  name: 'NewTodoForm',
  data() {
    return {
      newTodo: null,
      categories: [],
      valid: false,
      menu: false,
      nonEmptyField: text =>
        text ? !!text.length : 'Поле не должно быть пустым',
      loading: false // индикация выполнения запроса
    }
  },
  apollo: {
    // загрузка данных для селектора категорий
    categories: {
      query: GET_CATEGORIES,
      update({ categories }) {
        return categories.map(c => c.name)
      }
    }
  },
  created() {
    this.clear()
  },
  methods: {
    add() {
      this.loading = true
      this.$apollo
        .mutate({
          mutation: ADD_TODO,
          variables: {
            ...this.newTodo
          },
          // кэш аполло позволяет манипулировать данными из этого кэша, вне зависимости
          // от того, в каком компоненте выполняется код. Здесь в качестве ответа
          // сервера мы получаем новую запись Todo. Добавляем её в кэш, записываем
          // обратно по запросу GET_TODO_LIST, таким образом переменная Apollo
          // сам разошлет всем подписчикам данного запроса измененные данные. В нашем
          // случае подписчиком является переменная todoList в компоненте index.vue
          update: (store, { data: { addTodo } }) => {
            // если в кэше отсутствуют данные по запросу, то бросится исключение
            const todoListData = store.readQuery({ query: GET_TODO_LIST })
            todoListData.todoList.unshift(addTodo)
            store.writeQuery({ query: GET_CATEGORIES, data: todoListData })

            const categoriesData = store.readQuery({ query: GET_CATEGORIES })
            // В списке категорий ищем категорию новой записи Todo. При неудачном поиске
            // добавляем в кэш. Таким образом селектор категорий всегда остается
            // в актуальном состоянии
            const category = categoriesData.categories.find(
              c => c.name === addTodo.category.name
            )
            if (!category) {
              categoriesData.categories.push(addTodo.category)
              store.writeQuery({ query: GET_CATEGORIES, data: categoriesData })
            }
          }
        })
        .then(() => {
          this.clear()
          this.loading = false
          this.$refs.form.reset() // сброс валидации формы
        })
    },
    clear() {
      this.newTodo = {
        title: '',
        text: '',
        dueDate: '',
        category: ''
      }
    }
  }
}
</script>

TodoItem.vue
<!-- frontend/components/NewTodoForm.vue -->
<template>
  <v-card>
    <v-card-title class="pb-1" style="overflow-wrap: break-word;">
      <b>{{ todo.title }}</b>
      <v-spacer />
      <!-- Изменено событие -->
      <v-btn
        @click="remove"
        flat
        small
        icon
        style="position: absolute; right: 0; top: 0"
      >
        <v-icon :disabled="$nuxt.isServer" small>close</v-icon>
      </v-btn>
    </v-card-title>
    <v-card-text class="py-1">
      <v-layout row justyfy-center align-center>
        <v-flex xs11 style="overflow-wrap: break-word;">
          {{ todo.text }}
        </v-flex>
        <v-flex xs1>
          <div style="text-align: right;">
            <!-- Добавлена обработка клика -->
            <v-checkbox
              :value="todo.done"
              @click.once="toggle"
              hide-details
              class="pa-0 ma-0"
              style="display: inline-block;"
              color="green lighten-1"
            />
          </div>
        </v-flex>
      </v-layout>
    </v-card-text>
    <v-card-actions>
      <span class="grey--text">
        Выполнить до <v-icon small>event</v-icon> {{ todo.dueDate }} | Создано
        <v-icon small>calendar_today</v-icon> {{ todo.createdDate }}
      </span>
      <v-spacer />
      <span class="grey--text">
        <!-- Изменен путь получения имени категории -->
        <v-icon small>category</v-icon>Категория: {{ todo.category.name }}
      </span>
    </v-card-actions>
  </v-card>
</template>

<script>
// импортируем свеженаписанные запросы
import { GET_TODO_LIST, REMOVE_TODO, TOGGLE_TODO } from '../graphql'

export default {
  name: 'TodoItem',
  props: {
    todo: {
      type: Object,
      default: () => ({})
    }
  },
  // с этого момента изменения по-серьезнее
  methods: {
    toggle() {
      // Для запроса который возвращает измененный элемент не обязательно
      // вручную прописывать функцию update. Apollo сам найдёт в каких
      // запросах "участвует" измененная запись, и разошлет всем подписчикам
      // измененный объект. В нашем случае это запрос в компоненте index.vue
      // на получение списка Todo
      this.$apollo.mutate({
        mutation: TOGGLE_TODO,
        variables: {
          todoId: this.todo.id
        }
      })
    },
    remove() {
      // функция update не видит контекста this
      const todoId = this.todo.id
      this.$apollo.mutate({
        mutation: REMOVE_TODO,
        variables: {
          todoId
        },
        update(store, { data: { removeTodo } }) {
          if (!removeTodo) return
          // В случае успешного удаления удаляем текущий элемент из кэша
          const data = store.readQuery({ query: GET_TODO_LIST })
          data.todoList = data.todoList.filter(todo => todo.id !== todoId)
          // Самоуничтожаемся!
          store.writeQuery({ query: GET_TODO_LIST, data })
        }
      })
    }
  }
}
</script>

Проверим, что у нас получилось


Результат


Заключение


В этой статье я попытался рассказать как построить взаимодействие между Django и Nuxt с помощью GraphQL API, показать решение проблем с которыми довелось столкнуться мне. Надеюсь это подтолкнет энтузиастов попробовать что-то новое, и сэкономит время в решении проблем.


Весь код доступен на GitHub.