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

от автора

Предисловие

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.

ссылка на оригинал статьи https://habr.com/ru/post/492486/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *