もふもふ技術部

IT技術系mofmofメディア

React + Firebase + Hasuraで体験する快適なGraphQL生活

元々graphql-rubyとReactで生きていたんですが、どうやらHasuraなるものが良いらしいと知り、掲題のスタックで社内書籍管理サービスをつくってみました。 楽しかったのでちょっと書いてみます。

具体的には認証つきHasuraからusersを取得できるところまで。 Apolloは使わず、FetchでPOST投げちゃいます。

また、コード自体は本番運用に耐えうるものでは全くないので、 概念を理解する・触りを体験して全体像をつかむくらいの用途にとどめてください。

なお、Firebaseを利用する場合はCloudFunctionsを利用するので従量課金のBlazeプランにする必要があります。(といっても個人で遊ぶ分には無料枠で事足りる) 認証にAuth0を利用するのであればそこは考慮しなくてOKです。でもあれ真面目に使うとなると高いですよね?

TL;DR

  • HasuraはRDB版Firestoreみたいな感覚
    • 元々PostgreSQLのみ対応だったんだけど最近MySQLも対応したっぽい?
  • Firebaseとか活用すると手軽に認証付きGraphQLサーバが手に入る
  • めっちゃ便利なGraphiQL駆動でHasuraの命名規則に則ったクエリつくれるのが結構なDX

Hasura: https://hasura.io/

Hasuraとは

立ち位置的にはActiveRecord/graphql-rubyがやっていることを代替するような気持ちで利用していました。

GraphQLでリクエストを受ける → SQLを発行 → データ取得 → レスポンス返す

これを勝手にやってくれます。すごい。 Hasuraの規則に則ることで、よしなにSQLを発行してくれる形です。 例えばユーザーを追加するmutationであればこんな形。

mutation CreateUser($email: String = "") {
  insert_users_one(object: {email: $email}) {
    id
    email
  }
}

insertテーブル名なんやら という命名規則に則ってリクエストを送ると、Hasuraがうまいことやってくれます。 この命名規則も暗記する必要はなく、GraphiQLのサポート機能としてHasuraが提供してくれているので、ポチポチクリックするだけでクエリが構築できます。すごい楽。(GraphiQLの機能なのかHasuraの機能なのかは知らないです 教えてください)

Schemaももちろん落とせるので、codegenとかばっちりできます。最高。

HasuraとFirebaseのアカウント作る

まずはアカウント類を作ります。

Hasura

ここからアカウントを作成します。Try Hasuraから、Free Tierで進めます。 https://hasura.io/

お好みの方法でアカウント作成を完了してください。 完了すると、ダッシュボードが表示されるはずです。

続けてプロジェクトを作成します。画面上部の new project から進めます。

(アカウント作成の手順にプロジェクト作成が含まれていた気がします…が、下記の手順と同様の内容で進められると思います。)

最初の画面ではどのDBを使うか選べます。 Try with Heroku でいきましょう。Herokuログインが求められたりするので、確認しつつ進めます。 完了するとプロジェクト詳細の画面に移ります。とりあえずやることはないので一旦放置。

Firebase

認証に利用するので、Firebaseのアカウントも作成します。 https://firebase.google.com/?hl=ja

ガイドに従い、よしなに進めてください。というかこの記事読んでる方はすでにアカウント持ってそうな気もする。続けてプロジェクトも適当に作りましょう。

できたらAuthenticationでGoogle認証をonにしておきます。

OK!ではReact触っていきましょうか。

ReactからHasuraにリクエストを投げる

プロジェクトつくる

create-react-appでプロジェクトを作ります。

$ npx create-react-app --typescript hasura-demo

一応立ち上がることを確認。

$ yarn start

Hasuraでテーブル作成とクエリお試し

したらHasura側でテーブル作成と適当なデータ作成をします。DATAタブから、Add Tableしましょう。

テーブル定義はこんなところでOK。idemailtextで入れておきます。

ページの最下部にAdd Table ボタンがあるので、クリックして完了。ついでにテストデータも入れておきましょう。

Insert Rowタブから適当に登録しておきます。なお、ユーザー登録はあとでFirebaseのFunctionsに任せるようにします。

さて、ここまででステップ1完了です。実際にデータを取り出してみましょう。 GRAPHIQLタブから、graphiql左のメニューをポチポチするとクエリが作れます。メニューはDBだかschemaだかを見て勝手にいい感じにしておいてくれます。すごい。

Reactからfetchする

これをReactから雑に取得してみます。 App.tsxをこれにしてください。エンドポイントはHasuraのコンソールに書いてありますので置き換えましょう。

src/App.tsx

import React from 'react';

function App() {
  const queryStr = "query MyQuery { users { id email } }"
  const query = { query: queryStr }

  const fetchUsers = () => {
    fetch('https://<yours>.hasura.app/v1/graphql', {
      method: 'POST',
      body: JSON.stringify(query)
    }).then(response => {
      response.json().then(result => {
        console.log(result.data)
      })
    })
  }

  return (
    <div>
      <button onClick={fetchUsers}>
        fetch
      </button>
    </div>
  );
}

export default App;

ボタンが一つだけある質素な画面になります。ボタンを押すとコンソールに結果が出ます。

良いですね。が、現状ではエンドポイントを知っていれば誰でも取り放題なので、これから認証設定をしていきます。

認証設定

Hasura側 環境変数設定

ADMIN_SECRETの設定

Hasuraは、プロジェクトの環境変数に適切な値を設定することで認証を動作させることができます。プロジェクト一覧から、プロジェクトの歯車ボタンをクリック。URLはこちらです。 https://cloud.hasura.io/projects

  1. メニューからEnv varsを選択
  2. New Env Var
  3. フォームに admin を入力してADMIN_SECRETを選択
  4. 自身で硬い値を設定

しましょう。

この値をAuthorizationヘッダに含めると、admin権限でHasuraを利用できます。サーバからHasuraにアクセスする際に利用するものですかね。今回はFirebaseのFunctionsからHasuraにリクエストを投げる際に利用します。Reactからは使いません。

JWT_SECRETの設定

次に、Firebaseの方も環境変数に設定します。

このページでFirebaseのプロジェクトIDを入れるといい感じの設定を生成してくれます。 https://hasura.io/jwt-config/

生成されたjsonJWT_SECRET に設定します。

これで認証設定はだいたいOKです。ヘッダのx-hasura-admin-secretに正しいadmin_secretが設定されていればadmin権限でのリクエスト、AuthorizationヘッダにJWTが設定されていればそれによる認証を行うようになりました。この状態で先ほどのfetchボタンを押すと怒られるはずです。僕のコンソールのnetworkではこんな感じになります。

errors   [ {…} ]
    0   Object { message: "Missing Authorization header in JWT authentication mode", extensions: {…} }
        extensions  Object { path: "$", code: "invalid-headers" }
            path    "$"
            code    "invalid-headers"
    message "Missing Authorization header in JWT authentication mode"

次はリクエスト時のヘッダ設定をしていきましょう。

Firebase側

Custom Claim設定

Functionsを利用して、ユーザー作成時にカスタムクレームを設定します。 Hasuraに「リクエストを行ったユーザーの権限」を伝えるための作業です。

なにはともあれFirebase initです。FirebaseのCLIが入ってない場合は入れておきましょう。https://firebase.google.com/docs/cli?hl=ja

また、FirebaseのプロジェクトはBlazeプラン にしておいてください。

$ firebase init

Functionsだけチェックしておけば大丈夫です。プロジェクトはさっき選んだやつにします。 あとはお好みでどうぞ。今回は特にいじる機会もそうないのでjsにしちゃいました。

必要なライブラリを入れておきます。といってもaxiosくらいです。Apollo使ってもいいですがやりたいことが非常に軽いので。

$ cd functions
$ yarn add axios

functions/index.jsはこれにします。axiosのurlとadmin_secretは自分のものに変更 しておいてください。 このあたりはこれらの記事を参考にさせていただいてます。 https://qiita.com/ryo2132/items/7c74ecaf3a001b28a02c https://www.graat.co.jp/blogs/cjwstb63d0daz0830mzctngvk

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const axios = require('axios');

admin.initializeApp(functions.config().firebase);

const createUser = `
mutation createUser($id: String = "", $email: String = "") {
  insert_users_one(object: {id: $id, email: $email}, on_conflict: {constraint: users_pkey, update_columns: []}) {
    id
    email
  }
}
`

exports.processSignUp = functions.auth.user().onCreate(user => {
  let customClaims;
  customClaims = {
    'https://hasura.io/jwt/claims': {
      'x-hasura-default-role': 'user',
      'x-hasura-allowed-roles': ['user'],
      'x-hasura-user-id': user.uid
    }
  }

  return admin.auth().setCustomUserClaims(user.uid, customClaims)
    .then(() => {
      let queryStr = {
        "query": createUser,
        "variables": {id: user.uid, email: user.email}
      }

      axios({
        method: 'post',
        url: '<url>',
        data: queryStr,
        headers: {
          'x-hasura-admin-secret': "<secret>"
        }
      })

      admin
        .firestore()
        .collection("user_meta")
        .doc(user.uid)
        .create({
          refreshTime: admin.firestore.FieldValue.serverTimestamp()
        });
    })
    .catch(error => {
      console.log(error);
    });
});

できたら$ firebase deploy --only functionsしましょう。 この際、jsの書き方やFirebaseのプランで怒られることが多いです。

ログイン

Firebase Authenticationを使ってログインします。特にあえて書くこともないので、このあたりを参考に実装しましょう。 https://firebase.google.com/docs/auth/web/google-signin?authuser=0

また、ここで Firestoreを使うことになるのでコンソールから有効にしておきます。リージョンはお好みで。

ログインできるようになったら、IDトークン=JWTを取得してそれをヘッダに突っ込んじゃいましょう。 諸々設定し、ログイン/ログアウト・Hasura認証が通るようになったコードがこちらです。(まだusersは取得できません)

import React, { useState } from 'react';
import firebase from './firebaseConfig'

function App() {
  const [idToken, setIdToken] = useState<string>('')
  const queryStr = "query MyQuery { users { id email } }"
  const query = { query: queryStr }

  const login = () => {
    const provider = new firebase.auth.GoogleAuthProvider()
    firebase.auth().signInWithPopup(provider)
  }

  const logout = () => {
    firebase.auth().signOut()
  }

  firebase.auth().onAuthStateChanged(user => {
    if(user) {
      user.getIdToken().then(token => {
        setIdToken(token)
        console.log(token)
      })
    }
  })

  const fetchUsers = () => {
    fetch('<url>', {
      method: 'POST',
      headers: { Authorization: `Bearer ${idToken}` },
      body: JSON.stringify(query)
    }).then(response => {
      response.json().then(result => {
        console.dir(result.data)
      })
    })
  }

  return (
    <div>
      <button onClick={login}>
        login
      </button>
      <button onClick={logout}>
        logout
      </button>
      <button onClick={fetchUsers} disabled={!idToken.length}>
        fetch
      </button>
    </div>
  );
}

export default App;

firebaseConfig.ts

import * as firebase from "firebase/app"
import 'firebase/auth'

firebase.initializeApp(<config>)

export default firebase

一点注意なのですが、アカウント作成直後はidTokenにHasura用のカスタムクレームが入ってないことがあります。FirebaseのAuth側での反映に多少ラグがあるためです。 コンソールに表示されるidTokenを下記のページでdecodeし、こんな感じの内容が含まれているか確認してください。https://jwt.io/

"https://hasura.io/jwt/claims": {
    "x-hasura-default-role": "user",
    "x-hasura-allowed-roles": [
        "user"
    ],
    "x-hasura-user-id": "firebaseが付与するuid"
},

なければログアウトとログインを試してください。いずれこれが入ってくるはずです。

ここまで確認出来たら最後の仕上げです。

権限設定

さきほどのJWTでは、userのroleに"user"が入っていたり、uidが入っていたりします。Hasuraではこれをもとにアクセス権限の制御を行います。

例として、ユーザーが自分自身のレコードしか取得できないようにします。

  1. Hasuraのコンソールで、DATAタブからuserテーブルを選択
  2. Permissionsタブを選択
  3. userロールを追加
  4. selectのセルをクリック
  5. Row select permissionsをクリック
  6. 画像の通りにセレクト
  7. Column select permissionsをクリック
  8. それぞれチェックを入れる

こんな感じにしたら完了です。保存しましょう。 これにより、ユーザーが自身のレコードしか取得できなくなります。 実際にReactからfetchボタンを押してみると、最初に手動で登録したレコードを含まない、自身の情報のみの配列が取得できるはずです。

なにかうまくいかない場合は、 - ヘッダがきちんと登録されているか - カスタムクレームを含んでいるか - Firebase上のuidとDBのidが一致しているか

等確認してみてください。(経験談)

この権限設定はかなり柔軟で、社内書籍管理サービスのusersはこんな設定にしていたりします。 自身と、自身が所属する企業のユーザーをselectできるようになっています。

以上

というわけで、React / Hasura / Firebaseのさわりでした。ちまちまと単純なCRUD書かなくて済むのが非常に楽です。権限管理もポチポチやるだけで済むのも良いですね。(初回いい感じにカスタムクレームを扱うあたり全然理解していないので教えてもらえると…)

次のステップとしてはmutationも同様に作ってみたり、schema落としてcodegenしたり、Apollo使って真面目に作ったりするとよきかと思います。