アプリ開発ナレッジ

アプリ開発のナレッジを掲載します

バックエンドのテストでSQLインジェクションのテストケースとかって書くべきなのだろうか

はじめに

どうもテストに弱いエンジニアです
特に自分はフロントエンドなので、バックエンドはテンでからっきし分けわかめだったりしますが、昨今の(出来る)エンジニア不足も相まって、お仕事でバックエンドの作業もやることになってきていて、テストまで作ることになっちゃっているのですが、テストケース何書いたらいいの問題発生しています

そして特に表題の件ですね
プラグインを使ってSQLの自動生成しているAPIと生でSQLを書いているAPIがあるんですが、後者には特にSQLインジェクションのテストケース作った方がいいのかなと思ったりしておりますね
フレームワーク使ってるからそこは当然のごとくサニタイズされてるはずだから大丈夫だよで逃げていいのやら

とりあえずまあテストケースくらいパパっと書けるので書いていますが、どういうテストケース作るのが一般的なんでしょうね
この辺のさじ加減が全くわけわかめでござんす

pythonお勉強Tips③ - グラフ型あれこれ

はじめに

マッチングアプリの実装に使うと何らかの工夫ができそうなグラフ型のDBや分析ツールを見てみる


グラフ型DB neo4j

neo4jというデータベースがグラフ型のデータベースになっている
人気な異性が誰から好かれているかなどが可視化できそう
neo4j.com


NetworkX

ネットワーク図を作ることのできるツール

networkx.org

チュートリアルがある

networkx.org


こんな感じのグラフが描画できる

networkx.org


電車の地図とか作れるっぽい

Pythonお勉強Tips② - Email

実装コード基本形

MSのホットメールを使う
なおgit管理する場合は個人情報はコンフィグファイルに分けてGitignoreするのを忘れずに

from email import message
import smtplib

# メール設定準備
smtp_host = 'smtp.live.com'
smtp_port = 587
from_email = [YOUR_EMAIL]
to_email = [TO_EMAIL]
user_name = [YOUR_USERNAME or YOUR_EMAIL]
password = [PASSWORD]

# メール設定
msg = message.EmailMessage()
msg.set_content('メール本文')
msg['Subject'] = 'メール題名'
msg['From'] = from_email
msg['To'] = to_email

# コネクション
server = smtplib.SMTP(smtp_host, smtp_port)
server.ehlo()
server.starttls()
server.ehlo()
server.login(username, password)
server.send_message(msg)
server.quit()

これが一連の流れ

添付ファイルをつける

from email.mime import multipart
from email.mime import text

# メール設定準備
上記踏襲

# メール設定
上記踏襲

msg.attach(text.MIMEText('test email', 'plain'))

with open('filename.py', 'r') as f:
  att = text.MIMEText(f.read(), 'plain')
  att.add_header(
    'Content-Disposition',
    'attachment',
    filename='filename.txt'
  )
  msg.attach(att)

# コネクション
同じ

メールでログ内容を送る

基本的にはログ分析ツールを利用して送る方式が一般的
コードでゴリゴリ実装する場合は、SMTPHandlerを利用する

Pythonお勉強Tips① - ロギング

ログレベルの出力設定が以下の記述でできる

import logging

logging.basicConfig(level=logging.INFO)

logging.info('ログ情報出力')

ログレベルは以下の順で、何も記述しないとWARNINGから出力される
CRITICAL
ERROR
WARNING
INFO
DEBUG

フォーマッタもつけることができる

formatter = '%(levelname)s:%(asctime)s:%(message)s'
logging.basicConfig(level=logging.INFO, message)

logging.info('ログ情報出力')

こうすると、「INFO:20201212123456;ログ出力情報」みたいな形でログを出力させられる
詳しくはここ参照

logging --- Python 用ロギング機能 — Python 3.10.6 ドキュメント

通常のアプリ開発のロギングはログは上記の設定を行った後、loggerを使ってログを残すようにする

題名通り
logging --- Python 用ロギング機能 — Python 3.10.6 ドキュメント

基本形

logger.getLogger(__name__)
logger.info('ログ情報出力')

ハンドラーを使ってファイル分けとかEmailイベントとかの使い分けをする

題名通り
ドキュメントはこちら
logging.handlers --- ロギングハンドラ — Python 3.10.6 ドキュメント

含めてはいけないキーワードをログに残さないようにフィルタリングする

パスワードをログに残すと大変なのでフィルタリングしたいときはこういう風に実装する

class NoPasswordFilter(logging.Filter) {
  def filter(self, record):
    log_message = record.getMessage()
    return 'password' not in log_message
}

logger.addFilter(NoPasswordFilter())

passwordという文字列が含まれていたら出力されなくなる

ログ出力はBIツール分析を見越してキーバリュー形式で書く

ログは障害時など分析するためにキーバリュー形式にするのが一般的
例えば以下のような書き方

logger.error({
  'action': 'create',
  'status': 'fail',
  'message': 'Api call is failed'
})

ログのコードを書く勘所

ログは出力させる場所の勘所が必要
例えば、処理の後の成功ログだけではなく、処理の実行前の実行開始ログを書いておくなどの工夫が地味に障害時対応に役立つ(どこでエラーが起きているかを特定しやすくなる)

学習記録Rust概要

はじめに

Rustの勉強記録です
この記事では概要のみ記載して、その後の勉強を進められるサイトの情報を記載しておきます
ほぼ個人のメモみたいな記事です

Rustとは

簡単に言うとC言語の上位種
ハードウェアよりの低レイアのメモリ上のデータを扱える言語で、コンパイルして機械語を生成し、C言語と同水準の高速処理が可能
C/C++言語ではメモリ操作における致命的なバグが発生しやすい文法となっていたが、Rustではバグが発生しにくい設計になっておりメモリ安全性が担保されている
ただし、学習が難しいが、学習難易度は高いため一度学習しておくと一目置かれるスキルセットとなり、技術的に廃れる速度が遅く、さらに今はGoogleAndroidの基盤言語(Linuxカーネル)として採用されたりMicroSoftでも書き換えがあるなど需要があり、技術セットを持っていると高い給料がもらえる可能性がある

Rustの特徴

  • 静的型付け言語
  • メモリ制御実装

Javaのようなガベージコレクションを使用しないため、Rustでは実装者が明示的にメモリ上のデータを確保したり解放したり適切に制御をする必要がある
バグは発生しやすいが高速になる
ガベージコレクション:使用されなくなったメモリを自動的に開放してくれる機能

  • メモリ制御の所有権モデル

C/C++では手動でメモリ管理していたが、重大なセキュリティ脆弱性が多数発生したが、Rustの所有権モデルではコンパイルが通る限りはメモリ安全性が担保されて、人為的ミスが発生しない

Rustの特徴を一言でいうとバグが少なく高速な処理を実現できる言語
※処理速度の速さとしてはPythonRubyなどの処理の遅い言語と比べて30倍速い

Rustの活用領域

  • Webアプリのバックエンド actix-web
  • WebAssembly(WASM)

ブラウザで実行可能なバイナリコードを作成することができて、JavaScriptからRustの処理を呼び出すことができる

  • OS開発
  • 組み込み機器

Rust勉強サイト

初めてRustに触れる人が取り組むことをお勧めされているサイトが以下です
今後勉強する場合はこちらを参照します
The Rust Programming Language 日本語版 - The Rust Programming Language 日本語版

Rustのパッケージサイト

Rustで使えるライブラリがまとめられたサイトはこちらです
https://crates.io/


おわりに

ちなみにC言語命名的にはC→C++C#の流れでRustになったのはなんでなんでしょうね

Three.js 学習記録 基本的な使い方

はじめに

Three.jsの学習を記録していきます
Three.jsを使いこなしてゆくゆくは3次元的なサービスを提供できるフロントエンジニアを目指します

ビルドツールとThree.jsの導入

Viteを利用します
ja.vitejs.dev

自分はユーザー名に半角スペースを含むため、yarnではコマンドが失敗するためnpmでインストールします
ユーザー名の半角スペースでつまずく場合の参考:https://qiita.com/koseidaiki/items/d2be30379a7f05c37f62

 npm create vite@latest [プロジェクト名]

純粋なJSで開発する場合はvanilla
vueやreactで開発する場合はそちらを選択します
以下では純粋にJavaScriptで開発する方法で基本的な使い方を記載します

その後基本準備として

その後プロジェクトフォルダに移動して、

npm install three

package.jsonの内容をインストールする

npm install

Three.jsパッケージ使い方基本

JSで開発する場合にどうやって使うかですが、JSファイルに以下のモジュールをインポートして使います

import * as THREE from "three";

Three.js必要三要素と基本的な使い方

1. シーン 2. カメラ 3. レンダー

以下の3つの要素が必要であり、基本形は以下のようになる
1. シーン
2. カメラ
3. レンダー

main.js

import * as THREE from "three"; 

let scene, camera, renderer;

window.addEventListener("load", init);

function init() {
    // シーン
    scene = new THREE.Scene();
    // カメラ(視野角、アスペクト比、開始距離、終了距離)
    camera = new THREE.PerspectiveCamera(
        50,
        window.innerWidth / window.innerHeight, // ブラウザの横幅 / ブラウザの縦幅
        0.1,
        1000
    );
    camera.position.set(0, 0, 500)
    // レンダラ
    renderer = new THREE.WebGLRenderer({alpha: true}); // 背景を設定する場合は透過度を設定する
    renderer.setSize(window.innerWidth, window.innerHeight)
    document.body.appendChild(renderer.domElement);
    renderer.render(scene, camera);
} 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ThreeJs</title>
</head>
<body>
    <script src="main.js" type="module"></script>
</body>
</html>

4 テクスチャ 5. ジオメトリ 6. マテリアル 7. メッシュ

上記では物体が表示されないので物体を配置したい場合はテクスチャ、ジオメトリ、マテリアル、メッシュを設定する
基本形は以下

main.js

    // テクスチャ = 柄 (https://threejs.org/docs/index.html?q=texture#api/en/loaders/TextureLoader)
    let textures = new THREE.TextureLoader().load("画像のPath")
    // ジオメトリ = 骨格 (https://threejs.org/docs/index.html?q=geometry#api/en/geometries/SphereGeometry)
    let ballGeometry = new THREE.SphereGeometry(100, 64, 32) // 例:球形
    // マテリアル = 材質 (https://threejs.org/docs/index.html?q=material#api/en/materials/MeshPhysicalMaterial)
    let ballMaterial = new THREE.MeshPhysicalMaterial({
        map: textures // テクスチャを設定
    }); 
    // メッシュ = ジオメトリ + マテリアル
    let ballMesh = new THREE.Mesh(ballGeometry, ballMaterial)
    scene.add(ballMesh) // 最後にシーンに追加する

    // 平行光源を設定することで物体を視認することができる
    let directionalLight = new THREE.DirectionalLight(0xffffff, 2);
    directionalLight.position.set(1,1,1)
    scene.add(directionalLight)  // 最後にシーンに追加する

8. アニメーション

また、基本的に動きのある画面を作りたいはずなので、アニメーションを使う
以下のようにアニメーション関数を作って、その中にレンダリングを入れて、変更のたびに再レンダリングをするような実装を行う


main.js

function animate() {
  // おまじない
	requestAnimationFrame( animate );
        // 動きのロジック - 例えばメッシュ(物体)の位置を変更するアニメーションをつけるなど
        // ballMesh.position.x += 時間に応じて加算
        // レンダラをこちらに入れる
	renderer.render( scene, camera );
}
// アニメーションを実行
animate();


ところで、これらにはバリエーションがありそのユースケースは以下のようになる

1. シーンについて

シーンについては基本的にバリエーションはなく思考停止で

scene = new THREE.Scene();

を使用すればよい
https://threejs.org/docs/index.html#api/en/scenes/Scene

2. カメラについて

カメラについては2つほどバリエーションがある
自分のアプリをどのように見せたいかを考えて適切なカメラを選択するとよい

1. PerspectiveCamera
遠近法(遠くのものは小さく)が適用される一番直観的な普通のカメラ
ユースケースは一番多いと思われる
https://threejs.org/docs/index.html#api/en/cameras/PerspectiveCamera


2. OrthographicCamera
遠近法が適用されないカメラ
遠くのものも近くのものも同じ大きさに見える
https://threejs.org/docs/index.html#api/en/cameras/OrthographicCamera

他にはStereoCameraがあって、立体的にみられるカメラもある模様
VRバイスが必須

2.1. カメラや物体のコントロールについて

カメラや物体のコントロール制御をできる簡便な機能がある

基本的には一番直観的なOrbitControlsを使っておけばよい
https://threejs.org/docs/index.html?q=Controls#examples/en/controls/OrbitControls

他一人称視点でコントロールしたいときは、FirstPersonControlsがある
https://threejs.org/docs/index.html?q=Controls#examples/en/controls/FirstPersonControls

2.2 光源について

Three.jsでは光源を必要とするマテリアル(MeshPhysicalMaterial)と必要としないマテリアル(MeshBasicMaterial、MeshNormalMaterial)があり、光源を必要とするマテリアルを使う場合に光源を設定する必要がある

光源にも種類があり、およそ4パターンほどある


1. 全体光源 AmbientLight
全方向から均一の光が当たる光源です
ちなみにThree.jsでは同じ物体の表裏の影はシミュレーションで表現されるが、他の物体に光がさえぎられても影ができることは基本的になかったり、他の物体から反射する光などはシミュレーションされていないので、物体の裏側の影の強さを表現することに使ったりすることができます
https://threejs.org/docs/index.html?q=Light#api/en/lights/AmbientLight


2. 平行光源 DirectionalLight
名が体を表している光源です
https://threejs.org/docs/index.html?q=Light#api/en/lights/DirectionalLight


3. 半球光源 HemisphereLight
2つの平行光源が互いに逆方向からやってくる光源です色を変えるなどができます
ユースケースは空と大地の対比を表現したいときに便利です
https://threejs.org/docs/index.html?q=Light#api/en/lights/HemisphereLight


4. 点光源 PointLight
とある1点から拡散する光源です
豆電球のイメージです
https://threejs.org/docs/index.html?q=Light#api/en/lights/PointLight


5. スポットライト光源 SpotLight
スポットライトや懐中電灯の光源のイメージです
https://threejs.org/docs/index.html?q=Light#api/en/lights/SpotLight


6. RectAreaLight
スタジオにおいてある四角形に広がる光源です
THREE デフォルトにないパッケージであることに注意が必要です
https://threejs.org/docs/index.html?q=Light#api/en/lights/RectAreaLight

3. レンダーについて

こちらもあまりバリエーションは考えなくていい
WebGLRendererを使う
https://threejs.org/docs/index.html#api/en/renderers/WebGLRenderer

4 テクスチャ

テクスチャーは画像を用意して物体に貼るというユースケースが最も多いと思われる
その場合は思考停止でTextureLoaderを使えばよい
https://threejs.org/docs/index.html?q=texture#api/en/loaders/TextureLoader

5. ジオメトリ

ジオメトリがそもそも何なのかというと物体の表面の任意の頂点を結んだ格子上の骨格のことです
不規則な形をとる物体の場合、任意の頂点の数が多ければ多いほど再現度が高くなります
フラーレンのイメージです

ジオメトリは種類が多く、こちらから使いたい出来合いのジオメトリを使うことが便利です
https://threejs.org/docs/index.html?q=geometry

よく使うものとしてはBoxGeometry, PlaneGeometry, SphereGeometry, TorusGeometry あたりだと思います
任意の形を自作する場合はBufferGeometryのベーシッククラスを継承して作成するか他の3Dソフトで作成したものをインポートする必要がある

6. マテリアル

マテリアルは物体の材質に相当するものです
大きく分けて光源を必要とするものと光源を必要としないものに分かれます
光源を必要としないものはMeshBasicMaterialとMeshNormalMaterialがあります
光源を必要とするものにはMeshPhysicalMaterialがあります
他のパラメータとしては金属光沢や粗さ、色などがあります
パラメータが多くデザインの範囲なのでUIデバッグをつけると便利です
https://threejs.org/docs/index.html?q=material

7. メッシュ

フラーレンに皮をつければサッカーボールになるイメージです
メッシュも基本的には組み立て作業に相当するためバリエーションは特にありません
思考停止でMeshオブジェクトを生成します
これが物体に相当します
注意点としてはこれを作った後にシーンに追加することを忘れないようにします

const box = new THREE.Mesh(
  new THREE.BoxGeometry(1,1,1), 
  new THREE.MeshNormalMaterial()
)
scene.add(box) // これを忘れない

UIデバッグ

UIデバッグというのはわざわざコードに戻らなくても画面上でパラメータの変更ができる機能のことです
この機能を使うと画面右上にパラメータをいじれるUIが出てくる

lil-guiを使用する
インストール方法は下記
lil-gui.georgealways.com

インスタンスを生成して、パラメータを追加するとすぐに利用できる(CDNを利用)
sceneにメッシュを追加するのと同じ要領

import GUI from 'https://cdn.jsdelivr.net/npm/lil-gui@0.16/+esm';
const gui = new GUI()

// boxというオブジェクトを作成

gui.add(box.position, "x") // x軸に沿って動かせるようになる
gui.add(box.position, "x", -3, 3, 0.01) // ドラッグで動かせるようになる
gui.add(box.position, "x").min(-3).max(3).step(0.01) // こういう書き方もできる

.nameを使用すると名前を付けることができる

THREE デフォルトにないパッケージを使う

THREE デフォルトにないパッケージを使う場合、jsmフォルダ内にある可能性がある
フォルダパス自体は以下
three.js-r[VERSION]\three.js-r[VERSION]\examples\jsm

光源のhelpers関数などがこちらに入っている場合がある

おわりに

今後は本格的なフロントエンド開発を行っていきたいので、Vue.jsにおけるThree.jsの使い方を調査していこうとおもいます

[追記]
残念ながらVue.js3.0でThree.js開発を行うことは現実的ではない様子
Three.jsをそのまま使う場合、Vueの書き方との相違が多くかなり使いずらいことが分かっている
そのためVueGLというライブラリが用意されているが、それもVue3.0系にはまだ対応しておらず、実質VueでのThree.js開発はあきらめた方が良いことが分かった
最近のフロントエンドはほぼReactによってきつつあるようにも見えるので、まだフロントエンドのどのフレームワークにも染まっていない人はReactを選んでおけば間違いがない気がします(Vue3.0簡単とか言ってるやつ、あれは詐欺です。Vueは書き方がありすぎてもはやブログなどで検索して出てきた情報が全くアテにならなかったりするので、ぶっちゃけ初心者には難しいです。Vueのドキュメントで読んで理解が進められる人はReactでも同じではと思ったりします。サポートされているライブラリもReactの方が多い印象ですし。)

マッチングアプリ個人開発【Flutter画面編】

はじめに

個人開発のスキルアップの教材としてマッチングアプリは非常に良いお手本かと思うので、マッチングアプリとして最低限必要な機能を備えたMVPを作りました
マッチングアプリ業界はすでにレッドオーシャンのように見えますが、マッチングアプリを使用するユーザーの真の目的は理想のパートナーを見つけることにありますが、実際のマッチングアプリ業界に身を投じたユーザーの阿鼻叫喚具合を観察していると目的を達成しているとはいいがたく、筆者視点ではまだまだ開拓の余地がある領域だと思っています
これらの課題はマッチングアプリとしての機能面では解決できないことがもはや明確であるためどのように運用するかが勝敗を決めると思います
ほぼ供給者のアイデア勝負の領域になっていると思われますし、昨今の技術革新でアプリは誰でも簡単に作ることができるようになっています
理想のパートナーを見つけるというのは人類の根源的な欲求の一つでもあり、この崇高な理想を叶えようとする人が技術面の障壁で躓かないように、誰でもマッチングアプリを作れる記事を今回作成しました

想定する読者としてはプログラミングの知識はある程度持っていてアプリ開発については概要レベルしか知識がない人です
いつか自力で何かしらアプリを作りたいけど具体的には何作ろうかなと思っている感じの人には特に参考になるのではないのかなと思っています

こちらの記事はフロントエンド編です
Flutterを使用してモバイルにもWebにも対応したアプリを作ってみようと思います
また、バックエンドには定番のFirebaseを利用せずにDjangoを利用しているのでFirebaseを利用しない場合にFlutterの実装をどうしようか悩んでいる人の参考になれば幸いです

画面の実装をこれから行っていきますのでフルスタックな技術を身に着けたい人はバックエンドの記事も参照してください
前回までのバックエンドの実装記事はこちらです
1.
マッチングアプリを個人開発する~Djangoバックエンドその1 環境構築~ - アプリ開発ナレッジ

2.
マッチングアプリを個人開発する~Djangoバックエンドその2 モデル作成~ - アプリ開発ナレッジ

3.
マッチングアプリを個人開発する~Djangoバックエンドその3 REST API作成~ - アプリ開発ナレッジ

4.
マッチングアプリを個人開発する~Djangoバックエンドその4 動作確認~ - アプリ開発ナレッジ

5,
マッチングアプリを個人開発する~Djangoバックエンドその5 決済機能実装~ - アプリ開発ナレッジ


また、実装済のコードはこちらです
実装しながら記事を書いていたので、ところどころ食い違いがあります
記事どおりに進めて詰まった時はこちらのリポジトリのコードを参照してください
GitHub - orekyo/matchingapp-oreyome-pub: 公開用リポジトリ


では初めて行きましょう

目次

Flutterとは

Google製のクロスプラットフォーム対応のアプリケーション開発フレームワークです
もう少しかみ砕いていうと1つのコードでiOSアプリとAndroidアプリとWebアプリすべてのアプリを実装することができて最近人気の技術です

Flutter環境構築

Flutterを使用していくので環境を構築してください
ここでは Windows を使用します

フラッターが導入できている前提で話を進めます
Flutterの環境構築に関してはYoutubeなどで構築手順を解説してくれているものがアップロードいたりするのでそちらを参考にするとよいかと思います

開発環境

Flutter プロジェクトを作成する

プロジェクトを新規作成する

プロジェクトを新規作成してください
このブログではmatchingappwebというプロジェクト名にしました

プロジェクト構成のガワを作成する

まずは以下のように空フォルダ・ファイルを作成しましょう

matchingappmobile
┗lib
 ┗models
  ┗message_model.dart
  ┗matching_model.dart
  ┗profile_model.dart
  ┗user_model.dart
 ┗providers
  ┗login_provider.dart
  ┗message_provider.dart
  ┗profiles_provider.dart
 ┗screens
  ┗menu.dart
  ┗login.dart
  ┗signup.dart
  ┗他画面......
 ┗services
 ┗utils
 ┗widgets
┗test
 ┗providers
 ┗screens
 ┗services
 ┗widgets
 ┗他......

modelsフォルダのファイル構成は基本的にDjangoで実装したモデル構成に準じています
providersは状態管理のライブラリで、フロントエンド側で扱うためのデータを一元管理します
screensは画面フォルダです

Flutterでコードを実装していく

さあ軽くジャブを打っていきましょう
さて環境構築を終えて最初のプロジェクトを作成するとカウンターのサンプルプログラムがあるのですが、とりあえずサンプルを実行してみましょう
flutter runのコマンドを打つことで実行することができます
なお、デバイスをインストールしていない方は仮想デバイスADVをインストールしましょう

次に、コードを実行することが出来たら軽くコードを眺めてみましょう
Dart言語(Flutterで扱っている言語のこと=ここではDartとFlutterはほとんど同じ意味で使ってます)を初めて見た人はぎょっとするコードかもしれません
特にJavaScriptのコードを少しいじったことがある人は少し違和感を感じるかもしれません
違和感の正体は、HTMLがないところにあろうかと思います
そうです、このDartという言語はHTMLやCSSを使用しません
このDartという言語は処理と画面を一つの言語で記述することができます
初めてアプリ開発を行う人にとっては何が何だかと思う類の話ですが、初心者としては他の言語を学ぶ必要がないうえに、Flutterだけで通常のアプリで作りたいであろう機能はほとんどカバーされているし、さらに作ったアプリは公開できるプラットフォームの幅も格段に大きくて、応用範囲が広いという点で、Flutterはプログラミング初学者に最もおすすめできるアプリ開発フレームワークだと思われます
ただし、チュートリアルや日本語の記事がまだまだ少ないので、そこが難関ではあるのですが......
今のところはプログラミング初心者は、無料のプログラミング学習サイトでJavaScriptやHTML、CSSを一通り習得してからFlutterを始める方が理解が早いかと思います


さて、サンプルのコードは大変ありがたいのですが、今回はプロバイダーを使用して状態管理をするためこの形を書き替えていきます
Providerとは状態管理のFlutterのライブラリです

状態管理パッケージのProviderを使ってみる

静的 Stateless と 動的 Statefull について

そもそもアプリというものは基本的にはデフォルトでは静的なものであって、あれこれ工夫することによってはじめて動的な処理を施すことができます
ちなみに静的(Stateless)というのはユーザーの操作に対してアプリの挙動が変化しないもののことを差し、動的(Statefull )とはその逆です
具体的には静的アプリはWikiペディアや阿部寛のサイト(阿部寛のホームページ)のようなものを想像すればよいと思いますし、動的アプリは私たちが日常的に使っているサイト全般です(例:Amazon などのECサイト

FlutterアプリはStatefull Widgetを継承することで動的アプリを実装できるようにしておりこれが基本形なのですが、この実装ではアプリの実装が進んでいくにつれて保守性が下がるため、通常はProviderといったデータの状態管理用のライブラリを導入するのが鉄板となっています

Providerの導入

ではProviderを導入していきます
pub.dev

pubspec.yamlファイルにproviderを追記します
なお、yamlファイルはインデントが大事なので、インデントは以下のようになります

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.0.2(最新バージョンを指定)

その後パッケージをインストールします

flutter pub dev
Providerの基本形

そして、pub dev サイトの Example のコードを参考にしながら以下のようなコードを書きます
これがプロバイダーの使用の基本形です
Exampleではプロバイダーの値の取り出しにcontext.read()を使用していますが、以下のコードではConsumerを利用しています
どちらがベストプラクティスかは不明ですが、Consumerを利用する方が画面の再描画を行うのがピンポイントで制御できるため処理速度向上の観点から優れているといわれています
分からない人は後でまた説明します

lib/main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
      MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (_) => UserProvider()),
        ],
        child: const MyApp(),
      ),
  );
}

class UserProvider with ChangeNotifier {
  String _nickname = '';

  String get nickname => _nickname; notifyListeners();

  void recommend() {
    _nickname = 'あなたの最高のお相手Aさんです';
    notifyListeners();
  }
}

class MyApp extends StatelessWidget  {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Menu(),
    );
  }
}

class Menu extends StatelessWidget {
  const Menu({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('メニューページ'),
      ),
      body: Center(
        child: Consumer<UserProvider>(
          builder: (context, provider, _) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Text('さあ最高のマッチングアプリを始めましょう!'),
                Text(provider.nickname),
                ElevatedButton(onPressed: () => provider.recommend(), child: Text('あなたにふさわしいお相手を表示します'))
              ],
            );
          },
        ),
      ),
    );
  }
}

この実装で動的処理(状態管理)を実装することができました

ボタンを押すと新たな文字が表示されます

いい感じですね
今はボタンを押してもプログラミング内部で定義したデータを表示しているだけですが、次はバックエンドからとってきたデータを表示させていってみましょう

この処理が何を行っているかについて

さてバックエンドからデータをとってくるその前に、main.dartの中身、特にProviderの説明を軽くしておきます

まずそもそもアプリが起動するということが何なのかということを考えたいのですが、Flutterで定義されたアプリはFlutter runとかでアプリが実行されると一番最初にmain()が実行されます
↓こいつですね

void main() {
}

このmain()の中でrunApp()が呼び出され、最初にMyAppが呼ばれているという構図です
MyApp はMenuを呼んでおり、メニューページが表示されます
そのため基本的には画面を見ている限りはclass Menu extends StatelessWidget {}の中身を眺めておけばよいことになります
さて、アップバーなどのウィジェットは解説しなくてもなんとなくわかると思います
難しいのはConsumerやprovider.nicknameやElevatedButton(onPressed: () => provider.recommend(), child: Text('あなたにふさわしいお相手を表示します'))の部分だと思います
これらは何をしているかというと、(何となくわかると思いますが)ElevatedButtonを押すとprovider.nicknameの値を画面に表示しています

最初は空文字で定義されてボタンを押すと定義した文字列が表示されるコードはこちらに定義されています

class UserProvider with ChangeNotifier {
  String _nickname = ''; // 初期値

  String get nickname => _nickname; notifyListeners();

  void recommend() { // ボタンが押されたら値がセットされる
    _nickname = 'あなたの最高のお相手Aさんです';
    notifyListeners(); // 値の変更を画面に伝えるメソッドで、プロバイダーにはこれが必要。わからなければおまじない
  }
}

青色のボタンを押して→recommend() が実行され、→nicknameの値が変更されて、変更された値が画面に反映されている流れです
画面に反映する役割は、notifyListeners()とConsumerが担っています

Consumerの基本形

Consumerの基本形はこちらです

Consumer<UserProvider>( // 定義したProviderのクラス名
  builder: (context, provider, _) { // 第二引数のproviderに実際のインスタンスが入る
    return Widget( // WidgetにはColumnなどのお好きなWidgetが利用できる
        // providerを利用する処理がかける
    );
  },
),

アロー関数などが思いっきり使われているので大変理解しがたい形でしょうが、まあ慣れましょう
これでデータの状態管理ができるようになりました

では次にまずはログイン機能を実装してみましょう

ログイン機能の実装

ログインをするためには正しいemailとpasswordを渡しつつログイン認証用APIエンドポイントを呼び出して認証情報を受け取る必要があるので、HttpクライアントとしてはDioを利用して、認証情報はCookieに格納する方針で実装することとします
その他の情報はデータの状態管理モジュールのProviderを利用して実装していきます
まずはmain.dartなどをProviderを利用できる形に書き換えていきます

必要なパッケージインストール

Httpクライアントとして使用するDioCookieを管理するためのパッケージとそのほか必要なパッケージをインストールします

pubspec.yaml

dependencies:
  http: [最新版]
  dio: [最新版]
  cookie_jar: [最新版]
  dio_cookie_manager: [最新版]
  path_provider: [最新版]

main.dartのProviderに対応するための書き換え実装

main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => LoginProvider()),
        ChangeNotifierProvider(create: (_) => ProfileProvider()),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget  {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MenuScreen(),
      routes: <String, WidgetBuilder>{
        '/menu': (BuildContext context) => MenuScreen(),
        '/login': (BuildContext context) => LoginScreen(),
        '/signup': (BuildContext context) => SignupScreen(),
        '/my-profile': (BuildContext context) => MyProfileScreen(),
        '/looking': (BuildContext context) => ProfilesScreen(),
      },
    );
  }
}

メニュー画面の実装

ところでアプリのタイトルを決めていませんでしたが、ここでは俺の嫁探しにしています(ギャルゲみたいとか言わない)

screens/menu.dart

import 'package:flutter/material.dart';

class MenuScreen extends StatelessWidget {
  const MenuScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('俺の嫁探し'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            const Text('さあ最高のマッチングアプリを始めましょう!',),
            const SizedBox(height: 16,),
            TextButton(
              child: const Text('ログイン'),
              onPressed: () {
                Navigator.pushNamed(context, '/login');
              },
            ),
            TextButton(
              child: const Text('新規会員登録'),
              onPressed: () {
                Navigator.pushNamed(context, '/signup');
              },
            ),
          ],
        )
      ),
    );
  }
}

ログイン画面の実装

まずは画面のレイアウトを整えます
Consumerを利用してProviderを利用できるようにしています
またemailにはバリデータをつけています
email_validator | Dart Package


screens/login.dart

import 'package:email_validator/email_validator.dart';
import 'package:flutter/material.dart';

class LoginScreen extends StatelessWidget {
  const LoginScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ログインページ'),
      ),
      body: Consumer2<LoginProvider, ProfileProvider>(
        builder: (context, loginProvider, profileProvider, _) {
          return Center(
            child: Padding(
              padding: const EdgeInsets.all(32.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  TextFormField(
                    onChanged: (value) => loginProvider.email = value,
                    decoration: const InputDecoration(
                      labelText: 'email',
                    ),
                    maxLength: 50,
                  ),
                  Text(loginProvider.message, style: const TextStyle(color: Colors.red),),
                  TextFormField(
                    onChanged: (value) => loginProvider.password = value,
                    decoration: InputDecoration(
                      labelText: 'password',
                      suffixIcon: IconButton(
                          onPressed: () => loginProvider.togglePasswordVisible(),
                          icon: const Icon(Icons.remove_red_eye)
                      ),
                    ),
                    obscureText: loginProvider.hidePassword,
                    maxLength: 50,
                  ),
                  Padding(
                    padding: const EdgeInsets.only(top: 16.0),
                    child: ElevatedButton(
                      child: const Text('ログイン'),
                      style: ElevatedButton.styleFrom(
                        fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32),
                      ),
                      onPressed: () {
                        loginProvider.setMessage('');
                        if( !EmailValidator.validate(loginProvider.email) ) {
                          loginProvider.setMessage('Email形式で入力してください');
                          return ;
                        }
                        loginProvider.auth()
                          .then( (isSuccess) {
                            if (isSuccess) {
                              profileProvider.fetchMyProfile(loginProvider.getUserId())
                                .then((isSuccess) {
                                  if (isSuccess) {
                                    print('プロフィール作成済みユーザーです');
                                    Navigator.pushAndRemoveUntil(
                                      context,
                                      MaterialPageRoute(builder: (context) => const MyProfileScreen()),
                                          (route) => false,
                                    );
                                  }
                                  else {
                                    print('プロフィール未作成ユーザーです');
                                    Navigator.pushAndRemoveUntil(
                                      context,
                                      MaterialPageRoute(builder: (context) => const MyProfileScreen()),
                                          (route) => false,
                                    );
                                  }
                                });
                            }
                          })
                          .catchError((error) => print(error));
                      },
                    ),
                  )
                ],
              ),
            ),
          );
        }
      ),
    );
  }
}

新規ユーザー作成画面の実装

ログインとほぼ同じです
ボタンを呼び出す際に呼び出しているProviderのメソッドが違います

screens/signup.dart

class SignupScreen extends StatelessWidget {
  const SignupScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('サインアップページ'),
      ),
      body: Consumer<LoginProvider>(
          builder: (context, loginProvider, _) {
            return Center(
              child: Padding(
                padding: const EdgeInsets.all(32.0),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    TextFormField(
                      onChanged: (value) => loginProvider.email = value,
                      decoration: const InputDecoration(
                        labelText: 'email',
                      ),
                      maxLength: 50,
                    ),
                    Text(loginProvider.message, style: const TextStyle(color: Colors.red),),
                    TextFormField(
                      onChanged: (value) => loginProvider.password = value,
                      decoration: InputDecoration(
                        labelText: 'password',
                        suffixIcon: IconButton(
                            onPressed: () => loginProvider.togglePasswordVisible(),
                            icon: const Icon(Icons.remove_red_eye)
                        ),
                      ),
                      obscureText: loginProvider.hidePassword,
                      maxLength: 50,
                    ),
                    Padding(
                      padding: const EdgeInsets.only(top: 16.0),
                      child: ElevatedButton(
                        child: const Text('新規ユーザー作成'),
                        style: ElevatedButton.styleFrom(
                          fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32),
                        ),
                        onPressed: () {
                          loginProvider.setMessage('');
                          if( !EmailValidator.validate(loginProvider.email) ) {
                            loginProvider.setMessage('Email形式で入力してください');
                            return ;
                          }
                          loginProvider.signup()
                              .then( (isSuccess) {
                            if (isSuccess) {
                              Navigator.pushAndRemoveUntil(
                                context,
                                MaterialPageRoute(builder: (context) => const LoginScreen()),
                                    (route) => false,
                              );
                            }
                          })
                          .catchError((error) => print(error));
                        },
                      ),
                    )
                  ],
                ),
              ),
            );
          }
      ),
    );
  }
}

LoginProviderの実装

ロジックの部分を実装します
プロバイダーはemailやパスワード情報を保持したり認証処理を行ったりする役割を担っています
togglePasswordVisibleのメソッドは例えば画面上でパスワードの表示非表示を切り替えるためのメソッドとなります
またauthのメソッドはいわゆるログインのメソッドとなっております
ログインの処理の流れとしてはまずはDioを呼び出してDioを使用する準備を整え、ログイン後に取得できるtokenをCookieに格納するための下準備をCookieJarなどを使って準備を行っています
またスマートフォンはストレージのアクセス権などが厳しく明示的にCookieの情報をストレージに書き込めることをpath_provider.dartのパッケージで明示しています(明示しなければストレージは読み取り専用)
そしてsaveFromResponseでクッキーを保存しています

これで認証情報を取得する処理は出来ました
認証情報を利用してAPIを呼び出す場合は直後の処理のようにヘッダーに 'Authorization': を付与します
正しい認証情報を持っていればアクセスができて、そうでなければエラーが返ってきます

また余談ですが、これはFlutter特有なのかわかりませんが、Flutterはエラーハンドリングはあまり細かく分けたりせずに大雑把にエラーをキャッチして一緒くたに処理するみたいですね
ネットに転がっている例とか見ていても細かくエラーハンドリングをしているケースをあまり見かけません
なのでこのアプリでもその慣習を踏襲して、エラーが起きたとしてもユーザーはエラーが起きたくらいのメッセージしか得られません
ユーザーフレンドリーじゃない気がしますが、Flutterの設計思想としてもしかしたらそもそも設計思想としてエラーを起こすやつは何やってもダメとかあるのかもしれないです
(Flutter開発歴が浅いので間違っていたらすみません)

providers/login_pro.dart

import 'dart:io';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';


class LoginProvider with ChangeNotifier {
  bool _isSuccess = false;
  String message = '';

  String email = '';
  String password = '';
  bool hidePassword = true;
  final UserModel _userModel = UserModel();

  final Uri _uriHost = Uri.parse('http://10.0.2.2:8000'); // Mobile(エミュレータ)ではこちらのホストを利用する
  // final Uri _uriHost = Uri.parse('http://127.0.0.1:8000'); // Webではこちらのホストを利用する

  String getUserId() {
    return _userModel.id;
  }

  void setMessage(String msg) {
    message = msg;
    notifyListeners();
  }

  void togglePasswordVisible() {
    hidePassword = !hidePassword;
    notifyListeners();
  }

  Future<bool> auth() async {
    _isSuccess = false;
    message = '';

    try {
      Dio dio = Dio();
      dio.options.baseUrl = _uriHost.toString();
      dio.options.connectTimeout = 5000;
      dio.options.receiveTimeout = 3000;
      dio.options.contentType = 'application/json';

      List<Cookie> cookieList = [];

      Directory appDocDir = await getApplicationDocumentsDirectory();
      String appDocPath = appDocDir.path;
      PersistCookieJar cookieJar = PersistCookieJar(storage: FileStorage(appDocPath+"/.cookies/"));
      dio.interceptors.add(CookieManager(cookieJar));

      final responseJwt = await dio.post(
          '/authen/jwt/create',
          data: {
            'email': email,
            'password': password,
          }
      );
      cookieList = [ ...cookieList, Cookie('access_token', responseJwt.data['access']) ];
      cookieJar.saveFromResponse(_uriHost, cookieList);

      final responseUser = await dio.get(
        '/authen/users/me',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
      );
      _userModel.id = responseUser.data['id'];
      _userModel.email = responseUser.data['email'];

      _isSuccess = true;
    } catch(error) {
      message = '正しいEメールとパスワードを入力してください';
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

  Future<void> logout() async {
  }

  Future<bool> signup() async {
    _isSuccess = false;
    message = '';

    try {
      Dio dio = Dio();
      dio.options.baseUrl = _uriHost.toString();
      dio.options.contentType = 'application/json';

      final response = await dio.post(
          '/api/users/create/',
          data: {
            'email': email,
            'password': password,
            'username': '',
          }
      );
      _userModel.id = response.data['id'];
      message = '新規ユーザーの仮登録が成功しました。本登録にはユーザーのアクティベーションを行って下さい';
      _isSuccess = true;
    } catch(error) {
      message = '新規ユーザー登録処理が失敗しました。同じEmailは使用できません';
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

}

http://10.0.2.2:8000http://127.0.0.1:8000/エイリアス となっており今回FlutterエミュレータDjangoのバックエンド双方が127.0.0.1:8000のホストを占有しておりバッティングしているため、10.0.2.2:8000を指定することでエミュレータ側からバックエンドにアクセスすることができるようになります
後で環境変数化の解説があるので、その時にこちらのコードも環境変数化してください
CookieJar の PersistCookieJar を使用することでクッキーの認証情報をストレージに格納して永続的に利用できるようになります


これが実装できるとこんな画面でログインが可能となるはずです
MyProfileを実装してない場合は当該部分を適宜コメントアウトなどして動作確認してくださいな


※パスワードはログイン後に空文字を入れるなどして初期化したほうがいいですね



サインアップの動作確認

サインアップも同様に利用できることを確認しておきましょう
サインアップはバックエンド開発の時に行ったようにユーザーを新規作成するとEmailかバックエンド側コンソールにアクティベーション用のURLが表示されるので、スマートフォンエミュレータのブラウザを開いてそのURLにアクセスしましょう
今回は開発環境のためURLのホストは12.7.0.0.1の部分を10.0.2.2に書き換えてアクセスします
Stripe画面が呼び出されてクレジット決済が完了したらユーザーがアクティベーションされることが確認出来たらOKです

今回はStripeのリダイレクトをDjango Rest Frameworkで作成したViewで返されたデータをフロントエンドで適切に受け取って画面を表示させていないので、Djangoの開発者用の画面が表示されてしまっています
これはDjangoテンプレートを使って画面を作成する方がいいなとフロント実装しながら思いました
今の実装だとアカウント作成の導線がぐちゃっているので、アプリを本気で運用する場合はこの辺は修正したほうがよさそうですね


自己プロフィール

さて、ログインが完了すると最初は自己プロフィール画面に遷移させるようにしたので、自己プロフィール画面を実装していきます

自己プロフィール画面の実装

レイアウトはこちらになります

全体像

まずは全体コードを記載します
とてもコードが長いので細かい単位でスナックバーなどの共通部品は個別にウィジェットに分けています

screens/my_profile_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:matchingappweb/pickers/graduation_picker_widget.dart';
import 'package:matchingappweb/pickers/passion_picker_widget.dart';
import 'package:matchingappweb/providers/login_provider.dart';
import 'package:matchingappweb/providers/profile_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../pickers/location_picker_widget.dart';
import '../pickers/sex_picker.dart';
import '../utils/show_snack_bar.dart';
import '../widgets/bottom_nav_bar_widget.dart';
import '../widgets/drawer_widget.dart';



class MyProfileScreen extends StatelessWidget {
  const MyProfileScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST'));

    return Scaffold(
      appBar: AppBar(
        title: const Text('マイプロフィール'),
      ),
      body: Consumer2<ProfileProvider, LoginProvider>(
        builder: (context, profileProvider, loginProvider, _) {
          return Padding(
              padding: const EdgeInsets.all(32.0),
              child: SingleChildScrollView(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Stack(
                      children: <Widget>[
                        profileProvider.uploadTopImage != null ? 
                          Image.file(profileProvider.uploadTopImage!)  :
                          profileProvider.myProfile.topImage != null ?
                            Image.network('${profileProvider.myProfile.topImage?.replaceFirst(dotenv.get('STORAGE_URL_HOST'), _uriHost.toString())}',
                              width: 100, fit: BoxFit.fill,
                              errorBuilder: (context, error, stackTrace) {
                                return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,);
                              },
                            ) :
                            Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,),
                        profileProvider.myProfile.isKyc ?
                          const Icon(Icons.check_circle, color: Colors.greenAccent, size: 16,) :
                          const SizedBox(),
                      ],
                    ),
                    TextButton(
                      child: const Text('画像変更'),
                      onPressed: () => profileProvider.pickTopImage(),
                    ),
                    TextFormField(
                      onChanged: (value) => profileProvider.myProfile.nickname = value,
                      decoration: const InputDecoration(labelText: 'ニックネーム', hintText: 'このフィールドは必須です'),
                      maxLength: 50,
                      initialValue: profileProvider.myProfile.nickname,
                    ),
                    TextFormField(
                      keyboardType: TextInputType.number,
                      inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                      onChanged: (value) => profileProvider.myProfile.age = int.parse(value),
                      decoration: const InputDecoration(labelText: '年齢', hintText: '18歳未満は登録出来ません',),
                      enabled: profileProvider.myProfile.user == null,
                      maxLength: 2,
                      initialValue: profileProvider.myProfile.age.toString(),
                    ),
                    for (String key in sexPicker.keys) ... {
                      RadioListTile(
                        value: key,
                        groupValue: profileProvider.myProfile.sex,
                        title: Text(sexPicker[key] ?? '性別不詳'),
                        selected: profileProvider.myProfile.sex == key,
                        onChanged: (value) {
                          if (profileProvider.myProfile.user == null) {
                            profileProvider.myProfile.sex = value.toString();
                            profileProvider.notifyListeners();
                          }
                          else {
                            null;
                          }
                        },
                      )
                    },
                    TextFormField(
                      keyboardType: TextInputType.number,
                      inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                      onChanged: (value) => profileProvider.myProfile.height = int.parse(value),
                      decoration: const InputDecoration(labelText: '身長cm', hintText: '140cm以上200cm未満で入力してください'),
                      maxLength: 3,
                      initialValue: profileProvider.myProfile.height != null ? profileProvider.myProfile.height.toString() : '',
                    ),
                    const LocationPickerWidget(),
                    TextFormField(
                      onChanged: (value) => profileProvider.myProfile.work = value,
                      decoration: const InputDecoration(labelText: '仕事',),
                      maxLength: 20,
                      initialValue: profileProvider.myProfile.work,
                    ),
                    TextFormField(
                      keyboardType: TextInputType.number,
                      inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                      onChanged: (value) => profileProvider.myProfile.revenue = int.parse(value),
                      decoration: const InputDecoration(labelText: '収入(万円)',),
                      maxLength: 4,
                      initialValue: profileProvider.myProfile.revenue.toString(),
                    ),
                    const GraduationPickerWidget(),
                    TextFormField(
                      onChanged: (value) => profileProvider.myProfile.hobby = value,
                      decoration: const InputDecoration(labelText: '趣味',),
                      maxLength: 20,
                      initialValue: profileProvider.myProfile.hobby,
                    ),
                    const PassionPickerWidget(),
                    TextFormField(
                      onChanged: (value) => profileProvider.myProfile.tweet = value,
                      decoration: const InputDecoration(labelText: 'つぶやき',),
                      maxLength: 10,
                      initialValue: profileProvider.myProfile.tweet,
                    ),
                    TextFormField(
                      keyboardType: TextInputType.multiline,
                      onChanged: (value) => profileProvider.myProfile.introduction = value,
                      decoration: const InputDecoration(labelText: '自己紹介',),
                      maxLength: 1000,
                      maxLines: null,
                      initialValue: profileProvider.myProfile.introduction,
                    ),
                    Padding(
                      padding: const EdgeInsets.only(top: 16.0),
                      child: ElevatedButton(
                        child: profileProvider.myProfile.user == null ? const Text('プロフィールを作成する') : const Text('プロフィールを更新する'),
                        style: ElevatedButton.styleFrom(
                          fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32),
                        ),
                        onPressed: () {
                          if (profileProvider.myProfile.user == null) {
                            profileProvider.createMyProfile(loginProvider.getUserId()).then((isSuccess) {
                              if (isSuccess) {
                                Navigator.pushReplacementNamed(context, '/my-profile');
                                showSnackBar(context, 'プロフィールが新規作成されました');
                              } else {
                                showSnackBar(context, 'エラーが発生しました');
                              }
                            });
                          }
                          else {
                            profileProvider.updateMyProfile(loginProvider.getUserId()).then((isSuccess) {
                              if (isSuccess) {
                                Navigator.pushReplacementNamed(context, '/my-profile');
                                showSnackBar(context, 'プロフィール更新が完了しました');
                              } else {
                                showSnackBar(context, 'エラーが発生しました');
                              }
                            });
                          }
                        },
                      ),
                    ),
                  ],
                ),
              ),
          );
        },
      ),
      drawer: const DrawerWidget(),
      bottomNavigationBar: const BottomNavBarWidget(),
    );
  }
}

共通部品のコード実装

スナックバー

ボタンを押すなどのユーザーアクション後にメッセージが表示されてくるアレです
トーストともいわれるUI部品です

utils/show_snack_bar.dart

import 'package:flutter/material.dart';

void showSnackBar(BuildContext context, String msg) {
  final snackBar = SnackBar(
    content: Text(msg),
    action: SnackBarAction(label: '閉じる', onPressed: () {}),
  );
  ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
ボトムナブバー

ボトムナビゲーションバーとか言ったり同義語がたくさんありますがアプリの下部にあるナビゲーションバーです
今回はただの飾りつけとしての実装しかしていません

widgets/bottom.dart

class BottomNavBarWidget extends StatelessWidget {
  const BottomNavBarWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      items: [
        BottomNavigationBarItem(
          label: '相手探し',
          icon: IconButton(
            icon: const Icon(Icons.wc),
            onPressed: () {
              // Navigator.pushNamed(context, '/looking');
            },
          )
        ),
        BottomNavigationBarItem(
            label: 'いいねリスト',
            icon: IconButton(
              icon: const Icon(Icons.volunteer_activism),
              onPressed: () {
                // Navigator.pushNamed(context, '/chance');
              },
            )
        ),
        BottomNavigationBarItem(
            label: 'メッセージ',
            icon: IconButton(
              icon: const Icon(Icons.message),
              onPressed: () {
                // Navigator.pushNamed(context, '/message');
              },
            )
        ),
      ],
    );
  }
}
ドロワー

左右から出てくるアレですね
今回はこれで画面移動を行います

widgets/drawer.dart

class DrawerWidget extends StatelessWidget {
  const DrawerWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: Consumer4<LoginProvider, ProfileProvider, MatchingProvider, MessageProvider>(
        builder: (context, loginProvider, profileProvider, matchingProvider, messageProvider, _) {
          return ListView(
            children: [
              const DrawerHeader(
                child: Text('ようこそ俺の嫁探しへ', style: TextStyle(color: Colors.white),),
                decoration: BoxDecoration(
                  color: Colors.blue,
                ),
              ),
              ListTile(
                title: const Text("相手を探す"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () async {
                  bool _isSuccess = await profileProvider.fetchProfileList();
                  if (_isSuccess) Navigator.pushNamed(context, '/looking');
                },
              ),
              ListTile(
                title: const Text("いいねした人リスト"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () async {
                  bool _isSuccess = await profileProvider.fetchProfileApproachingList();
                  if (_isSuccess) Navigator.pushNamed(context, '/favorite');
                },
              ),
              ListTile(
                title: const Text("いいねをもらった人リスト"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () async {
                  bool _isSuccess = await profileProvider.fetchProfileApproachedList();
                  if (_isSuccess) Navigator.pushNamed(context, '/chance');
                },
              ),
              ListTile(
                title: const Text("マッチング成立リスト"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () async {
                  bool _isSuccess = await profileProvider.fetchProfileMatchingList();
                  if (_isSuccess) Navigator.pushNamed(context, '/matching');
                },
              ),
              ListTile(
                title: const Text("メッセージ"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () {} // Navigator.pushNamed(context, '/message'),
              ),
              ListTile(
                title: const Text("自己プロフィール"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () {
                  profileProvider.fetchMyProfile(loginProvider.getUserId())
                    .then( (_) {
                       Navigator.pushNamed(context, '/my-profile');
                    });
                }
              ),
            ],
          );
        }
      )
    );
  }
}
環境変数の導入

開発環境や本番環境の分離やシークレット情報の保持などのために使用する環境変数を設定します
以下の環境変数用のパッケージをインストールしてマニュアルに記載の設定を行っていきます
flutter_dotenv | Flutter Package

.env.devと.env.prodをプロジェクト直下に作成してassetsに記載します

pabspec.yaml

dependencies:
  flutter_dotenv: ^5.0.2

flutter:
  assets:
   - .env.dev
   - .env.prod

flutter pub get コマンドを叩いてインストールします

その後、.envファイルに環境変数を記載します
.env.dev

BACKEND_URL_HOST=http://10.0.2.2:8000
STORAGE_URL_HOST=http://127.0.0.1:8000/

main.dart環境変数を読み込む記述を行います

import 'package:flutter_dotenv/flutter_dotenv.dart';

Future<void> main() async {
  await dotenv.load(fileName: ".env.dev");
  runApp();
}

その後は読み込みたいファイルで環境変数を読み込みます
my_profile_screen.dart

import 'package:flutter_dotenv/flutter_dotenv.dart';

class MyProfileScreen extends StatelessWidget {
  Widget build(BuildContext context) {
    String _urlString = dotenv.get('BACKEND_URL_HOST');
    final Uri _uriHost = Uri.parse(_urlString);
      ...
  }
}
pickerの導入

詳しい手順はQiitaにまとめました
Provider利用時のFlutterのpicker(ドラムロール)の実装方法 - Qiita

例:graduation_picker.dart

final Map<String, String> graduationPicker = {
  '' : '',
  'junior_high_school' : '中卒',
  'high_school' : '高卒',
  'trade_school' : '短大・専門学校卒',
  'university' : '大卒',
  'grad_school' : '大学院卒',
};

例:graduation_picker_widget.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:matchingappweb/pickers/graduation_picker.dart';
import 'package:provider/provider.dart';
import '../providers/profile_provider.dart';

class GraduationPickerWidget extends StatelessWidget {
  const GraduationPickerWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Consumer<ProfileProvider>(
        builder: (context, profileProvider, _) {
          return Row(
            children: [
              const Text('最終学歴 ',),
              Text(graduationPicker[profileProvider.myProfile.graduation] ?? '', style: const TextStyle(fontWeight: FontWeight.bold)),
              TextButton(
                child: const Text('選択'),
                onPressed: () {
                  showModalBottomSheet(
                      context: context,
                      builder: (BuildContext context) {
                        return Container(
                          height: MediaQuery.of(context).size.height / 2,
                          child: Column(
                            children: [
                              Row(
                                crossAxisAlignment: CrossAxisAlignment.end,
                                children: [
                                  TextButton(
                                    child: const Text('戻る'),
                                    onPressed: () => Navigator.pop(context),
                                  ),
                                  TextButton(
                                    child: const Text('決定'),
                                    onPressed: () {
                                      profileProvider.notifyListeners();
                                      Navigator.pop(context);
                                    },
                                  ),
                                ],
                              ),
                              Container(
                                height: MediaQuery.of(context).size.height / 3,
                                child: CupertinoPicker(
                                  itemExtent: 40,
                                  children: [
                                    for (String key in graduationPicker.keys) ... {
                                      Text(graduationPicker[key] ?? '最終学歴不詳')
                                    },
                                  ],
                                  onSelectedItemChanged: (int index) => profileProvider.myProfile.graduation = graduationPicker.keys.elementAt(index),
                                ),
                              )
                            ],
                          ),
                        );
                      }
                  );
                },
              ),
            ],
          );
        }
    );
  }
}
ラジオボタンの実装

ラジオボタンもPickerと同じような実装になります
Picker_Widgetを作らずmy_profile_screen上でラジオボタンを実装しており、表示させるデータだけをpickerファイルに分離させています

例:sex_picker

final Map<String, String> sexPicker = {
  'male' : '男性',
  'female' : '女性',
};

ProfileProviderの実装

それでは自己プロフィールを扱うためのデータとロジックをProviderに実装していきましょう
以下が自己プロフィールに関連したコード全体です
profile_provider.dart

import 'dart:io';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image/image.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../models/profile_model.dart';

class ProfileProvider with ChangeNotifier {
  final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST'));

  bool _isSuccess = false;
  File? uploadTopImage;

  ProfileModel myProfile = ProfileModel(
      user: null,
      isSpecial: false,
      isKyc: false,
      nickname: '',
      topImage: null,
      createdAt: null,
      updatedAt: null,
      age: 0,
      sex: '',
      height: null,
      location: null,
      work: null,
      revenue: 0,
      graduation: null,
      hobby: null,
      passion: null,
      tweet: null,
      introduction: null,
      sendFavorite: null,
      receiveFavorite: null,
      stockFavorite: null,
  );

  Future fetchMyProfile(String userId) async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);
      final Response<dynamic> profile = await dio.get(
        '/api/users/profile/$userId',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
      );
      myProfile = _inputProfileModel(profile.data!);
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

  Future pickTopImage() async {
    _isSuccess = false;
    final ImagePicker _picker = ImagePicker();
    try {
      final XFile? image = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 25);

      if (image != null) {
        final _imageDecode = decodeImage(File(image.path).readAsBytesSync());
        if (_imageDecode != null) {
          var _imageResize;
          const int _imageLongSide = 720;
          if (_imageDecode.width > _imageDecode.height) {
            if (_imageDecode.width > _imageLongSide) {
              _imageResize = copyResize(
                  _imageDecode,
                  width: _imageLongSide,
                  height: _imageLongSide * _imageDecode.height ~/ _imageDecode.width
              );
            }
          } else {
            if (_imageDecode.height > _imageLongSide) {
              _imageResize = copyResize(
                  _imageDecode,
                  width: _imageLongSide * _imageDecode.width ~/ _imageDecode.height,
                  height: _imageLongSide
              );
            }
          }
          if (_imageResize != null) {
            File(image.path).writeAsBytesSync(encodePng(_imageResize));
          }
        }
        uploadTopImage = File(image.path);
      }
      _isSuccess = true;
    } catch (error) {
      print("エラーが発生しました");
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

  Future createMyProfile(String userId) async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);

      FormData formData = FormData.fromMap({
        "is_special": false,
        "is_kyc": false,
        "top_image": uploadTopImage != null ?
          await MultipartFile.fromFile(
            uploadTopImage!.path,
            filename: uploadTopImage!.path.split('/').last,
          ) :
          myProfile.topImage,
        "nickname": myProfile.nickname,
        "age": myProfile.age,
        "sex": myProfile.sex,
        "height": myProfile.height,
        "location": myProfile.location,
        "work": myProfile.work,
        "revenue": myProfile.revenue,
        "graduation": myProfile.graduation,
        "hobby": myProfile.hobby,
        "passion": myProfile.passion,
        "tweet": myProfile.tweet,
        "introduction": myProfile.introduction,
        "send_favorite": myProfile.sendFavorite,
        "receive_favorite": myProfile.receiveFavorite,
        "stock_favorite": myProfile.stockFavorite
      });

      final Response profile = await dio.post(
        '/api/profiles/',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
        data: formData,
      );
      myProfile = _inputProfileModel(profile.data!);
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

  Future updateMyProfile(String userId) async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);

      FormData formData = FormData.fromMap({
        "top_image": uploadTopImage != null ?
          await MultipartFile.fromFile(
            uploadTopImage!.path,
            filename: uploadTopImage!.path.split('/').last,
          ) :
          myProfile.topImage,
        "nickname": myProfile.nickname,
        "height": myProfile.height,
        "location": myProfile.location,
        "work": myProfile.work,
        "revenue": myProfile.revenue,
        "graduation": myProfile.graduation,
        "hobby": myProfile.hobby,
        "passion": myProfile.passion,
        "tweet": myProfile.tweet,
        "introduction": myProfile.introduction,
      });

      final Response profile = await dio.patch(
          '/api/users/profile/$userId/',
          options: Options(
            headers: {
              'Authorization': 'JWT ${cookieList.first.value}',
            },
          ),
          data: formData,
      );
      myProfile = _inputProfileModel(profile.data!);
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

  ProfileModel _inputProfileModel(dynamic profile) {
    return ProfileModel(
      user: profile!['user'],
      isSpecial: profile!['is_special'],
      isKyc: profile!['is_kyc'],
      topImage: profile!['top_image'],
      nickname: profile!['nickname'],
      createdAt: profile!['created_at'],
      updatedAt: profile!['updated_at'],
      age: profile!['age'],
      sex: profile!['sex'],
      height: profile!['height'],
      location: profile!['location'],
      work: profile!['work'],
      revenue: profile!['revenue'],
      graduation: profile!['graduation'],
      hobby: profile!['hobby'],
      passion: profile!['passion'],
      tweet: profile!['tweet'],
      introduction: profile!['introduction'],
      sendFavorite: profile!['send_favorite'],
      receiveFavorite: profile!['receive_favorite'],
      stockFavorite: profile!['stock_favorite'],
      // profile!['fromLastLogin'],
    );
  }

  Future<List<Cookie>> _prepareDio(Dio dio) async {
    dio.options.baseUrl = _uriHost.toString();
    dio.options.connectTimeout = 5000;
    dio.options.receiveTimeout = 3000;
    // dio.options.contentType = 'application/json' or 'multipart/form-data';
    Directory appDocDir = await getApplicationDocumentsDirectory();
    String appDocPath = appDocDir.path;
    PersistCookieJar cookieJar = PersistCookieJar(storage: FileStorage(appDocPath+"/.cookies/"));
    dio.interceptors.add(CookieManager(cookieJar));
    List<Cookie> cookieList = await cookieJar.loadForRequest(_uriHost);
    return cookieList;
  }

}

CookieDioの使い方はLoginの機能を作成したときと同じです
ここではmy_profileという変数を用意して、自己プロフィールを新規作成するメソッドと取得するメソッドと更新するメソッドを作成しました
Profileモデルが大きいのでコード量が大きく見えていますが、基本的にはバックエンドで作成したAPIのエンドポイントを呼んでいるだけです
ただ、HTTPクライアントで取得したデータは型がDynamicになってしまうため、少し面倒ですがProfile型に戻す処理をつける必要があり、その処理が_inputProfileModelです
また画像のアップロードも行えるロジックを実装しました
画像などのデータを含むフォームデータを作成する場合はFormData型でFormData.fromMapを使用するとよいところがポイントです

画像のアップロード

詳細はQiitaに記述しました
Flutter×Django(DRF)で画像をアップロードする方法 - Qiita

この実装ではpickTopImageのメソッドでproviderにセットし、プロフィール作成時もしくは更新時にアップロードするようになっています
現段階の実装ではほかの画面に遷移したときに画像はクリアされませんが、実運用では画像のアップロードを取りやめたい場合の画像クリア機能をつける必要があります

動作確認

新規アカウントを作成したり、既存のアカウントを使用して、ログインします
そしてプロフィール作成や更新を行って、作成や更新が出来たらスナップバーが出現し作成・更新されたデータができていることをDjangoの管理画面などでも確認してください

プロフィール一覧閲覧機能&いいね機能の実装

次に異性を閲覧できるプロフィール一覧機能を作っていきます

プロフィール全件取得コードの作成

まずはProfileProviderにProfileデータを取り扱うための処理を記載していきます
まずは画面共有するための変数を定義します

ProfileProvider.dart

/// プロフィール機能
  List<ProfileModel> profileList = [];
  List<ProfileModel> profileApproachingList = [];
  List<ProfileModel> profileApproachedList = [];
  List<ProfileModel> profileMatchingList = [];
  ProfileModel? profileDetail;

そしてバックエンドから全件データを取得する処理をまずは記載します
Provider内のプライベートメソッドにします
バックエンドで実装したAPIをHTTPクライアントのDIOで呼び出しています
またmy_profileの時と同様に、HTTPクライアントで取得したデータは型がDynamicになってしまうためProfile型に戻す処理をつける必要があり、これらはこの後も他のメソッドで頻繁に共通利用されるため_inputMessageModel、_inputMessageModelListのメソッドとして処理を外だししています

ProfileProvider.dart

/// 【プライベート】プロフィール全件取得
  Future _fetchProfileAllList() async {
    _isSuccess = false;
    profileList.clear();
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);
      final Response profiles = await dio.get(
        '/api/profiles',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
      );
      profileList = _inputProfileModelList(profiles.data!);
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

/// 【プライベート】バックエンドから取得したメッセージデータのProvider化
  MessageModel _inputMessageModel(dynamic message) {
    return MessageModel(
      id: message!['id'],
      sender: message!['sender'],
      receiver: message!['receiver'],
      message: message!['message'],
      createdAt: message!['created_at'],
    );
  }

  /// 【プライベート】バックエンドから取得したメッセージデータ一覧のProvider化
  List<MessageModel> _inputMessageModelList(dynamic messageList) {
    return messageList.map<MessageModel>(
            (message) => _inputMessageModel(message)
    ).toList();
  }

マッチングデータモデルの作成

次にプロフィール一覧画面ではすでにいいねを送ったユーザーやいいねをもらったユーザーやマッチングしているユーザーは表示させない仕様とするため、いいね機能であるマッチングモデルの処理をここで追加していきます
_matchingList はマッチング中のMatchingModelデータがリストで格納され、_approachingList はいいねを送ったユーザー、_approachedList はいいねをくれたユーザーを格納します
またフィルタリングの処理の都合上、_matchingUserIdList のように相手ユーザーのIDだけをリスト化したものも作成しておきます

ProfileProvider.dart

  /// マッチング機能
  MatchingModel? matching;
  List<MatchingModel> _matchingList = [];
  List<String> _matchingUserIdList = [];
  List<MatchingModel> _approachingList = [];
  List<String> _approachingUserIdList = [];
  List<MatchingModel> _approachedList = [];
  List<String> _approachedUserIdList = [];

そして、バックエンドから取得したマッチングに関する全件データをローカルでそれぞれのデータに振り分けていく処理を実装して、上記で作成した変数に振り分けたデータをmapやwhereを使ってフィルタリングし、それぞれ格納していきます

ProfileProvider.dart

/// 【プライベート】いいねしているユーザー・いいねされているユーザー・マッチングしているユーザーのそれぞれのマッチングリストを取得する
  Future _fetchMatchingList() async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);
      final Response matchingList = await dio.get(
        '/api/favorite/',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
      );
      _matchingList = _inputMatchingModelList(matchingList.data!);
      _approachingList = _matchingList.where((matching) => matching.approaching == myProfile.user).toList();
      _approachingUserIdList = _approachingList.map((matching) => matching.approached).toList();
      _approachedList = _matchingList.where((matching) => matching.approached == myProfile.user).toList();
      _approachedUserIdList = _approachedList.map((matching) => matching.approaching!).toList();
      _matchingList = _matchingList.where((matching) => matching.approved == true).toList();
      _matchingUserIdList = _matchingList.map((matching) => matching.approaching!).toList();
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

フィルタリングの実装

プロフィール一覧画面に表示するユーザーのフィルタリング

上記で格納した _approachingList などの変数を使用していいねを送ったユーザーといいねをくれたユーザーを取り除く処理を記述します
removeWhereを使います
実装はこちらで以上となります
ちなみに後で気づきましたが、_isSuccessの部分の実装は正しくない実装です
余力のある日とはtry catchで囲んで実装の修正をしておきましょう
まあこのままでも動きますが、今のところ

ProfileProvider.dart

/// ユーザーのプロフィール一覧を取得する(いいね・マッチング状態のユーザーは除外)
  Future fetchProfileList() async {
    _isSuccess = false;
    _isSuccess = await _fetchProfileAllList();
    _isSuccess = await _fetchMatchingList();
    profileList.removeWhere((profile) => _approachingUserIdList.contains(profile.user));
    profileList.removeWhere((profile) => _approachedUserIdList.contains(profile.user));
    notifyListeners();
    return _isSuccess;
  }
いいねを送ったユーザーのフィルタリング

上記で格納した _approachingList の変数を使用していいねを送ったユーザーをフィルタリングしていく処理を記述します
whereを使用しています
特段上の実装と変わったところはありません

ProfileProvider.dart

/// いいねしたユーザーのプロフィール一覧を取得する(マッチング状態のユーザーは除外)
  Future fetchProfileApproachingList() async {
    _isSuccess = false;
    _isSuccess = await _fetchProfileAllList();
    _isSuccess = await _fetchMatchingList();
    profileApproachingList = profileList.where((profile) => _approachingUserIdList.contains(profile.user)).toList();
    profileApproachingList.removeWhere((profile) => _matchingUserIdList.contains(profile.user));
    notifyListeners();
    return _isSuccess;
  }
いいねをもらったユーザーのフィルタリング

同様に

ProfileProvider.dart

/// いいねされたユーザーのプロフィール一覧を取得する(マッチング状態のユーザーは除外)
  Future fetchProfileApproachedList() async {
    _isSuccess = false;
    _isSuccess = await _fetchProfileAllList();
    _isSuccess = await _fetchMatchingList();
    profileApproachedList = profileList.where((profile) => _approachedUserIdList.contains(profile.user)).toList();
    profileApproachedList.removeWhere((profile) => _matchingUserIdList.contains(profile.user));
    notifyListeners();
    return _isSuccess;
  }
マッチングしたユーザーのフィルタリング

同様です

ProfileProvider.dart

/// マッチングしているユーザーのプロフィール一覧を取得する
  Future fetchProfileMatchingList() async {
    _isSuccess = false;
    _isSuccess = await _fetchProfileAllList();
    _isSuccess = await _fetchMatchingList();
    profileMatchingList = profileList.where((profile) => _matchingUserIdList.contains(profile.user)).toList();
    notifyListeners();
    return _isSuccess;
  }

ユーザー一覧画面の作成

それではユーザー一覧画面を実装していきます
一覧画面は4画面で共通利用するので共通部分を共通化しておきます
リストビューで実装して画像とニックネームと年齢とつぶやきが表示されるような実装にしました

共通利用画面のWidget

widgets/ProfileListWidget .dart

class ProfileListWidget extends StatelessWidget {
  ProfileListWidget({
    Key? key,
    required List<ProfileModel> profiles,
    String? nextUrl,
    Function? nextAction
  }) : _profiles = profiles, _nextUrl = nextUrl ?? '/profile-detail', super(key: key);

  final List<ProfileModel> _profiles;
  final String _nextUrl;
  // Flutter Web の場合 dotenv.get('BACKEND_URL_HOST_CASE_FLUTTER_WEB')
  final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST'));

  @override
  Widget build(BuildContext context) {
    return Consumer<ProfileProvider>(
      builder: (context, profileProvider, _) {
        return ListView.builder(
          itemCount: _profiles.length,
          itemBuilder: (BuildContext context, int index) {
            return ListTile(
              leading: Stack(
                children: <Widget>[
                  _profiles[index].topImage != null ?
                  Image.network('${_profiles[index].topImage?.replaceFirst(
                      dotenv.get('STORAGE_URL_HOST'), _uriHost.toString())}',
                    errorBuilder: (context, error, stackTrace) {
                      return Image.asset('images/nophotos.png',);
                    },
                  ) :
                  Image.asset('images/nophotos.png',),
                  _profiles[index].isKyc
                      ?
                  const Icon(
                    Icons.check_circle, color: Colors.greenAccent, size: 16,)
                      :
                  const SizedBox(),
                ],
              ),
              title: Text('${_profiles[index].nickname} ${_profiles[index].age}歳'),
              subtitle: Text(_profiles[index].tweet ?? ''),
              trailing: Icon(
                profileProvider.checkSendFavorite(_profiles[index].user ?? '') ? Icons.favorite : Icons.favorite_border,
                color: Colors.pinkAccent,
              ),
              onTap: () async {
                profileProvider.setProfileDetail(_profiles[index]);
                if(_nextUrl == '/message') await profileProvider.getMessageList();
                Navigator.pushNamed(context, _nextUrl);
              },
            );
          },
        );
      }
    );
  }
}
プロフィール一覧画面の実装

screens/ProfilesScreen.dart

class ProfilesScreen extends StatelessWidget {
  const ProfilesScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('プロフィール一覧'),
      ),
      body: Consumer<ProfileProvider>(
        builder: (context, profileProvider, _) {
          return ProfileListWidget(profiles: profileProvider.profileList);
        },
      ),
      drawer: DrawerWidget(),
      bottomNavigationBar: BottomNavBarWidget(),
    );
  }
}
いいねを送ったユーザーのプロフィール一覧画面

上記とほぼ同じ実装です
以下の部分が主に違います

screens/ApproachingScreen.dart

return ProfileListWidget(profiles: profileProvider.profileApproachingList);
いいねをもらったユーザーのプロフィール一覧画面

上記と同様です
引数にprofileProvider.profileApproachedListを与えます

マッチング中のユーザーのプロフィール一覧画面

上記と同様ですが、引数にprofiles: profileProvider.profileMatchingList と それだけでなく、一覧のユーザーをタップしたときに、マッチング画面ではメッセージ画面に飛ぶようにnextUrl: '/message',の引数を加えておきます

ユーザー情報詳細画面の実装

一覧画面のうち特定のユーザーをタップすると詳細情報を見られるように詳細画面を作成します

ユーザー詳細選択ロジック

タップしたときにどのユーザーをタップしたかわかるようなロジックをProviderに実装します

ProfileProvider.dart

ProfileModel? profileDetail;

/// 選択したユーザーのプロフィールをセットする
  void setProfileDetail(ProfileModel profile) {
    profileDetail = profile;
    notifyListeners();
  }
詳細画面実装

詳細画面では先ほど格納したprofileDetail の情報を元に詳細を表示する画面を作成します
レイアウトは適当ですので、適宜手を加えて改善してください

ProfileDetailScreen.dart

class ProfileDetailScreen extends StatelessWidget {
  const ProfileDetailScreen({Key? key,}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final Uri _uriHost = Uri.parse('http://10.0.2.2:8000');
    return Scaffold(
      appBar: AppBar(
        title: const Text('プロフィール詳細'),
      ),
      body: Consumer<ProfileProvider>(
        builder: (context, profileProvider, _) {
          return Padding(
            padding: const EdgeInsets.all(32.0),
            child: SingleChildScrollView(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text(profileProvider.profileDetail!.nickname, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),),
                  ),
                  Stack(
                    children: <Widget>[
                      profileProvider.profileDetail!.topImage != null ?
                      Image.network('${profileProvider.profileDetail!.topImage?.replaceFirst('http://127.0.0.1:8000/', _uriHost.toString())}',
                        width: 100, fit: BoxFit.fill,
                        errorBuilder: (context, error, stackTrace) {
                          return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,);
                        },
                      ) :
                      Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,),
                      profileProvider.profileDetail!.isKyc ?
                      const Icon(Icons.check_circle, color: Colors.greenAccent, size: 16,) :
                      const SizedBox(),
                    ],
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      Text('${profileProvider.profileDetail!.age.toString()}歳',),
                      Text(sexPicker[profileProvider.profileDetail!.sex] ?? '',),
                      Text(locationPicker[profileProvider.profileDetail!.location] ?? '',),
                    ],
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('送ったいいね ${profileProvider.profileDetail!.sendFavorite ?? ''}',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('もらったいいね ${profileProvider.profileDetail!.receiveFavorite ?? ''}',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('所持しているいいね ${profileProvider.profileDetail!.stockFavorite ?? ''}',),
                  ),
                  const Padding(
                    padding: EdgeInsets.all(8.0),
                    child: Text('いいねする',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: IconButton(
                      icon: Icon(
                        profileProvider.checkSendFavorite(profileProvider.profileDetail!.user ?? '') ? Icons.favorite : Icons.favorite_border,
                        color: Colors.pinkAccent
                      ),
                      onPressed: () async {
                        if (!profileProvider.checkSendFavorite(profileProvider.profileDetail!.user ?? '')) {
                          profileProvider.sendFavorite().then((isSuccess) {
                            if (isSuccess) {
                              showSnackBar(context, 'いいねを送りました');
                            } else {
                              showSnackBar(context, 'エラーが発生しました');
                            }
                          });
                        }
                      },
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text(profileProvider.profileDetail!.tweet ?? '', style: const TextStyle(decoration: TextDecoration.underline),),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('${profileProvider.profileDetail!.height ?? ''}cm',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('お仕事 ${profileProvider.profileDetail!.work ?? ''}',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('${profileProvider.profileDetail!.revenue ?? ''}万円',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text(graduationPicker[profileProvider.profileDetail!.graduation] ?? '',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('趣味 ${profileProvider.profileDetail!.hobby ?? ''}',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('結婚願望 ${passionPicker[profileProvider.profileDetail!.passion ?? '']}' ,),
                  ),
                  const Padding(
                    padding: EdgeInsets.all(8.0),
                    child: Text('自己紹介',),
                  ),
                  Container(
                    padding: const EdgeInsets.all(8.0),
                    decoration: BoxDecoration(border: Border.all()),
                    child: Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Text(profileProvider.profileDetail!.introduction ?? '',),
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
      drawer: const DrawerWidget(),
      bottomNavigationBar: const BottomNavBarWidget(),
    );
  }
}

いいね送信機能の実装

さて詳細画面ができたので、詳細画面からいいねを押すことができる機能を作成していきましょう
全体としてのコードはこちらです
sendFavoriteがいいねを送るロジックを記載したコードであり、相手とのいいねの状態によって処理が異なります
check〇〇Faoriteはコメントアウト通りの処理になっており条件判定に使います

ProfileProvider.dart

/// いいねをしたユーザーかどうかをチェックする
  bool checkSendFavorite(String userId) {
    return _approachingUserIdList.contains(userId);
  }

  /// いいねをくれたユーザーかどうかをチェックする
  bool checkReceiveFavorite(String userId) {
    return _approachedUserIdList.contains(userId);
  }

  /// いいねを送る・承認する
  Future sendFavorite() async {
    _isSuccess = false;
    try {
      String approachUserId = profileDetail != null ? profileDetail!.user! : '';
      // 既にこちらからいいねを送っている場合は処理を行わない
      if (checkSendFavorite(approachUserId)) {
        // 何も処理を行わない
      }
      // 既に相手からいいねが来ている場合は上記のリクエストデータのapprovedをTrueにしていいねを行い、相手のマッチングモデルデータのapprovedもTrueに更新する
      else if (checkReceiveFavorite(approachUserId)) {
        // いいね新規作成処理
        await _createFavorite(approached: approachUserId, approved: true);
        // いいねをくれたユーザーのマッチングリストの中でapproachUserIdと一致するマッチングデータのIDを探索する
        Iterable<MatchingModel> approachMatching = _approachedList.where((matching) => matching.approaching == approachUserId);
        int approachMatchingId = approachMatching.first.id ?? 0;
        // 相手のいいねデータに対する承認処理
        await _patchApproved(id: approachMatchingId);
        // マッチングデータ再取得
        await fetchProfileMatchingList();
      }
      // どちらもいいねを送っていない場合は上記のリクエストデータを用いてマッチングデータを作成する
      else {
        // いいね新規作成処理
        await _createFavorite(approached: approachUserId, approved: false);
        // いいねしたユーザー一覧データ再取得
        await fetchProfileApproachingList();
      }
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

/// 【プライベート】マッチングデータ新規作成
  Future _createFavorite({required String approached, required bool approved}) async {
    Dio dio = Dio();
    List<Cookie> cookieList = await _prepareDio(dio);
    return await dio.post(
      '/api/favorite/',
      options: Options(
        headers: {
          'Authorization': 'JWT ${cookieList.first.value}',
        },
      ),
      data: {
        "approached": approached,
        "approved": approved,
      },
    );
  }

  /// 【プライベート】マッチングデータ承認フィールド更新
  Future _patchApproved({required int id}) async {
    Dio dio = Dio();
    List<Cookie> cookieList = await _prepareDio(dio);
    return await dio.patch(
      '/api/favorite/$id/',
      options: Options(
        headers: {
          'Authorization': 'JWT ${cookieList.first.value}',
        },
      ),
      data: {
        "approved": true
      },
    );
  }
既にもらっているいいねに対する承認ロジックについて

条件分岐ごとの処理はコメントアウトに記載されている通りですが、すでにいいねをもらっている人にいいねを送る処理というのは言い換えるといいねを承認していることと同じです
相手からすでにいいねが来ているということは、相互でいいねを送り合うので、マッチング成立ととらえます
こちらからは承認済のいいね(マッチングモデルデータ)を送信して、相手からすでにもらっているいいねデータのapprovedを承認済みに変更します
approvedはメッセージ送信を許可するかどうかを判定するためのフィールドであるため必ず両者のマッチングデータのapprovedをTrueにします
相手のデータを更新する必要があるため、_patchApprovedのメソッドを作成しています(※Djangoの記事でバックエンドのPatchを拒否する設定になってたかもしれないので、そうなっていた場合は当該コードをコメントアウトするなり修正してください)

これにて、いいね機能を実装することができました

メッセージ機能の実装

最後にメッセージ機能を作成します

ModelとProviderの実装

Modelの実装

MessageModel .dart

class MessageModel {
  late int? id = 0;
  late String sender = '';
  late String receiver = '';
  late String message = '';
  late String? createdAt;

  MessageModel({
    this.id,
    required this.sender,
    required this.receiver,
    required this.message,
    this.createdAt,
  });
}
全件メッセージを取得するロジック

別ファイルに分けたいのは山々ですが、Provider間でのデータの受け渡しに関しては、理解できておらず状態管理がうまくできるコードを作成できないので、ProfileProviderが長大になるとは思いつつもこちらに追記していきます
上記で実装してきた容量で状態管理したい変数とロジックを作成していきます

ProfileProvider.dart

/// メッセージ機能
  List<MessageModel> messageList = [];
  List<MessageModel> _sendMessageList = [];
  List<MessageModel> _receiveMessageList = [];
  String newMessage = '';

バックエンドからデータを取得してデータを格納する処理

ProfileProvider.dart

/// 【プライベート】送ったメッセージの内容を全件取得する
  Future _fetchSendMessageList() async {
    Dio dio = Dio();
    List<Cookie> cookieList = await _prepareDio(dio);
    final Response<dynamic> message = await dio.get(
      '/api/dm-message/',
      options: Options(
        headers: {
          'Authorization': 'JWT ${cookieList.first.value}',
        },
      ),
    );
    _sendMessageList = _inputMessageModelList(message.data!);
  }

  /// 【プライベート】受け取ったメッセージの内容を全件取得する
  Future _fetchReceiveMessageList() async {
    Dio dio = Dio();
    List<Cookie> cookieList = await _prepareDio(dio);
    final Response<dynamic> message = await dio.get(
      '/api/dm-inbox/',
      options: Options(
        headers: {
          'Authorization': 'JWT ${cookieList.first.value}',
        },
      ),
    );
    _receiveMessageList = _inputMessageModelList(message.data!);
  }

/// 【プライベート】バックエンドから取得したメッセージデータのProvider化
  MessageModel _inputMessageModel(dynamic message) {
    return MessageModel(
      id: message!['id'],
      sender: message!['sender'],
      receiver: message!['receiver'],
      message: message!['message'],
      createdAt: message!['created_at'],
    );
  }

  /// 【プライベート】バックエンドから取得したメッセージデータ一覧のProvider化
  List<MessageModel> _inputMessageModelList(dynamic messageList) {
    return messageList.map<MessageModel>(
            (message) => _inputMessageModel(message)
    ).toList();
  }
特定のユーザーとのメッセージだけにフィルタリングするロジック

マッチング成立画面からメッセージ画面に移るときに呼び出されるAPIを実装したものが以下です

ProfileProvider.dart

/// 指定したユーザーとのメッセージの内容を取得する
  Future getMessageList() async {
    _isSuccess = false;
    messageList.clear();
    try {
      // バックエンドから自身に関連するメッセージ一覧を取得する
      await _fetchSendMessageList();
      await _fetchReceiveMessageList();
      // 指定したユーザーとのメッセージだけをフィルタリングして新規メッセージ順に messageList に格納する
      messageList.addAll(_sendMessageList.where((message) => message.receiver == profileDetail!.user));
      messageList.addAll(_receiveMessageList.where((message) => message.sender == profileDetail!.user));
      messageList.sort((alpha, beta) => alpha.createdAt!.compareTo(beta.createdAt!));
      // 処理成功
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }
新規メッセージ作成

新たなメッセージを送る処理です

ProfileProvider.dart

/// 指定したユーザーに新規メッセージを送信する
  Future createMessage() async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);
      await dio.post(
        '/api/dm-message/',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
        data: {
          "receiver": profileDetail!.user,
          "message": newMessage,
        },
      );
      newMessage = '';
      await getMessageList();
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

メッセージ画面の実装

まずはメッセージ画面を作成します
コードが長くなるので、MessageListWidgetで一部ウィジェット化しています

MessageScreen.dart

class MessageScreen extends StatelessWidget {
  const MessageScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Consumer<ProfileProvider>(
      builder: (context, profileProvider, _) {
        return Scaffold(
          appBar: AppBar(
            title: Text('${profileProvider.profileDetail!.nickname}とのメッセージ'),
          ),
          body: Column(
            children: [
              Expanded(child: MessageListWidget(messages: profileProvider.messageList,),),
              Padding(
                padding: const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 4.0),
                child: TextFormField(
                  onChanged: (value) => profileProvider.newMessage = value,
                  decoration: InputDecoration(
                    labelText: 'メッセージを送りましょう',
                    suffixIcon: IconButton(
                        onPressed: () {
                          profileProvider.createMessage().then((isSuccess) {
                            if (isSuccess) {
                              showSnackBar(context, 'メッセージが送信されました');
                            } else {
                              showSnackBar(context, 'エラーが発生しました');
                            }
                          });
                        },
                        icon: const Icon(Icons.send)
                    ),
                  ),
                  maxLength: 500,
                ),
              ),
            ],
          ),
          drawer: const DrawerWidget(),
          bottomNavigationBar: const BottomNavBarWidget(),
        );
      },
    );

  }
}
Widget化したメッセージ一覧画面コンポーネントの実装

こちらもレイアウトは適当です
LINEのような言わゆるよく見るチャットのレイアウトを作成する実力はなかったのでひとまずこちらの実装となっています
実運用を目指すのであればブラッシュアップが必要です

widgets/MessageListWidget.dart

class MessageListWidget extends StatelessWidget {
  MessageListWidget({
    Key? key,
    required List<MessageModel> messages,
  }) : _messages = messages, super(key: key);

  final List<MessageModel> _messages;
  // Flutter Web の場合 dotenv.get('BACKEND_URL_HOST_CASE_FLUTTER_WEB')
  final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST'));

  @override
  Widget build(BuildContext context) {
    return Consumer<ProfileProvider>(
      builder: (context, profileProvider, _) {
        return ListView.builder(
          itemCount: _messages.length,
          itemBuilder: (BuildContext context, int index) {
            String imageUrl = profileProvider.myProfile.topImage ?? '';
            String sender = profileProvider.myProfile.nickname;
            String createdAt = _messages[index].createdAt ?? '';
            if(_messages[index].sender == profileProvider.profileDetail!.user) {
              imageUrl = profileProvider.profileDetail!.topImage ?? '';
              sender = profileProvider.profileDetail!.nickname;
            }
            if(imageUrl != '') imageUrl.replaceFirst(dotenv.get('STORAGE_URL_HOST'), _uriHost.toString());
            return ListTile(
              leading: imageUrl != '' ?
                Image.network(imageUrl,
                  errorBuilder: (context, error, stackTrace) {
                    return Image.asset('images/nophotos.png',);
                  },
                ) :
                Image.asset('images/nophotos.png',),
              title: Text('$index ${_messages[index].message}'),
              subtitle: Text('$sender $createdAt'),
            );
          },
        );
      }
    );
  }
}

これでマッチングアプリに必要な機能をすべて実装することができました
お疲れ様です
動作確認を行って想定どおりに動くか確認しておきましょう

終わりに

今回作成したコードは記述の重複が多いため実運用を考える際はリファクタリングが必要かと思います
またログアウト機能や認証情報の期限切れに対するログイン画面へのリダイレクト機能も作っていません
またテストも実装していません
その他改善すべき点はまだまだありますが、マッチングアプリに絶対必要な機能を一通り作成することができたので、これにてひとまず完成ということにします
お疲れさまでした

マッチングアプリを個人開発する~Djangoバックエンドその5 決済機能実装~

この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています

前回までの記事はこちらです
shinseidaiki.hatenablog.com


前回はバックエンドのコア機能の動作確認を終えました
今回は積み残しのタスクとしての決済機能の実装を行っていきます
決済機能は不要だという方はこの章は飛ばしてもらってよいです

また、以下で紹介する実装は実運用には到底耐えないと思いますので本番運用を目指す方は更なるブラッシュアップを行ってください

目次

決済機能のStripeについての概要

今回決済機能にはStripeを利用します
Stripeとはクレジット決済機能代行サービスです
さらにクレジットのみならず Apple Pay や Google Play電子マネーの決済にもデフォルトで対応しており、豊富なドキュメントと実績があり、料金は決済時の手数料の3%のみというわかりやすさで、アプリケーション開発における決済代行機能プラットフォームのデファクトスタンダードといってもよいでしょう

ちなみに一昔前はJCBに非対応でなかなか大きな穴がありましたが今現在は対応されており、Stripeの使用にあたって突っ込みが入るところはほぼなくなっていると思います

なお初めのうちは決済機能の自作を考えるかもしれないですが、決済に関しては各種法令や業務要件・仕様が非常にシビアなため個人開発で決済機能を自作することは全く現実的ではないと思われます
素直に代行機能を利用する方が賢明だと思います

なお、今回マッチングアプリの実装で決済機能を導入する最大の目的は年齢確認のためですので、機能としては最もシンプルな実装にとどめたいと思います

アーキテクチャ設計

ではシステム構成とREST APIURIのエンドポイントとその機能をざっくり整理しておきます

システム構成図

やはり図を書いた方が何をこれからやるかがはっきりとするのでまずはお絵描きをします
図のような実装を行っていきます


画面のユーザー新規作成の段階では仮登録としてis_active=Falseであり、Emailに記載するURLからクレジット決済機能提供サービスのStripe経由でクレジット決済が完了したユーザーのみをアクティベーションします

ちなみにこれらの実装の目的としては不正なユーザーが簡単にユーザー登録をできないようにすることにあります
ただし残念ながらこの構成で目的が適うのかは私も知見がないのでわかりません
ここから先は多分大丈夫だろうという前提で実装を行っていきます

REST API設計

エンドポイント メソッド 機能内容
/api/users/{str:token_id}/payment/ GET Stripeが提供するクレジット決済決済画面にリダイレクトして、決済が成功した場合はユーザーアクティべーションのエンドポイントへリダイレクトし、キャンセルされた場合はキャンセルのエンドポイントにリダイレクトする。有効でないToken_idを指定したり、ユーザー新規作成からXX日以内にアクセスを行わない場合はStripeへのリダイレクトを行わず、決済画面へリダイレクトを行わない旨のメッセージを返す
/api/users/payment/cancel/ GET Stripeクレジット決済画面でキャンセルさせた場合にリダイレクトされてキャンセルされた旨のメッセージをレスポンスする
/api/users/{str:activate_token}/activation/ GET Stripeクレジット決済画面で決済が成功した後に呼び出されてユーザーのis_activeをtrueにすることでアクティベーションする。なおAPIとして公開されているためactivate_tokenは秘匿しておく必要がある


まとまりました
では実装をしていきましょう
ブログの説明の構成的には、アクティベーション機能とEmail機能とクレジット決済機能単位で分けた方が分かりやすいとは思いますが、コードがごちゃごちゃするので全機能をまとめてsettings.pyやmodel.pyから説明していきます
それぞれの機能の実装方法は今後切り分けたほうが分かりやすければブログにまとめたいとおもいます


各種機能を使用するための設定と環境変数を実装する

まずはEmailの機能とStripeを利用するための設定を行いましょう
Email機能はDjangoがデフォルトで用意しているものを使います

Stripeアカウント登録とStripeのインストール

Stripeアカウントを持っていない方はアカウントを作成してください
stripe.com

メール認証も済ませてアカウントをアクティベーションしてください
アカウントを作成するとダッシュボードが表示されていると思います
デフォルトでテスト環境になっていることを確認します
なお本番利用する場合はアプリサービスごとに都度申請が必要であるのでテスト環境だと思ってたら実は本番だった的なうっかり課金が起こってしまうことがないので初心者やちょっとAPIを試してみたいユーザーには心強い存在ですね

さて、アカウントを作成したらStripeパッケージをインストールします

pip install stripe

settings.pyの実装

次にDjangoでEmailとStripeを利用するための設定を行います

Email機能の設定

DjangoでEmailを利用したい場合はsettings.pyにEMAIL_BACKENDを設定すればよいことになっています
本番環境ではdjango.core.mail.backends.smtp.EmailBackendを使用しますが、テスト環境でメールの確認をしたい場合にはdjango.core.mail.backends.console.EmailBackendを用いることがコンソールに結果が出力されるのでお手軽に試したい場合はこちらを使うのが便利です
メールを実際に送信する場合はメールサーバーを用意したり、gmailを利用したりしなければならないので少し手間がかかるので基本的にはコンソール出力でテスト環境は対処します

settings.py

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # コンソールにメール内容を表示する
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # メールを送信する

またメールには送り手の情報などが必要になるので必要項目をsettings.pyに記述します
settings.py

EMAIL_HOST = env.str('EMAIL_HOST')
EMAIL_PORT = env.int('EMAIL_PORT')
DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL')
EMAIL_HOST_USER = env.str('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = env.str('EMAIL_HOST_PASSWORD')
EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS')

PASSWORDなどおよそコード上に残したくないものを設定するのでこれらの値は.envファイルに記述して環境変数化します
以下はgmailを使用する場合の設定です
EMAIL_HOST_PASSWORDはgmailの生パスワードか二段階認証を有効化した時に使えるアプリパスワードを設定します
私は横着して生パスワードで一度試しましたが、googleアカウントのセキュリティで弾かれたので、弾かれた場合はGoogleアカウントの設定(Chromeブラウザの設定ではないことに注意)>セキュリティ>安全性の低いアプリのアクセスを「オン」にすれば使用できるようなります
しかしGoogleサービスはスパム判定とかどうなってるのかよくわからず怖いのでこういったものを試すなら吹っ飛んでも大丈夫なGoogleアカウントを用意しておく方が無難な気がします
smtp.live.comというホットメールを使えるホストサーバーがあるらしい)

.env.dev or .env.prod

EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
DEFAULT_FROM_EMAIL=[YOUR.EMAIL]
EMAIL_HOST_USER=[YOUR.EMAIL]
EMAIL_HOST_PASSWORD=[YOUR.EMAIL.HOST.PASSWORD.BUT.APP.PASSWORD.FOR.GMAIL]
EMAIL_USE_TLS=True

決済機能の設定

次にStripeの設定を行います
こちらは特にSTRIPE_API_SECRET_KEYが大事です
MY_URLはStripeの成功・キャンセル時のリダイレクトに使うために使用します
STRIPE_ITEM_PRICEはマッチングアプリの開始利用料を設定します
これらも環境変数化します

settings.py

STRIPE_API_SECRET_KEY = env.str('STRIPE_API_SECRET_KEY')
MY_URL = env.str('MY_URL')
STRIPE_ITEM_PRICE = env.str('STRIPE_ITEM_PRICE')

.env.dev or .env.prod

STRIPE_API_SECRET_KEY=[STRIPE API シークレットキー]
MY_URL=http://127.0.0.1:8000
STRIPE_ITEM_PRICE=price_[STRIPE自動生成コード]

STRIPE_API_SECRET_KEYはダッシュボードの開発者向けのところで目隠しされているシークレットキーをコピーします
STRIPE_ITEM_PRICEに関してはドキュメントの販売商品の定義のところで自作した商品のコードを入力します
stripe.com

以下のようにテスト商品を作成することができるのでPRICE_IDに入った文字列をSTRIPE_ITEM_PRICEに格納する

Stripe例

line_items=[
  {
       # Provide the exact Price ID (for example, pr_1234) of the product you want to sell
        'price': '{{PRICE_ID}}', # ドキュメントで商品の値段を設定すると ------> 'price_[PRODUCT_KEY]'になるはず  
        'quantity': 1,
   },
],

設定はこれでおしまいです
次はモデルを作ります

Modelを実装する

次はモデルを実装します
仮登録ユーザーを本登録するアクティベーションの機能とメール送信機能を実装していきます
アクティベーションにはトークンを利用するのでトークンを格納するためのモデル作成していきます

アクティベーション機能の実装

現実世界で実際に運用されているWebサービスは通常、不正ユーザーの登録を防ぐために仮登録を行ってEmailの認証情報を入力させた後に本登録を完了させる実装が一般的かと思います
実装の具体的な方法はいろいろあるかと思いますが、今回は有効期限付きのトークンを発行して有効なトークンを持っているユーザーが期限内に決済を完了させた場合にアクティベーションをする実装とします

トークンを格納するためのモデルを作成する

まずは基本的なモデルを作ります
token_idは決済画面へリダイレクトするために用いてactivate_tokenは決済を通過した後のユーザーのアクティベーションのために用います
トークンとして用いるのは議論の余地がありそうですがトークンにはどちらもUUIDを使用しています
expired_atは有効期限です

models.py

class UserActivateTokens(models.Model):

    token_id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    activate_token = models.UUIDField(default=uuid.uuid4)
    expired_at = models.DateTimeField()

    objects = UserActivateTokensManager()

ここで UserActivateTokens のモデルはユーザーの時のようにマネージャークラスを使っているのでマネージャークラスを実装します

トークンとユーザーのデータを検証してアクティベーションをする機能の実装

マネージャークラスは公式ドキュメントでは「Django のモデルに対するデータベースクエリの操作を提供するインターフェイスです」と説明されていますが、分かりにくいのでモデルの特殊なメソッド置き場みたいなイメージでいいと思います
さて、マネージャーの中のactivate_user_by_tokenの処理がまさしくユーザーのアクティベーションを行っている処理です
user.is_active = Trueをしている箇所からわかると思います
また前処理としてアクティブにするユーザーは 正しいactivate_tokenと、かつ、有効期限が切れていないデータを持っているユーザーだけにフィルタリングしています
つまりactivate_token(※実際に渡してるのはtoken_id)をユーザーを新規作成した本人にしか分からない渡し方、つまりemailで渡すと、トークンはその人しか持っていないはずなので、ここでアクティベーションされるユーザーは、ユーザーを新規作成した本人のアカウントであるという認証を行うことができる仕掛けとなっています

models.py

class UserActivateTokensManager(models.Manager):

    def activate_user_by_token(self, activate_token):
        user_activate_token = self.filter(
            activate_token=activate_token,
            expired_at__gte=datetime.now() # __gte = greater than equal
        ).first()
        if hasattr(user_activate_token, 'user'):
            user = user_activate_token.user
            user.is_active = True
            user.save()
            return user
トークンを発行する処理

さて、ユーザーを作成した時に同時にトークンを発行してそのトークンをemailに乗せて送信出来たら便利ですが、Djangoにはちゃんとそのためのシグナルズ django.db.models.signals という機能が備わっています
シグナルズを利用すると何らかの処理がプログラム上で起こったときにタイミングをプログラム上で明示しなくても条件に合致した場合に自動的にあらかじめ定義したメソッドを実行してくれる機能です
例えば今回利用するようなユーザーが作成された場合に自動的にトークンを発行してメール送信するといったような実
装です
シグナルズの使い方ですが、アノテーションを利用することで使えます
アノテーションとはメソッドの手前に@がつくものです
例えば、今回使う@receiver(post_save, )のようなものです
ちなみにシグナルズと混乱するかもですが、アノテーションをつけていると今回のようなメソッドの実行タイミングの制御やパーミッションの制御などに用いることができます
コード量を減らす工夫です
そして、@receiver(post_save, sender)ですがこれをつけるとsenderに定義したモデルが保存の処理を行うたびに直下のメソッドが自動的に呼び出されます
ここではユーザーが新規作成されたりフィールドが更新されて保存されたタイミングで呼び出されています
シグナリングを使用しなくてもviews.pyに処理をゴリゴリ書いてもいいですが、シグナリングを使用する方が見通しが良くなる場合はこちらで記述するのが得策です

そして、以下がトークンを発行する処理になります
userにはシグナルズでuser.save()メソッドを呼んだつまり新規作成されたユーザーのインスタンスが入ってきます
expired_atの有効期限は現在時刻にsettings.ACTIVATION_EXPIRED_DAYSで定義した3日を足した時刻を格納しています
アクティベーションのメソッドを呼び出すのはこの日時までだよという意味にあたるのがexpired_atになります

models.py

from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def publish_activate_token(sender, instance, **kwargs):
    if not instance.is_active:
        user_activate_token = UserActivateTokens.objects.create(
            user=instance,
            expired_at=datetime.now()+timedelta(days=settings.ACTIVATION_EXPIRED_DAYS),
        )
    # .........Emailを送信する処理   

またexpired_atの日付の格納に関してもAwareやNaiveなどのややこしい話があってこの実装ではDjangoのコンソールに警告文が出てしまうのですが、今回は気にしないことにします
この点に関しては記事を少しまとめたものがあるので暇があれば読んでください
qiita.com

トークンをEmailで送信する処理の実装

Emailで送信する処理ですが、Djangoはsend_mail()メソッドを呼ぶだけでメールを送ることができます
引数の意味はぱっと見でわかると思いますので以下実装で示します
上のpublish_activate_token()メソッドの続きにEmail処理を記述して以下のようにします
メール本文に{settings.MY_URL}/api/users/{user_activate_token.token_id}/payment/を記述しており、これをクリックするとStripeの決済画面に遷移する動きになっています
またpost_saveはマネージャークラスでis_activeをtrueにするときにも反応するのでアクティベーション完了のメールも送信する設定にしました

models.py

from django.core.mail import send_mail

@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def publish_activate_token(sender, instance, **kwargs):
    if not instance.is_active:
        user_activate_token = UserActivateTokens.objects.create(
            user=instance,
            expired_at=datetime.now()+timedelta(days=settings.ACTIVATION_EXPIRED_DAYS),
        )
        subject = 'Please Activate Your Account'
        message = f'URLにアクセスして決済を完了してください。\n {settings.MY_URL}/api/users/{user_activate_token.token_id}/payment/'
    if instance.is_active:
        subject = 'Activated! Your Account!'
        message = 'ユーザーが使用できるようになりました'
    from_email = settings.DEFAULT_FROM_EMAIL
    recipient_list = [
        instance.email,
    ]
    send_mail(subject, message, from_email, recipient_list)  

ルーティングURLの実装をする

Viewの前にルーティング設定であるurls.pyの実装を行っておきます
アーキテクチャ設計のところで記載したものと同じです

urls.py

from .views import activate_user
from .views import pay_stripe
from .views import pay_stripe_cancel 

urlpatterns = [
    path('users/<uuid:token_id>/payment/', pay_stripe, name='pay-stripe'),  # Stripe決済画面にリダイレクト
    path('users/payment/cancel/', pay_stripe_cancel, name='pay-stripe-cancel'),  # 決済失敗時に実行
    path('users/<uuid:activate_token>/activation/', activate_user, name='users-activation'),  # 決済成功時にユーザーアクティベーションを実行
]

Viewを実装する

Viewの実装では決済機能とユーザーアクティベーションの処理を実装します

決済機能の実装

Stripeを利用するための実装

今回のAPI機能はDjangoで作成するモデルをメインで使うよりはStripeの決済機能を使うロジックの方が多いので、メソッドベースのAPIViewを採用します
そして以下のアノテーションをつけてアクセス制御を行っています
@api_view(['GET'])
@permission_classes([AllowAny])

また、処理に関してはほぼStripeのドキュメントをそのまま利用しています
success_urlとcancel_urlはぱっと見でわかると思いますが成功時とキャンセル時のリダイレクト先を記載するところです
こちらのアプリで想定するURLに飛ばすように設定します
success_urlにはアクティベーション用のトークンをくっつけておきます

views.py

from rest_framework.decorators import api_view, permission_classes
from django.shortcuts import redirect
from django.conf import settings
import stripe

stripe.api_key = settings.STRIPE_API_SECRET_KEY


@api_view(['GET'])
@permission_classes([AllowAny])
def pay_stripe(request, token_id):
    try:
        # ......tokensの前処理
        checkout_session = stripe.checkout.Session.create(
            line_items=[
                {
                    'price': settings.STRIPE_ITEM_PRICE,
                    'quantity': 1,
                },
            ],
            mode='payment',
            success_url=f'{settings.MY_URL}/api/users/{tokens.activate_token}/activation/',
            cancel_url=f'{settings.MY_URL}/api/users/payment/cancel/',
        )
    except Exception as e:
        return str(e)
    return redirect(checkout_session.url, code=303)
token_idを受け取ってアクティベーションさせるメソッドにはactivate_tokenを渡す処理の実装

今回token_idとactivate_tokenでトークンを分けていましたが、これはtoken_idをもしusers/{str:token_id}/activation/のようにアクティベーションURIに使ったとしたらユーザーがURLのエンドポイントの/activation/を推測できると決済を通さずにアクティベーションできてしまうというリスクがあるからでした
なので、token_idを決済画面のユーザー識別に使ってactivate_tokenをアクティベーショントークンとして使い分ける実装まで行ったStripe決済画面呼び出しメソッド pay_stripe(request, token_id) は以下のようになります
といってもマネージャークラスで実装した処理とほとんど一緒です
これでトークンが流出するかUUIDが推測でもされない限り決済をスキップされる可能性がなくなりました

views.py

from .models import UserActivateTokens

@api_view(['GET'])
@permission_classes([AllowAny])
def pay_stripe(request, token_id):
    try:
        tokens = UserActivateTokens.objects.all().filter(
            token_id=token_id,
            expired_at__gte=datetime.now()
        ).first()
        if tokens is None:
            return Response({'message': 'トークン間違いもしくはトークンの有効期限切れです'})
        checkout_session = stripe.checkout.Session.create(
            line_items=[
                {
                    'price': settings.STRIPE_ITEM_PRICE,
                    'quantity': 1,
                },
            ],
            mode='payment',
            success_url=f'{settings.MY_URL}/api/users/{tokens.activate_token}/activation/',
            cancel_url=f'{settings.MY_URL}/api/users/payment/cancel/',
        )
    except Exception as e:
        return str(e)
    return redirect(checkout_session.url, code=303)
Stripeで決済をキャンセルしたときの処理の実装

キャンセルされたときの処理です
メッセージを返すだけです
Djangoで画面も作る実装の場合はHttpResponseを返しますが、今回のようにバックエンド特化のREST APIの場合はResponseを返すという違いがあります

views.py

from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response

@api_view(['GET'])
@permission_classes([AllowAny])
def pay_stripe_cancel(request):
    return Response({'message': '決済がキャンセルされました'})
ユーザーアクティベーションの処理の実装

決済が成功した場合はユーザーのアクティベーションを行うメソッドがリダイレクトされて呼び出されます
UserActivateTokens.objects.activate_user_by_token(activate_token)がアクティベーションの処理を呼び出しています
大まかには決済が成功して想定通りユーザーがアクティベーションされればactivated_userが返ってきて、何らかの障害やエラーが発生した場合には失敗のメッセージを送る想定としています

ただしこのメソッドは決済が絡んでくるためサービスの品質として絶対に落としてはいけないものであるので、本番環境ではこの処理の失敗時に特別なログを出力させて管理者に通知する実装をとった方が良いと思われます
余力があればそちらの実装も行っていきたいと思います

views.py

from .models import UserActivateTokens
from rest_framework.decorators import api_view, permission_classes


@api_view(['GET'])
@permission_classes([AllowAny])
def activate_user(request, activate_token):
    activated_user = UserActivateTokens.objects.activate_user_by_token(activate_token)
    if hasattr(activated_user, 'is_active'):
        if activated_user.is_active:
            message = {'message': 'ユーザーのアクティベーションが完了しました'}
        if not activated_user.is_active:
            message = {'message': 'アクティベーションが失敗しています。管理者に問い合わせてください'}
    if not hasattr(activated_user, 'is_active'):
        message = {'message': 'エラーが発生しました'}
    return Response(message)

このメソッドではGETメソッドを使っていますが、Userモデルの変更を伴っているのにGETで呼び出していいのかなと思いつついい方法が思い浮かばなかったのでこの実装にしています

動作確認

さて簡単に動作を確認していきます
まずはDBの変更を伴っているのでマイグレーションを行ってから起動しましょう

py manage.py makemigrations
py manage.py migrate
py manage.py runserver

ユーザー作成時のトークン生成とEmail通知の確認

POSTMANでhttp://127.0.0.1:8000/api/users/create/にアクセスして、アカウントを作成しましょう
するとコンソールに次のようなEmailが返ってくることが確認できると思います

Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit
Subject: Please Activate Your Account
From: super@user.com
To: user5@user.com
Date: Sat, 19 Feb 2022 11:27:48 -0000
Message-ID: 
 <***********************************************>

URLにアクセスして決済を完了してください。
 http://127.0.0.1:8000/api/users/[token-id-uuid]/payment/
-------------------------------------------------------------------------------
[19/Feb/2022 20:27:48] "POST /api/users/create/ HTTP/1.1" 201 84

また管理画面でもユーザーが作成できていて、is_activeがFalseになっていることを確認します
またトークンが発行されていることも見ておきましょう
トークンテーブルを見るのはadmin.pyをいじって管理画面にテーブルを増やすか、CB Browswe for SQLiteのようなUIで操作が出来るフリーのツールをインストールしてきて、プロジェクトフォルダのdb.sqlite3を開けば確認することができます
トークンも同時に生成されていることが分かりました

Stripe決済機能とアクティベーション機能の確認

さて、emailの本文にあるhttp://127.0.0.1:8000/api/users/[token-id-uuid]/payment/にアクセスしましょう
Stripeの画面にリダイレクトしていることを確認します

さてテスト用の番号は以下のようになっているので試してみましょう

支払いが成功しました 4242 4242 4242 4242
支払いには認証が必要です 4000 0025 0000 3155
支払いが拒否されました 4000 0000 0000 9995

stripe.com


4242 4242 4242 4242を入力してその他の項目も適当に埋めてOKを押します

チェックがかかりいつものDjangoのブラウザのRest API実行環境にリダイレクトされます
アクティベーションが成功した旨のメッセージが出ていれば成功です

{
    "message": "ユーザーのアクティベーションが完了しました"
}

逆にOKボタンを押さずに戻るボタンを押すなどするとキャンセルが起こります
キャンセルのリダイレクトも確認しておきましょう

Stripeを使えない場合のトラブルシュート

以下のようなエラーが発生した場合は、Stripeのアカウント名を変更する必要がある

InvalidRequestError at /pay/checkout/
In order to use Checkout, you must set an account or business name at https://dashboard.stripe.com/account.

ダッシュボードにアクセスし、プロフィール>アカウントのところで「名称未設定」のアカウントと表示されていればアカウント名を登録する
登録方法は 設定(歯車アイコン)>アカウントの詳細>アカウント名 で好きなアカウント名を入力する
保存を押すことでアカウント名は保存される
これで再度実行するとできるはずです

決済を通さないで不正にアクティベーションの実行を試みた場合に失敗するかを確認する

存在しないもしくは適当に自身で生成したuuidをactivate-token-uuidの部分に代入してアクティベーションを試みます
http://127.0.0.1:8000/api/users/[発行されていないactivate-token-uuid]/activation/

{
    "message": "エラーが発生しました"
}

失敗のメッセージが返ってきて、どのユーザーも勝手にアクティベーションされていないことを確認しておきます


これで、動作確認は終了しました
お疲れ様です


次回予告

これで実装したいバックエンドの機能は完成しました
次は変更に備えてテストと行きたいところですが、まずはフロントエンドの画面を先に作ろうと思います
テストは少し後回しにしたいと思います


次回記事

shinseidaiki.hatenablog.com

マッチングアプリを個人開発する~Djangoバックエンドその4 動作確認~

この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています

前回までの記事はこちらです
shinseidaiki.hatenablog.com


前回はREST API作成まで終わりました
今回は実装したREST API機能がちゃんと動くかを動作確認していきます

目次

動作確認

それではAPIが想定通り正しく動作するか確認を行っていきましょう
DjangoRest APIのテスト実行環境も用意されておりサポートが手厚いのでここでもDjangoにおんぶに抱っこされながら動作確認をしていきましょう

DjangoREST APIテスト実行環境を使用するにあたって必要なことはサーバーを起動して/api/のエンドポイントにアクセスするだけです

さてそれではPyCharmの△ボタンを押してサーバーを起動しましょう

確認するエンドポイントの整理

確認するエンドポイントの整理をしておきましょう
前回の記事でURL設計を作成しましたが、この動作確認ではそのエンドポイントの各挙動を確認していきます
これらは後でテストケースとしても使用できます

エンドポイント メソッド 確認内容
/api/authen/jwt/create POST ログイン後JWTトークンが返ってくる
/api/users/create/ POST 認証なしでユーザー新規作成ができる
/api/users/{pk}/ GET, PATCH pkで指定したユーザーの情報が取得できることを確認する。pkで指定したユーザーのemailとパスワード更新ができてパスワードはハッシュ化されている。ただしpkは自分自身に限り、他人のpkは使用しても何もできないことも確認する。PATCHのみの使用を想定し、PUTの使用は想定しない
/api/users/profile/{pk} GET, PUT, PATCH pkで指定したユーザーのプロフィール情報の取得ができることを確認する。pkで指定したユーザーのプロフィール情報の更新ができることを確認する。ただしpkは自分自身に限り、他人のpkは使用しても何もできないことも確認する。
/api/profiles/ CREATE, GET ユーザーのプロフィールを作成できることを確認する。すでに自身のプロフィールが作られている場合は作成できないことを確認する。異性だけがフィルタリングされたプロフィール一覧が取得できることを確認する。自分のプロフィールがない状態では誰のプロフィールも取得できないことを確認する
/api/profiles/{pk} GET pkで指定したユーザーのプロフィールが取得できることを確認する。このエンドポイントでは更新(UPDATE)はできないことを確認する
/api/favorite/ CREATE, GET いいねすなわちマッチングレコードが作成できることを確認する。approachingとapproachedが自分であるようにフィルタリングされたマッチングモデルのデータ一覧を取得できることを確認する。
/api/favorite/{pk}/ GET, PATCH approvedをtrue, falseに変更することができることを確認する。PATCHの利用を想定しUPDATEは想定しない
/api/dm-message/ CREATE, GET approavedがTrueでかつapproach"ed"が自分であるマッチングデータが存在する場合にDMデータが作成できることを確認する。senderが自分自身のDMだけが一覧で取得されることを確認する。
/api/dm-message/{pk}/ GET, UPDATE pkで指定したDMを取得できる。DMのメッセージを変更できる。PATCHの利用を想定しUPDATEは想定しない。
/api/dm-inbox/ GET receiverが自分のDM一覧が取得できることを確認する。
/api/dm-inbox/{pk}/ GET pkで指定したDMを取得できる。更新はできないことを確認する


/api/authen/jwt/create/と/api/users/create/以外はJWTトークンなしではアクセスを拒否されることも確認しておく


ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)

サーバ起動後まずはhttp://127.0.0.1:8000/api/にアクセスしましょう
以下のような画面が表示されているはずです
f:id:shinseidaiki:20220212174953p:plain

認証情報が含まれていないといわれて接続を拒否されているのはデフォルトでログインユーザーのみしかAPIにアクセスできないとしているため正しい挙動をしていることが分かります

それではまずはユーザーを作成してそのユーザーのJWTの認証情報を手に入れましょう
手順は以下です

ユーザー新規作成

http://127.0.0.1:8000/api/users/create/
にアクセスします
このエンドポイントはあらゆるアクセスを許可しているのでアクセスできるはずです
以下のように任意のユーザーを作成しましょう

f:id:shinseidaiki:20220212175550p:plain

POSTの送信ボタンを押すとレスポンスが返ってきます

{
    "email": "user1@user.com",
    "username": "user1",
    "id": "uuid-665e........................................."
}

さて管理画面で作成できていることを確認しましょう
http://127.0.0.1:8000/adminのusersを確認しましょう
作成したユーザーがあればよいです

そしてここで、本アプリの特性ですが、作成したユーザーのis_activeはデフォルトでFalseとしていたので管理画面上で今作ったユーザーを使えるようにアクティベーションしておきます
is_activeフィールドを見つけてチェックを入れておいてください

f:id:shinseidaiki:20220212180106p:plain

ログイン認証処理とJWT発行

ではユーザーが作成できたので、先ほど作ったemailとpasswordを使用してJWTトークンを発行しましょう
Djoserのモジュールを今回利用しているので/authen/jwt/create/のエンドポイントに正しいemailとpasswordをリクエストボディに詰めて送信すると認証が成功しJWTトークンがレスポンスのaccessパラメータに格納されて返ってきます
返ってきたaccessのパラメータの値をトークンといい今回はJWTトークンという名前がついています
JWTの認識をつけるためにトークンに接頭辞としてJWT をつけるようにsettings.pyで設定しているのですが、取得した[JWT Token] をその後のリクエストに「JWT [JWT Token]」とした値でくっつけて送信すると認証済みユーザーとしてアクセスを許可する仕組みとなっています
この流れがいわゆる世間でいうログインです

それでは動作確認していきますが、ここで新しいToolを使用します
POSTMANというAPIのテスト実行環境を用意してくれるソフトウェアを利用します
まずはインストールして起動してください
www.postman.com

これはDjangoの用意してくれているREST API実行環境と同じようなものなのですがPostmanの方がより機能が多く代表的な機能としてはリクエストボディにパラメータを詰めてリクエストを投げることができます
それではPostmanの以下のような画面を開きます
f:id:shinseidaiki:20220212181531p:plain

必要な項目を埋めていきます
メソッド:POST
URL:http://127.0.0.1:8000/authen/jwt/create/
body>form-data: email, password の各値
を正しく入力してSendを押します

するとjsonが返ってくるはずです
このaccessの方の値を控えておきます

{
    "refresh": "........................................................",
    "access": "jwttoken........................................."
}

値を控える際は以下のように「JWT」の文字を接頭語に着けて控えておきます
後で使用します
「JWT jwttoken.........................................」

さて、JWTが発行されたので先ほど作成したuser1はログイン済みということになりました
では先ほどアクセスを拒否された/api/エンドポイントに再度アクセスしてみましょう

JWTを使用したアクセス認証

ここでまた別のツールを利用します
今回使用するのはテスト環境で利用するためのトークンであるため、ほかのサービスに使うような本物のトークンと混じってしまうと大変です
しかしそうならないためのサービスがグーグルクローム拡張機能にあります
ModHeaderという拡張機能をインストールしてください
インストールして有効化すると右上に表示されます
これを利用していきます

f:id:shinseidaiki:20220212182355p:plain

RequestHeaderに先ほど控えたJWTトークンを貼り付けてトークンを使用するチェックを付けます
f:id:shinseidaiki:20220212182638p:plain
f:id:shinseidaiki:20220307132017p:plain

これでトークンがリクエストヘッダーにくっついてくるようになりました
ではhttp://127.0.0.1:8000/api/にアクセスしましょう
すると先ほどとは違ってapiの他のエンドポイントを見せてもらえるようになりました
これで認証が通っていることが確認出来ました
成功です

f:id:shinseidaiki:20220212182747p:plain

それではこの認証情報を使いながら他のエンドポイントの動作確認を行っていきましょう

ログイン中のユーザー情報の取得と更新(/api/users/{pk}/)

それでは次にユーザーの情報を取得・更新できるエンドポイントが想定通り動作するか確認していきます
このエンドポイントを利用するためにはユーザーのIDが必要になるのでIDが分からない人は管理者画面にアクセスしてユーザーのIDを引っ張って決ましょう
管理画面の一覧にIDを表示させている場合はこちらから
EMAIL ID パスワード
user1@user.com uuid-......................... hashed-password............

表示させるのを忘れている人は詳細更新画面でのURLからも引っ張ってくることができます
http://127.0.0.1:8000/admin/basicapi/user/[uuid-..........................]/change/

IDを控えたら/api/users/{pk}/のpkに控えたIDを入力してアクセスしましょう
アクセスできていたこのような画面になっているはずです
f:id:shinseidaiki:20220212194831p:plain

下の入力フォームを使っても変更できますが、ユーザー情報はemailもユーザー名もパスワードも別々の画面を経由して編集することが多いと思いますので、差分更新のPATCHを利用して更新できるかを確認してみようと思います

なお差分更新のPATCHはDjango REST FRAMEWORKの画面では起動できないようなので、PATCHに関してはポストマンで動作確認しましょう
ちなみにPATCHの実行はPUTにpatial=trueを引数に持たせて実装しているようなので、Djangoの実行環境でもがんばったらもしかしたらできるかもしれないですね

emailとユーザー名のPATCH変更

まずはemailの変更ができるかを試します
ポストマンで先ほどのように必要な項目を埋めていきます
メソッド:PATCH
URL:http://127.0.0.1:8000/api/users/{pk}
body>form-data: email, username の各値
を正しく入力してSendを押します
すると更新後に再度GETが実行されてユーザー情報が更新されたjsonデータが返ってきます

f:id:shinseidaiki:20220212195637p:plain

管理画面でも更新されていることが確認しておいてください

emailとユーザー名のPATCH変更

次にpasswordの変更ができるかを試します
ポストマンで先ほどのように必要な項目を埋めていきます
メソッド:PATCH
URL:http://127.0.0.1:8000/api/users/{pk}
body>form-data: password の値
を正しく入力してSendを押します
パスワードは書き込み専用なのでjsonでは返ってこないので管理画面で更新されていることを確認します
確認ポイントとしてはちゃんとパスワードがハッシュ化されていることとパスワード変更前のハッシュ値と変更後のハッシュ値が違うことを確認します
また/authen/jwt/create/のJWT認証エンドポイントで新しく変更したemail と passwordで認証が通るかを念のために確認するのもよいでしょう

f:id:shinseidaiki:20220212200243p:plain

他のユーザーのidをpkに入れても見れないことを確認する

APIからでも管理画面からでもどちらでもいいですが、別のユーザーを作成しましょう
ちなみにこういったデータ動作確認作業に管理画面(/admin)は大変重宝します
自分でいろいろいじってみてください

さて実行しましょう

GET /api/users/[別ユーザーuuid]

以下のように404が確認できれば確認OKです

HTTP 404 Not Found
Allow: GET, PUT, PATCH, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "見つかりませんでした。"
}

ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)

それでは次にユーザーの情報を取得・更新できるエンドポイントが想定通り動作するか確認していきます
このエンドポイントを実行するにはプロフィールが存在しないといけないので、管理画面で先ほど作ったユーザーのどちらでもいいので、Profileクラスのデータを作成してください
なお、今回管理画面ではUserにProfileクラスをインクライン(挿入)しているためUserの中でProfile項目を編集できるようにしています
Profileクラスの詳細画面でProfileデータを追加してもいいですが、外部キーを入れるなど誰のデータをいじっているかわからなくなったりするのでadminにインクラインの設定を加えて作業しやすくしておくことは結構大事だったりします

ユーザー情報取得の確認(GET)

さてプロフィールを作成したらユーザーのUUIDをpkに入力して/api/users/profile/{pk}/にアクセスしましょう
なお、ログインしていない方のユーザーを使っている場合は404が出るはずなのでJWT認証の作業をもう一度行ってください

さてアクセスが成功して以下のような結果が返ってくればデータ取得はうまくいっていることが確認できました

HTTP 200 OK
Allow: GET, PUT, PATCH, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "user": "uuid-.............................",
    "is_special": false,
    "is_kyc": false,
    "top_image": "http://127.0.0.1:8000/media/images/top_image/[path]",
    "nickname": "ユーザー1_男性",
    "created_at": "2022-02-12 02:26:00",
    "updated_at": "2022-02-12 03:24:18",
    "age": 20,
    "sex": "male",
    "height": null,
    "location": null,
    "work": null,
    "revenue": null,
    "graduation": null,
    "hobby": null,
    "passion": "slowly",
    "tweet": null,
    "introduction": "",
    "send_favorite": 0,
    "receive_favorite": 0,
    "stock_favorite": 1000
}
ユーザー情報更新の確認(UPDATE)

更新ができるかも確認しましょう
/api/users/profile/{pk}/でアクセスしている画面の下にフォームがあるので、そちらを変更してPUTを押して想定通りに変更されているかを確認します
上記と同様に変更後のデータがJSONで返ってくるので変更した内容に書き換わっているかを確認できれば成功です

ログイン中のプロフィール新規作成と異性プロフィール一覧取得(/api/profiles/)

プロフィール新規作成の確認(CREATE)

さてプロフィールの新規作成確認をしましょう
プロフィールをすでに作成してしまっている場合は新しくユーザーを作るなどしましょう
別のユーザーを使って動作確認する場合は別ユーザーのJWTを取得してModHeaderで切り替えて利用していきましょう

http://127.0.0.1:8000/api/profiles/ にアクセスします
プロフィールがない状態では空リストが表示されていることが確認できます

ではプロフィールを埋めていきましょう
ここで年齢などのバリデーションが効いているかも同時に確認しておきましょう
特に未成年チェックは必須です

作成した後にもう一度作成しようとしてみるとエラー(IntegrityError)で返されることも確認する
念のために作成されたユーザーは最初に入力したデータのままで作成されているか管理画面でも確認しておきましょう

異性をフィルタリングしたプロフィール一覧取得の確認(GET)

上記でプロフィールを作成したら再度http://127.0.0.1:8000/api/profiles/にアクセスします
ログインさせているユーザーの性別と違うユーザーだけが表示されていれば確認は完了です
同じことですがデータがない場合は新たに作成して確認みてください

プロフィール取得(/api/profiles/{pk})

プロフィールのあるユーザーを使用して/api/profiles/{異性ユーザーのUUID}にアクセスします
取得できれば成功です
なお、自身と同性ユーザーは取得できないことも確認しておきます

またPUTボタンを押して更新できないことを確認します

マッチングデータ作成・マッチング一覧取得(CREATE, GET /api/favorite/ )

次にマッチング機能(いいね機能)の動作を確認していきます
/api/favorite/にアクセスします
approachedでユーザーを選択してマッチングデータを作成します
ユーザは同性も選べますが異性を選んでおきましょう
POSTで作成が終わると再び同じURLでGETを実行しましょう

他にもマッチングデータを作成して想定通りのデータが取得できているか確認しましょう
自分に関連するデータだけが取得されているかを確認できれば成功です

マッチングデータ取得・更新(GET, PATCH /api/favorite/{pk})

次にマッチング機能(いいね機能)の更新処理の動作を確認していきます
/api/favorite/{自分と関連するマッチングデータのpk}にアクセスします
今回アクセスするマッチングデータのIDはUUIDではなくIntであることに注意します
マッチングデータのIDはhttp://127.0.0.1:8000/api/favorite/にアクセスしたときに表示されているIDから取得するか、管理画面の一覧画面にadmin.pyのlist_displayに'id'を追加するか、詳細画面の○○/{pk}/changeのpkを見ることでも取得できます

正しくアクセスできてPUTで更新処理が行えれば成功です

また/api/favorite/{自分と関連しないマッチングデータのpk}にアクセスすると404 Not Foundがレスポンスされることも確認しておきます

DM送信側処理(/api/dm-message/ )

次にDMの送信側の処理を動作確認します

送信側DM一覧取得(CREATE, GET)

approvedがTrueでapproachedに自分を指定しているマッチングデータのapproachingに対応するユーザーをreceiverに選択してmessageデータを作成するとデータが作成できてsenderに自分自身が入力されていることを確認します
またsenderが自分自身のmessageデータ一覧を取得できることを確認します

DM送信取得・更新取得(GET, PATCH /{pk}/ )

次に/api/dm-message/{自身がsenderになっているDMのpk}/にアクセスして送信済のデータを取得できるかを動作確認します
senderが他人のpkの場合は404 Not Foundであることも確認する
また、POSTMANで/api/dm-message/{自身がsenderになっているDMのpk}に対してPATCHを用いてメッセージの修正を目的とした更新が行えるかを確認する
なおJWTトークンをリクエストヘッダーに含めることを忘れないように

DM受信側一覧取得(GET /api/dm-message/, /api/dm-message/{pk})

次にDMの受信側の処理を動作確認します
まずは/api/dm-message/にアクセスしてreceiverが自分を差しているDMの一覧が取得できるか確認する
次に/api/dm-message/{自分がreceiverであるDMのpk}にアクセスしてDMが取得できるか確認する
receiverが自分ではないDMは404 Not Foundが返ってくることを確認する

トークン期限切れ確認

トークンの有効期限は今回1日に設定しているので期限切れで/api/のエンドポイントにアクセスするとアクセスが拒否されることを確認する

GET /api/

HTTP 401 Unauthorized
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
WWW-Authenticate: JWT realm="api"

{
    "detail": "Given token not valid for any token type",
    "code": "token_not_valid",
    "messages": [
        {
            "token_class": "AccessToken",
            "token_type": "access",
            "message": "Token is invalid or expired"
        }
    ]
}

これで動作確認は終了です
想定通りの挙動になっていない場合はデバッグを行いましょう

次回予告

動作確認を終えて、これにてバックエンドの機能はほとんど完成しました
ところで本アプリは新規ユーザー作成時にアカウントをデフォルトで非アクティブにしているので次はアカウント作成時にメールを送信し、メール中に記載したURLからクレジットカード決済画面(Stripe)にアクセスして、決済が成功したユーザーをアクティブとする処理を加えていきましょう

なお対面などで年齢確認を行ってユーザーをアクティブにする運用の場合はクレジットカード決済を使用せずにその都度管理者が管理画面でユーザーのアクティベーションを手動で行うという方法もあるかもしれない


続きはこちらです

shinseidaiki.hatenablog.com