Предисловие
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/, он должен нас встречать следующей картиной:
Настройка 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:
Ничего страшного в том, что нас встречает ошибка. Она появляется из-за того, что наша схема пуста. Мы исправим это при реализации дальше.
Приложение 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
:
# 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
, со следующим содержимым:
# 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
. И как вы, возможно, заметили ошибка пропала.
Давайте проверим получившиеся запросы и мутации.
Запрос
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 {
id
title
text
createdDate
dueDate
category {
id
name
}
}
categories {
id
name
}
}
Результат:
Мутация
mutation ($todoId: ID) {
toggleTodo(todoId: $todoId) {
id
title
text
createdDate
dueDate
category {
id
name
}
done
}
}
Переменные
{
"todoId": "1"
}
Результат:
Мутация
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:
Перенос конфигурационных файлов
Как я говорил ранее, фронтенд и бэкенд будут находиться у нас в одной директории, поэтому было бы неплохо перенести конфигурационные файлы и зависимости на уровень повыше. Для этого из корневой папки проекта выполните:
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
:
<!-- 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
, там же:
<!-- 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
, и добавим в него немного рыбы:
<!-- 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
# 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
, а вместе с тем формируем цепочку обработчиков запросов.
// 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
с уже знакомым нам содержимым:
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 компоненты:
<!-- 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>
<!-- 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>
<!-- 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.