【Django入門】Django REST framework+ Vue CLIでPOSTでデータ送ってみる

この記事は約31分で読めます。
広告

Django REST framework + Vue CLIがうまくいかないので次の点を明らかにしたいと思い作りました。

  • REST frameworkにPOSTするだけでデータベースにかきこまれるのか?
  • DjangoとVueCLIがうまくいかない原因CORSとCSRFとは何か?

nMoMo’sさんのVue.jsとDjangoでサクッとCRUDを利用させていただきます。
前も利用させていただきました。

上はCDNをindex.htmlに読み込んでVue.jsを使っています。
今回はDjango側は、そのまま使わせていただきVueCLIで簡単なVue側を作りました。当初は完全にVueCLIのみで作ろうと思ったのですが、やってるうちにCors、CSRFの問題でつまづきました。具体的にはVueCLIのPOST部分で上記の問題でつまずきます。
その結果できるだけシンプルなもので、この辺が理解できるもを作ることに変更し今回記事にしました。私の場合、自分のパソコンで仮想環境をつくるのではなくWEBの開発環境PaizaCloudを使用しているため、よりこの問題にひっかかるのだと思います。
PaizaCloud使用を前提で書いてますが自分のパソコンで仮想環境作ってる人にも参考になると思うのでお読みください。
完成品

広告

PaizaCloudで動かす

PaizaCloudを使うとパソコンに開発環境を構築する必要がありません。
PaizaCloudのやり方はこちらの記事を参考にしてください。

nMoMo’sさんのVue.jsとDjangoでサクッとCRUDのコードをPaizaCloudで動かしてみます。
上記記事の1番下にGitHubへのリンクがあります。
それをクリックし緑色のCodeをクリック
https://github.com/~.gitというコードをコピーします。
PaizaCloudのターミナルで

git clone 今コピーしたhttps://github.com/~.git
cd vuejs-django-crud

vues_crud/settings.pyを変更
DEBUG = True
ALLOWED_HOSTS = []→ALLOWED_HOSTS = ['*']へ変更して保存

Cors対策で以下のようにします。(Corsについては後程説明します。)本番では使わないか十分注意して使いましょう。

settings.pyを変更
INSTALLED_APPS = [
・・・・
'corsheaders',#追加

MIDDLEWARE = [
・・・・
'corsheaders.middleware.CorsMiddleware',#追加
]
CORS_ORIGIN_ALLOW_ALL =True #追加 どこからでも受け入れるので危険な設定
# CORS_ORIGIN_WHITELIST = [ #個別に許可する場合はこっちを使う
#     'http://***.jp', 
# ]
# レスポンスを公開する
CORS_ALLOW_CREDENTIALS = True #追加
ターミナルで
pip install djangorestframework
pip install django-cors-headers
python manage.py migrate
python manage.py runserver
左の8000をクリックするとサーバーが立ち上がります。
アドレスバーに
 https://localhost-人によって違う部分.paiza-user-free.cloud:8000/article
入力してEnterで動きます。

ターミナルでCTRL+Cでサーバーが停止します。

目次へ

REST frameworkにPOSTするときの通信を見てみる

ここでサーバーを起動させ
https://localhost-人によって違う部分.paiza-user-free.cloud:8000/api/articleにアクセス。
下の方にPOSTできるところがあります。Article_headingとArticle_bodyに適当に入力してPOSTを押すと投稿(データベースに登録)できます。
このときの通信内容を以下のようにコピーするとfetchとして使えるコードがコピーできます。fetchとはJavascriptでサーバーにHTTP リクエストを送信できるコードです。なのでこのまま貼り付ければ同じHTTPリクエストが出ます。

fetch("https://localhost-人によって違う.paiza-user-free.cloud:8000/api/article/", {
  "headers": {
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
    "accept-language": "ja,en;q=0.9,en-GB;q=0.8,en-US;q=0.7",
    "cache-control": "max-age=0",
    "content-type": "multipart/form-data; boundary=----WebKitFormBoundaryhmNZdkxLmKvWiSzQ",
    "sec-ch-ua": "\"Chromium\";v=\"104\", \" Not A;Brand\";v=\"99\", \"Microsoft Edge\";v=\"104\"",
    "sec-ch-ua-mobile": "?0",
    "sec-ch-ua-platform": "\"Windows\"",
    "sec-fetch-dest": "document",
    "sec-fetch-mode": "navigate",
    "sec-fetch-site": "same-origin",
    "sec-fetch-user": "?1",
    "upgrade-insecure-requests": "1"
  },
  "referrer": "https://localhost-人によって違う.paiza-user-free.cloud:8000/api/article/",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": "------WebKitFormBoundaryhmNZdkxLmKvWiSzQ\r\nContent-Disposition: form-data; name=\"csrfmiddlewaretoken\"\r\n\r\nnDz07Ce略GRXXyfcYS\r\n------WebKitFormBoundaryhmNZdkxLmKvWiSzQ\r\nContent-Disposition: form-data; name=\"article_heading\"\r\n\r\nテスト\r\n------WebKitFormBoundaryhmNZdkxLmKvWiSzQ\r\nContent-Disposition: form-data; name=\"article_body\"\r\n\r\n投稿してみる\r\n------WebKitFormBoundaryhmNZdkxLmKvWiSzQ--\r\n",
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
});

これを元にあとで紹介するコードを作りました。fetchはfetch(宛先url,{HTTPリクエストの内容})の形で使います。body部分が複雑になってますが、もっと簡潔な記述で行けました。またbodyの中にcsrfmiddlewaretokenのあとに乱数があります。これは送信するときに付けなければいけない値です。
しかし今回作ったものは、これなくてもエラーになりませんでした。なぜかは下で検討しましたが分かりませんでした。
目次へ

CSRF

CSRFとは私の理解ではアプリが送ってきた入力フォームが正規のものであることを証明するための機能だと考えています。具体的には乱数を入力フォームに埋め込んでおいて、その乱数を入力フォーム値と一緒に送る。間違っていたりなかったらCSRFエラーとする。こちらが参考になります。https://djangobrothers.com/blogs/django_csrf/

さっきのDjango RestframeworkのAPI画面の最初のほうの通信のGETを検証ツールで見てみます。具体的には該当する名前をクリックして回答のタブをクリックすると以下のように送られてきたソースが見れます。

一部のみ 
<form action="/api/article/" method="POST" enctype="multipart/form-data" class="form-horizontal" novalidate>
                            <fieldset>
                              <input type="hidden" name="csrfmiddlewaretoken" value="GnHOa4t53wCUJcYxJaE略c1bp5j9wB08T0869">
                              
これは通常Djangoでアプリをつくるときと同じでformタグの下に{% csrf_token %}を書くと最終的なタグは上のようなhiddenの形になっています。
formタグを使うときは{% csrf_token %}を入れないと実行した段階でエラーになります。
しかし今回作ったformを使わずにfetchでデータを送信するとcsrfmiddlewaretokenがないのにエラーになりませんでした。つまり自己責任でCSRF対策してくださいということだと思います。

またcsrfトークンは2種類あるようです。こちらhttps://kikuichige.com/11885/を参考にしてください。この実験ではfetchでもCSRFエラーになってます。今回作ったものはなぜかエラーになりません?

出来上がったアプリに投稿して要求ヘッダ見るとCookie: csrftoken=略があります。これはcsrfmiddlewaretokenとは別物のようです。またcookie自体送ってないときもあります。つまりこのCookie: csrftokenは機能してないように見えます。さらにbody(ペイロード)の中にはcsrfmiddlewaretokenはありません。そもそもcsrfmiddlewaretokenの値が来てないと思います。settings.pyのミドルウェアdjango.middleware.csrf.CsrfViewMiddlewareも有効な状態です。でもエラーにはなりません。

結局この辺クリアできてないので今回作った方法は本番での使用は避けたほうがいいかも。
APIの画面と同じformを使ったほうが無難だと思います。VueでPOSTも作ると、この部分はどうなっているか気になります。
その後、axiosを使った別サンプルでうまくいきました、下の記事になります。
また今回の記事もAxios版として1番下に追記しました。

VueCLIでフロント側作成

VueCLIで簡単なフロント側作ります。最終的にはビルドしてできたindex.htmlとjsとcssの静的ファイルをDjangoのテンプレートとstaticで使用します。その際POSTの部分は改めてindex.htmlで作ります。
大元のルートにプロジェクトを作ります。(GitHubを使用するとき、たくさんファイルを入れたくないので。)

Vueプロジェクト作成

cdで大元に戻ります。または別のターミナルを立ち上げます。
npm install -g @vue/cli
vue create vue-toukou
デフォルトのままVue3を選んでEnter
最後はuse NPMに↓で選んでEnter

vue.config.jsにPaizaCloud対策する。これをやらないとInvalid Host headerというエラーになります。(~allowedHosts: "all",~の部分、注意、ビルド前には消した方がいい)
またビルドしたとき相対パスで出力するようにpublicPathの設定もしておきます。
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true
})
module.exports = {
  devServer: {
    allowedHosts: "all",
  },
  publicPath: './',
}
ディレクトリを移動して実行して動作確認します。
cd vue-toukou
npm run serve

目次へ

フロント側作成

デフォルトのものを少し改造するだけにしておきます。
src/App.vueのVueのロゴ部分<img alt="Vue logo" src="./assets/logo.png">を削除

HelloWorld.vueを以下のように修正します。

そのままでは動きません。赤色の部分はそれぞれ変更してください。
fetch('https://localhost-人それぞれ違うサーバー名.paiza-user-free.cloud:8000/api/article/',{credentials:'include'})
先ほどコピーしたPOSTのfetchはいろんなものが書いてありました。今はGETです。ほとんどデフォルト値でいけるので、このように短くなっています。
{credentials:'include'}はPaizaCloudにログイン状態であることを知らせるためにクッキーを付けています。これがないとエラーになります。またデフォルトのsame-originではVue側のポート(8080)とDjango側のポート(8000)で異なるためクロスオリジンになりクッキーがつきません。
credentialsの詳細はhttps://developer.mozilla.org/ja/docs/Web/API/Request/credentials
<template>
  <div class="hello">
<div id="starting">
  <div class="container">
    <div class="row">
      <h1>投稿一覧</h1>
      <table class="table">
        <thead>
          <tr>
            <th scope="col">ID</th>
            <th scope="col">タイトル</th>|
            <th scope="col">本文</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(article, index) in articles" :key=index>
            <th scope="row">{{article.article_id}}</th>
            <td>{{article.article_heading}}</td>|
            <td>{{article.article_body}}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
  
</div>      
      

  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
      
    data() {
        return {
            
            articles: [],
            loading: false,
            currentArticle: {},
            message: null,
           newArticle: {'article_heading': null, 'article_body': null },
        };
    },
    mounted() {
         this.getArticles();
    },
    methods: {
         getArticles() {
            
            this.loading = true;
            fetch('https://localhost-人それぞれ違うサーバー名.paiza-user-free.cloud:8000/api/article/',{credentials:'include'})
                .then(response => {
                    return response.json()
                  })
                  .then(data => {
                this.test=data,
                this.articles=data
                 })

          .catch((err) => {
           this.loading = false;
           console.log(err);
          })
         }
     },
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>
Django側をpython manage.py runserver
Vue側をnpm run serveで動かすと先ほど投稿したものが表示されます。

目次へ

Cors

入力フォーム(名前とか入力するフォーム)が1番イメージしやすいと思いますので入力フォームを送る(POST)場合で説明します。入力フォームが送られてきたサーバー(ポート番号)と違うところにデータを送信する場合にCORSエラーが発生します。
今回の場合https://localhost-人それぞれ違うサーバー名.paiza-user-free.cloud:8080のVueで作った入力フォームに書き込んだデータをhttps://localhost-人それぞれ違うサーバー名.paiza-user-free.cloud:8000に送ろうとするとCORSエラーになる。データを取りに行くだけのGETでも出ます。
このエラーを回避するためにDjangoではpip install django-cors-headersしてsettings.pyを数か所追加修正すれば、このエラーを無視することができます。

VueCLIでGETはうまくいくのですがPOSTのときうまくいきませんでした。原因はプリフライトがうまくいってません。プリフライトとはPOSTする前に送るリクエストのことです。プリフライトが発生するには条件があります。単純リクエストに該当する場合はプリフライトはないのですがAPIでPOSTする場合どうしてもcontent-type: application/JSONでなければならず、これが単純リクエストの条件に入っていません。
またdjango-cors-headersを使用すれば多分プリフライトもうまくいくはずと思うのですがうまくいきません。

具体的にはプリフライトのDjango側からの応答にAccess-Control-Allow-Origin:の項目が返ってくるはずなのに返ってきません。 公式っぽいところをみると
django-cors-headers 3.13.0
Requirements
Python 3.7 to 3.11 supported.
Django 3.2 to 4.1 supported.とありPaizaCloudでは、DjangoはできるのですがPythonのバージョンアップが無理でした。これが原因かは分かりません。とりあえず色々やってもだめだったので別の方法を取りました。

CorsはこのYoutubeが分かりやすいです。CORSの原理を知って正しく使おう
目次へ

ビルドしてindex.htmlと静的ファイルをDjango側へ

POSTはDjango側でindex.htmlに追加します。
ここからは同一オリジンになるのでCors対策は不要。(8080は使わない8000の1本になる)なのでsettings.pyのCors対策部分は削除しても大丈夫です。

VueをCTRL+Cで停止し
npm run build
ビルドするとvue-toukou/distディレクトリにhtmlとjs、cssファイルができています。mapは不要。jsとcssのファイル名はビルドするたびに変更されるので都度以下の作業で注意してください。
dist/index.htmlをDjangoのarticle/templatesに移動
dist/cssとjsにあるそれぞれのファイルをDjango側にarticle/static/toukou/cssとjsディレクトリを作りそれぞれ移動*.mapは不要。

index.htmlを以下のように変更(メモ帳にコピペして編集するとやりやすいです)

冒頭に{% load static %}
jsファイルやcssファイルを参照しているところを{% static 'jsファイル' %}で囲む。

fetchは上でコピーしたfetchをベースにしてる。デフォルトでよさそうな部分削除してます。
bodyにcsrfmiddlewaretokenはなく単純なJSON形式のデータのみ。
DLETEはPOSTをベースにMethodsをDELETEに変えbodyは削除。
{% load static %}<!doctype html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="favicon.ico"><title>vue-toukou</title><script defer="defer" src="{% static 'toukou/js/chunk-vendors.8a7b031a.js' %}"></script><script defer="defer" src="{% static 'toukou/js/app.63c511da.js' %}"></script><link href="{% static 'toukou/css/app.f999ea50.css' %}" rel="stylesheet"></head><body><noscript><strong>We're sorry but vue-toukou doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript>
<h1>RestFramwWorksにPOSTでJSONデータを送信する</h1>
    <p>article_heading: <input type="text" id="article_heading" size="30" value="2個目"></p>
    <p>article_body: <input type="text" id="article_body" size="30" value="POSTできた"></p>
    <p><input type="button" value="送信" class="btn"> </p>
    <p>削除article_id: <input type="text" id="article_id" size="30"></p>
    <p><input type="button" value="削除" class="btndel"> </p>
 <script>

	const btn = document.querySelector('.btn');
	const btndel = document.querySelector('.btndel');
	const url = 'https://localhost-人によって違う-1.paiza-user-free.cloud:8000/';


	const postFetch = () => {
            	
            	var data2 = document.getElementById("article_heading").value;
            	var data3 = document.getElementById("article_body").value
                var JSONdata = {
                    
                    article_heading: data2,
		            article_body: data3
                };

            


	    fetch("https://localhost-sumoayui-1.paiza-user-free.cloud:8000/api/article/", {
  "headers": {
    "accept": "application/json, text/javascript, */*; q=0.01",
    "accept-language": "ja,en;q=0.9,en-GB;q=0.8,en-US;q=0.7",
    "content-type": "application/JSON"
  },
  "referrer": "https://localhost-sumoayui-1.paiza-user-free.cloud:8000/article",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": JSON.stringify(JSONdata),
  "method": "POST",
  "mode": "cors",
  "credentials": "same-origin"
}).then((response) => {
        	if(!response.ok) {
        	    console.log('error!');
        	} 
             	return response.json();
   	 }).then((data)  => {
	        
	        location.reload();
	    }).catch((error) => {
		console.log('err');
	        console.log(error);
	    });
	};

const deleteFetch = () => {
            	var data1 = document.getElementById("article_id").value;
                var delurl="https://localhost-sumoayui-1.paiza-user-free.cloud:8000/api/article/"+data1+"/";
	    fetch(delurl, {
  "headers": {
    "accept": "application/json, text/javascript, */*; q=0.01",
    "accept-language": "ja,en;q=0.9,en-GB;q=0.8,en-US;q=0.7",
    "content-type": "application/JSON"
  },
  "referrer": "https://localhost-sumoayui-1.paiza-user-free.cloud:8000/article",
  "referrerPolicy": "strict-origin-when-cross-origin",
  
  "method": "DELETE",
  "mode": "cors",
  "credentials": "same-origin"
}).then((response) => {
        	if(!response.ok) {
        	    console.log('error!');
        	}
        	location.reload();
   	 });
	};
	btn.addEventListener('click', postFetch, false);
	btndel.addEventListener('click', deleteFetch, false);
	</script>

<div id="app"></div></body></html>

完成品

どこにPOSTすればデータベースに書き込めるのか?

結局、サーバー/api/article/にGET、POST、DELETEすればデータベースに参照、書き込み、削除ができることが確認できました。この宛先はどこで設定しているか見てみます。

まずurls.pyでapiにきたらimportしたrouterモジュールのurlsを見に行くようになっています。
from .routers import router
path('api/', include(router.urls)),

routers.pyの下の部分でrouterモジュールのurlsとして'article'とデータベースのモデルArticleViewSetが紐づけされていると思われます。
router.register('article', ArticleViewSet)
これでサーバー/api/article/にGET、POST、DELETEを送ればデータモデルArticleViewSetの操作ができるようです。

追記、Axiosでうまくいった。

fetchではなくAxiosでやったらVue側でPOSTやDelete作っても、うまくいきました。
ただし、どうしてもオリジンが違うとPOSTやDELETEでプリフライトが発生してうまくいかないのは対策できません。
なのでビルドしてDjangoに入れないとダメなのは変わっていません。

Axiosのインストールが必要です。
npm install axios --save
<template>
  <div class="hello">
<div id="starting">
  <div class="container">
    <div class="row">
      <h1>投稿一覧</h1>
      <p>タイトル: (article_heading) <input v-model="new_article_heading"><br></p>
      <p>本文: (article_body) <input v-model="new_article_body"><br></p>
      <button v-on:click="createNewUser">投稿</button>
      <p>削除article_id:  <input v-model="del_article_id"><button v-on:click="deleteid">削除</button></p>
      <table class="table">
        <thead>
          <tr>
            <th scope="col">ID</th>
            <th scope="col">タイトル</th>|
            <th scope="col">本文</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(article, index) in articles" :key=index>
            <th scope="row">{{article.article_id}}</th>
            <td>{{article.article_heading}}</td>|
            <td>{{article.article_body}}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
  
</div>      
      

  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'HelloWorld',
      
    data() {
        return {
            new_article_heading:'',
            new_article_body:'',
            del_article_id:'',
            articles: [],
            loading: false,
            currentArticle: {},
            message: null,
           newArticle: {'article_heading': null, 'article_body': null },
        };
    },
    mounted() {
        axios.defaults.xsrfCookieName = 'csrftoken' ;// ←ココと
        axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"; // ←ココに追加しました
         this.getArticles();
    },
    methods: {
         getArticles() {
            
            this.loading = true;
            axios.get('https://localhost-人それぞれ違うサーバー名.paiza-user-free.cloud:8000/api/article/',{
  withCredentials: true
})
                .then(response => {
                    this.articles=response.data
                  })
          .catch((err) => {
           this.loading = false;
           console.log(err);
          })
         },
         createNewUser: function(){
             axios.post('https://localhost-人それぞれ違うサーバー名.paiza-user-free.cloud:8000/api/article/',{
                 "article_heading": this.new_article_heading,
                 "article_body": this.new_article_body
         
                 },{
                      withCredentials: true
                    })
                  .then(location.reload())
                  .catch(error => console.log(error))     
            },
        deleteid: function(){
            var delurl="https://localhost-人それぞれ違うサーバー名-1.paiza-user-free.cloud:8000/api/article/"+this.del_article_id+"/";
             axios.delete(delurl,{
                      withCredentials: true
                    })
                  .then(location.reload())
                  .catch(error => console.log(error))     
            }            
     },
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

最終的に(Django側に入れたとき)はwithCredentials: trueは削除、DjangoのCors対策も削除してます。
完成品(Axios版)見た感じは違いは分かりません。

あとがき

やってないけど<form action="サーバー名" method="POST">{% csrf_token %}の形でうまくいけば、そのほうがCSRF対策は楽。今回のfetchの方法はCSRF対策不明です。

今回フロント側をVueCLIで完全に作ることはできなかった。しかし壁に当たったとき色々切り分けて考えるヒントになればと思います。

この記事を書いたイチゲを応援する
Vプリカでのお支払いがおすすめです。

MENTAやってます(ichige)

目次へ

コメント

タイトルとURLをコピーしました