LoginSignup
90
66

More than 3 years have passed since last update.

Flutter & Firebase でレシピ管理アプリをリリースした話

Last updated at Posted at 2020-12-11

Flutter & Firebase でレシピ管理アプリをリリース

Flutter & Firebase でレシピ管理アプリ「シンプルなレシピ」をリリースしました!

検索画面 レシピ画面 調理時の閲覧画面
mock-top.png mock-recipe.png mock-recipe-detail.png

※ iOS 版のリンクが環境やタイミングによって、403エラーを返すことが度々あるようです。そのような場合は、こちらもご確認下さい。

投稿の概要と背景

「シンプルなレシピ」は、私も参加している「KBOY の Flutter 大学」のオンラインサロンで、2020年10月頃に始まった共同開発プロジェクトの一つとして、発案者である私に加え、5人のオンラインサロンのメンバーが参加して開発を行った、Flutter と Firebase によるレシピ管理アプリです。関わり方やその密度はメンバーそれぞれで異なりましたが、都合がつくときには週に2回のミーティングをし、共同開発に関係のあることもないことも話しながら、少しずつ一緒に進めることができて非常に楽しかったです。5人の参加者の皆さんと、相談に乗ってくれたり、情報共有をしてくれたりしたオンラインサロンの皆さんへの感謝を表明します。ありがとうございました。

この投稿では、「シンプルなレシピ」の開発の背景に関する話をしつつ、部分的に実際の機能を紹介しながら Flutter や Firebase でそれらをどのように実装したのかということをまとめたいと思います。

私にとってこの「シンプルなレシピ」は、Flutter を用いて開発・リリースした2つ目のアプリです(経験や知見が相当豊富にあるという訳ではありません)。しかし、私が「KBOY の Flutter 大学」の YouTube チャンネルを見ながら Flutter や Firebase の学習を始め、その他にも学習に有益な情報をオープンに発信して下さっているディベロッパー・クリエイターの皆さんや、Google 公式の情報や資料を参考にしてアプリ開発を進めたように、いま Flutter を学習している方やこれから始める方の参考になればと思い本記事を投稿し、(まだまだ改善したいところはあるのですが)アプリのソースコードも Github のパブリックリポジトリとして公開します。

「シンプルなレシピ」の Github リポジトリ

また、もしソースコードやアプリの実装内容についてご興味をもたれたり、ご質問のある方などがいらっしゃいましたら、私のTwitter までフォロー・ご連絡を頂ければと思います。

「シンプルなレシピ」について

まずはじめに、どのようなきっかけで「シンプルなレシピ」を開発することに決めたのか、その発案の背景について書きます。

私は1年ほど前からひとり暮らしをしており、いろいろなレシピ投稿サービス(クックパッドやクラシル)や YouTube 動画(リュウジのバズレシピ が一番のお気に入りです)を観ながら、冷蔵庫に入っている材料またはスーパーですぐに買えそうなもので作れそうか、また料理のレベル的に自分にもできそうかどうかなどを考えながら、初心者なりに色々な料理をしてきました。気に入った料理は数日間連続して食べても飽きないタイプではあるのですが、コツコツ自炊をしてきたので50くらいは作ったことのあるレシピの数が増えていきました。

例えば以前作ったお気に入りの唐揚げのレシピをもう一度作ろうかな、となった時に再度10〜15分くらいの YouTube 動画を見返すのは時間がかかりますし、キッチンでスマホの YouTube アプリの動画をストップしたり再開したりしながら調理するのは大変です。

また、既存のレシピ投稿アプリを見てみると、綺麗な写真や手順を分かりやすくまとめたショートムービーが複数含まれていてレシピを探すときにはとても参考になるのですが、材料欄と作り方の手順の説明欄が離れたところに記載されていて、作り方の手順の説明欄には「まず、★の材料 XXX して、...」とか「次に、◎の調味料を YYY して、...」といった記述となっている場合もあるため、「★の材料ってなんだっけ....?」と、調理中にもスマホ画面をスクロールして見るべき情報を探す必要があります。手が濡れたり汚れたりしているので、調理中にスマホを触って画面をスクロールするのは私にとっては不満を感じてしまうポイントでした。

つまり、それらのメリット・デメリットをまとめると次のような感じです。

種類 メリット デメリット
YouTube などの動画 • 視覚情報として具体的な作り方や手順が閲覧できるので、初めて作る際にはとても参考になる
• 自分に合ったお気に入りの YouTuber のレシピ動画は、見ているだけで楽しい
• 一度作ったレシピを再度作る際には、動画を見返すのが手間に感じられる
• 調理中にキッチンにスマホを置いて作り方を参照するには不向きである
レシピ投稿アプリ • 写真やショートムービーを参考にして、作ってみたいレシピを探すのには大変便利である
• 材料欄が整理されているので、スーパーでの買い物中に見ると分かりやすい
• 材料欄と作り方の手順の説明欄が離れているので、調理中の手が濡れたり汚れたりした状態では使いづらい

そこで、後で見返して再度作る時のためのレシピの記録として、しばらくLINE の自分ひとりのグループチャットの Note 機能に、下図のスクリーンショットのような形式で、1枚だけの写真と、材料と作り方を区別することなく書いた簡潔なレシピの手順を記録していました。

唐揚げ キーマカレー 塩ラーハン
image 1.png image 2.png image 3.png

例として過去に私が書いた豚キムチのレシピを引用します。

まず豚こま肉を200g程度、塩胡椒と薄力粉でコーティングして下準備する。ニンニクは2片ほどを荒みじん切り、玉ねぎ半分を薄めのスライスにしておく。
温めたフライパンにニンニクを入れて火を通し、柴犬色になったら肉をよく広げながら入れる。全体に火が通ってきたら玉ねぎを入れて、透明になるまで炒める。そこにキムチを投入して、酒大さじ1、醤油小さじ1、佐藤小さじ1、めんつゆ少々を加えて、水分が少し減るくらいまで炒める。
ご飯によく合う味で美味しいぞ!!

このような方法であれば、レシピの内容はスマホのディスプレイの縦幅 (1vh) に大抵の場合収まるので、調理中にはこの画面を表示させたスマホをキッチンに置いておくだけで、濡れたり汚れたりした手でスクロールする必要もありません。材料を表にしてまとめることや、手順を細かく追った詳細な説明は行なっていませんが、過去に自分で作ったことのあるレシピであることが前提なので、具体的で詳しい情報は、自分が再度料理をする上ではそれほど重要ではありません。

また、「自分用のメモ」といった意味合いで始めたこの LINE の Note でのレシピの記録でしたが、お気に入りのレシピになったものや自分なりにアレンジを加えたレシピは時々友人に紹介することもあり、そのような時は画面キャプチャを LINE で送って共有していました。

LINE の Note を使ってのレシピの記録という方法は、上記の通り自分の考え方や習慣にフィットしていたのでしばらく続けていたのですが、レシピ数が30を超えてきた辺りから、過去に自分が作ったレシピを見返すのが大変になったり、お気に入りだったのにしばらく作らないとそのようなレパートリーがあることを忘れかけてしまったりすることが増え、さらには、例えば冷蔵庫に玉ねぎが余っている時に、過去に作ったことのある玉ねぎを使うレシピを検索したいな、といったユースケースにも対応したいと思うようになりました。

長くなりましたが、以上が今回「シンプルなレシピ」というアプリを作ることに決めた背景です。

機能や実装内容の一覧

つまり、「シンプルなレシピ」の機能要件・非機能要件として

  • 「わたしのレシピ」として、レシピの投稿を、最大1枚の写真、レシピ名、材料名を含む作り方の手順(分けて書かない)くらいのシンプルなフォーマットで簡単にできること
  • その中で、他人に公開しても良いと思ったものについては、「みんなのレシピ」にも公開できること
  • 「わたしのレシピ」や「みんなのレシピ」の中で、「またすぐに見返せる状態にしておきたい!今度作ってみたい!」と思うレシピにアクセスしやすくする「お気に入りのレシピ」の機能があること
  • レシピ名および材料名で検索をして探したいレシピを「わたしのレシピ」「みんなのレシピ」「お気に入りのレシピ」から絞り込めること

といったポイントが挙げられました。

主に使った技術やフレームワークは、Flutter, Firestore, Firebase Authentication, Cloud Storage, Cloud Functions, Docker などですが、各機能や実装内容をまとめると下記のようになります:

  • アプリの UI の実装に必要な様々な Flutter ウィジェットの実装
  • Provider, ChangeNotifier を用いた Stateless ウィジェットによる状態管理
  • development, staging, production の 3 つの Flavor と debug, release の 2 つのビルドモードに応じた各環境および Firebase プロジェクトで、 iOS, Android の両方のビルドを行うための環境構築
  • 認証機能 (Firebase Authentication)
  • 画像を含むレシピの投稿, 更新, 削除機能
  • 投稿する画像のトリミング・圧縮機能
  • レシピの検索機能(N-gram を用いたサードパーティを使わない Firestore による全文検索機能の実装)
  • レシピの公開・非公開の状態管理機能
  • レシピの無限スクロール機能
  • レシピのお気に入り機能(Flutter の Stream を用いたお気に入りステイタスの監視を含む)
  • レシピの更新時などのバッチ処理の機能
  • 認証認可・スキーマ検証・バリデーションに分けた Firestore Security Rules の実装およびテストの実装
  • Cloud Storage の Security Rules の実装
  • Docker を用いた Firebase CLI の環境構築
  • ユーザー登録やお問い合わせを管理者に通知する機能 (Cloud Functions)

機能と実装内容の紹介

それでは「シンプルなレシピ」の機能や実装内容について、いくつか具体的に説明・紹介します。

1. Card() ウィジェットによる整ったレシピ情報の UI の実装

今回開発したレシピ管理アプリのコンテンツの中心は当然「レシピ」です。そのレシピについて「わたしのレシピ」「みんなのレシピ」「お気に入りのレシピ」の3つの種類分けが存在し、レシピを閲覧したり、投稿したり、公開したり、編集したり、削除したり、というアクション・働きかけが存在します。OOUI (Object Oriented User Interface) と言うと大げさですが、コンテンツの中心であるレシピというオブジェクトを分かりやすく整然と表示するために、Card() ウィジェットを ListView() で並べることにしました。

ListView(
  children: [
    Card(
      child: Text('カード'),  // カードの内部に表示するウィジェット
    ),
  ],
)

カードの内部は、

  • 左側:レシピ名、お気に入りアイコン、レシピ抜粋、更新日などの情報部分
  • 右側:レシピのサムネイル画像部分

のように左右に2分割するので、Card() ウィジェットの子には Row() ウィジェットを使用します。

さらに、左側のレシピ名、お気に入りアイコン、レシピ抜粋、更新日などの情報は縦に並べるので、最初の Row() の子ウィジェットのひとつ目の子に Column() を指定します。

異なる画面サイズのデバイスでもレイアウトが崩れないように、各ウィジェットの横幅は他のウィジェットの横幅や指定した余白の大きさを考慮・計算しながら、MediaQuery.of(context).size.width で取得できるデバイスの横幅に対して相対的な大きさで指定すると良いでしょう。また、たとえば料理名が長いレシピの場合にも、レシピ名の欄のレイアウト崩れが起きないように、maxLines: 1overflow: TextOverflow.ellipsis のようなプロパティを Text() ウィジェット内に指定して、規定の行数を超える長さの場合は「...」で省略できるようにします。

対応するファイル に記載している通りですが、その他の調整なども含めて、以下のように実装すると、添付のスクリーンショットのようなレシピのカードでの表示が可能となります。興味のある方は試してみて下さい。

ListView(
  children: [
    Card(
      // カードの影を消す
      elevation: 0.0,
      // カードの枠線の調整
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(5.0),
        side: BorderSide(
          color: Color(0xFFDADADA),
          width: 1.0,
        ),
      ),
      child: Container(
        padding: const EdgeInsets.all(16.0),
        // カード内部を大きく左右に2分する Row() ウィジェット
        child: Row(
          children: [
            // 左側の情報部分
            Container(
              padding: const EdgeInsets.only(right: 8.0),  // 左画の上方部分の右端の余白(好み)
              SizedBox(
                width: MediaQuery.of(context).size.width - 148;  // 148:各ウィジェットの横幅や余白の大きさにを考慮して調整した値
                height: 100,
                // レシピ名、お気に入りアイコン、レシピ抜粋、更新日などの情報を縦に並べる
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.start,  // 垂直方向上に整列
                  crossAxisAlignment: CrossAxisAlignment.start,  // 水平方向左に整列
                  children: [
                    // レシピ名とお気に入りアイコン
                    Container(
                      height: 26,
                      child: Row(
                        children: [
                          // レシピ名
                          Container(
                            width: MediaQuery.of(context).size.width - 184,
                            child: Text(
                              'ここにレシピ名を表示する',
                              maxLines: 1,
                              overflow: TextOverflow.ellipsis,  // 1行以上になるレシピ名は「...」で末尾を省略する
                              style: TextStyle(fontSize: 16),
                            ),
                          ),
                          // お気に入りアイコン
                          Container(
                            padding: const EdgeInsets.only(
                              top: 4.0,
                              left: 8.0,
                              right: 8.0,
                              bottom: 4.0,
                            ),
                            Icon(
                             Icons.favorite,
                             size: 18.0,
                            ),
                          ),
                        ],
                      ),
                    ),
                    SizedBox(height: 4.0),  // レシピ名とレシピの抜粋の間の余白
                    Container(
                      height: 50,
                      child: Text(
                        'ここにレシピの内容の抜粋が表示される',
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(fontSize: 14),
                      ),
                    ),
                    SizedBox(height: 4.0),  // レシピの抜粋と更新日の間の余白
                    Container(
                      height: 16,
                      Text(
                        '更新:2020-12-10 (木)',
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(fontSize: 12)),
                    ),
                  ],
                ),
              ),
            ),
            // 右側の情報部分
            Container(
              // width, height:表示するサムネイル画像の大きさ
              width: 100,
              height: 75,
              // 今回画像を表示するウィジェットは省略し、テキストで代用
              color: Color(0xFFDADADA),
              child: Center(child: Text('photo')),
            ),
          ],
        ),
      ),
    ),
  ],
)

image 4.png

2. Firestore による全文検索の機能

「シンプルなレシピ」では、過去に作ったレシピを検索して作り方を再度確認したい時(例えば「唐揚げ」と検索)や、冷蔵庫に余っている食材で作れるレシピを探したい時(例えば「玉ねぎ 鳥もも肉」と検索)のようなユースケースを考えて、全文検索の機能を実装したいと思っていました。

そこで「Firestore 全文検索」でググると、このような Google 公式のドキュメント が見つかります。そのドキュメントにはこのように書かれています。

Cloud Firestore では、ネイティブ インデックスの作成やドキュメント内のテキスト フィールドの検索をサポートしていません。さらに、コレクション全体をダウンロードして、クライアント側でフィールドを検索することは現実的ではありません。...

Cloud Firestore データの全文検索を有効にするには、Algolia のようなサードパーティの検索サービスを使用します。各メモがドキュメントとして記録されているメモ作成アプリを見てみましょう。

つまり、Firestore では全文検索は対応していないので、サードパーティを使って下さい、という Google 公式の回答です。しかし、同時に「Firestore だけで Algolia を使わず全文検索」という Qiita の記事も見つかりました。

そちらの記事で説明されているのは、テキストを N 文字ごとの連続する文字のかたまりに分解するアルゴリズムである N-gram を用いて、あらかじめ全文検索の対象とする文字列を Firestore の Map フィールドとして保存しておき、該当する Map のフィールドをもっているかどうかをクエリの where 句の検索条件に指定すれば、Firestore で全文検索が実現できるという内容です。「これはすごい。。。」と思って、そちらの記事の著者の方の電子書籍「りあクト! Firebaseで始めるサーバーレスReact開発」も購入させていただいた上で、同様の方法で実装しました。

Qiita の記事に書かれている通りですが、例えば、次の豚キムチのレシピ

まず豚こま肉を200g程度、塩胡椒と薄力粉でコーティングして下準備する。ニンニクは2片ほどを荒みじん切り、玉ねぎ半分を薄めのスライスにしておく。
温めたフライパンにニンニクを入れて火を通し、柴犬色になったら肉をよく広げながら入れる。全体に火が通ってきたら玉ねぎを入れて、透明になるまで炒める。そこにキムチを投入して、酒大さじ1、醤油小さじ1、佐藤小さじ1、めんつゆ少々を加えて、水分が少し減るくらいまで炒める。
ご飯によく合う味で美味しいぞ!!

に「豚キムチ」のレシピ名を加えた文字列を、N = 2 つまり bi-gram で、分解した下記のような Map をそれぞれのレシピドキュメントのフィールドに保存しておきます。

tokenMap = {
  '豚キ': true,
  'キム': true,
  'ムチ': true,
  'まず': true,
  'ず豚': true,
  '豚こ': true,
  'こま': true,
  'ま肉': true,
  '肉を': true,
  // ... 省略
  '玉ね': true,
  'ねぎ': true,
  'ぎ半': true,
  '半分': true,
  '分を': true,
  // ... 省略
};

ユーザーが検索ワードに入力した語句も、即座に bi-gram で分解し、下記のようなクエリとすれば良いということです。

つまり、ユーザーが「玉ねぎとキムチが冷蔵庫に余っているんだけど作れるものあったっけ?」と考えた時に、検索ワードとして「玉ねぎ キムチ」と入力した際のクエリは次のようになります(説明の単純化のために、一部アプリの実際のソースコードとは異なる書き方をしています)。

Query query = FirebaseFirestore.instance
    .collection('{レシピデータが保存されたコレクション}')
    .where('玉ね', isEqualTo: true)
    .where('ねぎ', isEqualTo: true)
    .where('キム', isEqualTo: true)
    .where('ムチ', isEqualTo: true)
    .get();

このようにすることで、玉ね: true, ねぎ: true, キム: true, ムチ: true のすべての Map のキー・バリューを持つ上記の豚キムチのレシピが正しく検索されることがわかります。
また、実際は、ひらがなとカタカナの変換を内部で行ったり、空白文字や記号、その他の特殊文字を適切に変換または除去したりして、さらにクエリに limit 句を含めることで検索にかかったコンテンツを随時表示していくような無限スクロールの機能も「シンプルなレシピ」には実装しています。

Firestore と bi-gram で行うことによる限界や制約(たとえばソートができないこと、「卵」のようなレシピ・料理では頻出だが漢字一文字なので検索しようがない例、「鳥もも肉」と「鶏もも肉」のような漢字の表記の違いは考慮できない例など...)はありますが、クライアント側に巨大な辞書データを持たせることなく、Aloglia のようなサードパーティを使うこともなく、高速でちょっとしたレシピの検索をするには十分に機能すると今のところ思っており、実際に検索機能を使ってもらえるとその速さを実感してもらえるとも思います。将来的には、レシピの検索パフォーマンスの向上のために、たとえば Cloud Functions で、サーバ側でもっておいた辞書データをもとに、たとえば「卵」の表記を見つけると、Map に たま: true, まご: true というキー・バリューを追加しておき、ユーザーにはひらがな(カタカナ)での検索を期待する、みたいな対応をしても良いかもしれません。

Firestore だけで Algolia を使わず全文検索」という Qiita の記事の著者の方のような、すごく有益で勉強になる情報をオープンにしてまとめて下さるクリエイター・ディベロッパーの方には本当に感謝しています。ありがとうございます。

3. レシピのお気に入り機能

「シンプルなレシピ」には、レシピのお気に入りの機能も実装されています。上述のような便利な検索機能はあるものの、登録・公開されたレシピの数が増えてくると、よく使うレシピやたまたま見かけた他人の公開レシピを近々作ってみようと思ったときなどに少し面倒なので、クイックアクセス・ブックマーク的な意味合いで、レシピのお気に入り機能を実装することにしました。

各レシピの画面右上にお気に入りボタンを表示させ、押すと各ユーザードキュメント下の favorite_recipes というコレクションに対象のレシピのデータを追加しておく、というシンプルな実装内容ですが、その favorite_recipes コレクションの変化をリアルタイムで監視して、お気に入りアイコン (ON/OFF) のリアルタイム反映や、お気に入りのレシピタブへのリアルタイムのコンテンツの追加・削除をする必要があり、Stream を使って、下記のように実装しました。

void listenFavoriteRecipes() {
    Stream<QuerySnapshot> querySnapshot = FirebaseFirestore.instance
        .collection('{お気に入りのレシピのコレクション}')
        .snapshots();

    /// users/{userId}/favorite_recipes コレクションの変更を監視して実行
    querySnapshot.listen((snapshot) async {
      // ここに、お気に入りアイコン (ON/OFF) のリアルタイム反映
      // お気に入りのレシピタブへのリアルタイムのコンテンツの追加・削除
      // などの処理を記述する
      notifyListeners();
    });
}

4. Firestore Security Rules の真面目な実装

また、Firestore を用いたアプリケーションのリリースには当然必須である Security Rules も真面目に記述しました。以前勉強した内容を「Firestore Security Rules の書き方と守るべき原則」と題して投稿しているので、そちらもご参考にして下さい。

今回は、例として、各ユーザードキュメントの下にあるレシピデータ(つまり各ユーザーの「わたしのレシピ」)の読み書きのルールを紹介したいと思います。雛形は下記の通りです。

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      match /recipes/{recipeId} {
        // ここにルールを書く
      }
    }
  }
}

Security Rules の記述では、アプリケーションの使用の際に起こり得るユースケースをもれなく把握して、

  • 認証・認可(ユーザーのサインインの必要性の有無と対象のデータの持ち主本人である必要性の有無)
  • スキーマ検証(書き込まれるデータの構成に関する検証)
  • データのバリデーション(書き込まれるデータの妥当性に関する検証)

を意識することが重要です。

早速、レシピデータの読み書きに起こりうる起こり得るユースケースを想定しながらルールを記述していきましょう。

read: レシピの「取得」について

  • 【認証】「わたしのレシピ」は、サインイン済みの本人のみが参照できる(「シンプルなレシピ」では「わたしのレシピ」と「みんなのレシピ」を異なるコレクションに保存しているため)
  • また、getlist は細かく区別する必要がないので、read でルールを記述する

つまり、レシピの「取得」については、下記のようなルールで良いでしょう。

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      match /recipes/{recipeId} {
        allow read: if request.auth != null && userId == request.auth.uid;
      }
    }
  }
}

create: レシピの「作成」について

  • 【認証】レシピの作成ができるのはサインイン済みの本人のみである
  • 【スキーマ検証】想定する13個のフィールドが揃っており、それぞれのデータの型も想定した通りである
  • 【データのバリデーション】それぞれのデータ文字数やサーバタイムスタンプが適切である

つまり、レシピの「作成」については、下記のようなルールとしました。また、レシピデータのスキーマ検証に関しては、isValidRecipe(recipe) というのを関数化して、後で使い回せるようにしておくと便利です。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // レシピデータのスキーマ検証
    function isValidRecipe(recipe) {
      return recipe.size() == 13
        && 'content' in recipe && recipe.content is string
        && 'createdAt' in recipe && recipe.createdAt is timestamp
        && 'documentId' in recipe && recipe.documentId is string
        && 'imageName' in recipe && (recipe.imageName is string || recipe.imageName == null)
        && 'imageURL' in recipe && (recipe.imageURL is string || recipe.imageURL == null)
        && 'isPublic' in recipe && recipe.isPublic is bool
        && 'name' in recipe && recipe.name is string
        && 'reference' in recipe && recipe.reference is string
        && 'thumbnailName' in recipe && (recipe.thumbnailName is string || recipe.thumbnailName == null)
        && 'thumbnailURL' in recipe && (recipe.thumbnailURL is string || recipe.thumbnailURL == null)
        && 'tokenMap' in recipe && recipe.tokenMap is map
        && 'updatedAt' in recipe && recipe.updatedAt is timestamp
        && 'userId' in recipe && recipe.userId is string;
    }

    match /users/{userId} {
      match /recipes/{recipeId} {
        allow create: if request.auth != null && userId == request.auth.uid
          // スキーマ検証
          && isValidRecipe(request.resource.data)
          // データのバリデーション
          && request.resource.data.content.size() > 0
          && request.resource.data.content.size() <= 1000
          && request.resource.data.createdAt == request.time
          && request.resource.data.documentId == recipeId
          && request.resource.data.name.size() > 0
          && request.resource.data.name.size() <= 30
          && request.resource.data.reference.size() >= 0
          && request.resource.data.reference.size() < 1000
          && request.resource.data.updatedAt == request.time
          && request.resource.data.userId == userId;
      }
    }
  }
}

update: レシピの「更新」について

  • 【認証】【スキーマ検証】【データのバリデーション】基本的に create と同じルールである
  • 【データのバリデーション】一部、createdAt, documentId, userId など更新させたくない値を検証する

というふうに考えます。更新の操作で最終的にフィールドに格納される値は request.resource.data で参照でき、実際に resource.data で実際に update() に渡された引数が参照できるので、それらに違いがないことをデータのバリデーションに含めます。

ルールは下記のように記述します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // レシピデータのスキーマ検証
    function isValidRecipe(recipe) {
      return recipe.size() == 13
        && 'content' in recipe && recipe.content is string
        && 'createdAt' in recipe && recipe.createdAt is timestamp
        && 'documentId' in recipe && recipe.documentId is string
        && 'imageName' in recipe && (recipe.imageName is string || recipe.imageName == null)
        && 'imageURL' in recipe && (recipe.imageURL is string || recipe.imageURL == null)
        && 'isPublic' in recipe && recipe.isPublic is bool
        && 'name' in recipe && recipe.name is string
        && 'reference' in recipe && recipe.reference is string
        && 'thumbnailName' in recipe && (recipe.thumbnailName is string || recipe.thumbnailName == null)
        && 'thumbnailURL' in recipe && (recipe.thumbnailURL is string || recipe.thumbnailURL == null)
        && 'tokenMap' in recipe && recipe.tokenMap is map
        && 'updatedAt' in recipe && recipe.updatedAt is timestamp
        && 'userId' in recipe && recipe.userId is string;
    }

    match /users/{userId} {
      match /recipes/{recipeId} {
        allow update: if request.auth != null && userId == request.auth.uid
          // スキーマ検証
          && isValidRecipe(request.resource.data)
          // データのバリデーション
          && request.resource.data.content.size() > 0
          && request.resource.data.content.size() <= 1000
          && request.resource.data.name.size() > 0
          && request.resource.data.name.size() <= 30
          && request.resource.data.reference.size() >= 0
          && request.resource.data.reference.size() < 1000
          && request.resource.data.updatedAt == request.time
          && request.resource.data.createdAt == resource.data.createdAt
          && request.resource.data.documentId == resource.data.documentId
          && request.resource.data.userId == resource.data.userId;
      }
    }
  }
}

delete: レシピの「削除」について

  • 【認証】レシピの削除ができるのはサインイン済みの本人のみである

ということで、delete は本人だけができるべきです。

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      match /recipes/{recipeId} {
        allow delete: if request.auth != null && userId == request.auth.uid;
      }
    }
  }
}

このように、get, list, create, update, delete のユースケースをしっかりと考慮して書くための参考にしてみて下さい。

最後に

「Flutter & Firebase でレシピ管理アプリをリリースした話」と題した記事内容は以上です。

上記で簡単に取り上げた機能や実装以外についても、「シンプルなレシピ」の Github リポジトリ で公開していますので、少しでも、Flutter をいま学習している方、これから始めてみようかなと思っている方の参考になれば幸いです。

90
66
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
90
66