🗝️

Rustでsupabaseにサインアップする

2021/11/16に公開

目標

supabaseのクライアントはemailとパスワードからユーザー登録できます. これをRustで実装してみる.

プロジェクト・ページ

brainvader/qdo-gotrue-api

What is GoTrue?

GoTrueはGo製の認証サーバーです.

Gotrue is responsible for issuing access tokens for your users, sends confirmation, magic-link, and password recovery emails (by default we send these from a Supabase SMTP server, but you can easily plug in your own inside the dashboard at Auth > Settings) and also transacting with third party OAuth providers to get basic user data.[1]

クライアントはこのエンド・ポイントへのラッパーです.

Gotrue-js (and also gotrue-csharp, gotrue-py, gotrue-kt, and gotrue-dart) are all wrappers around the gotrue API endpoints, and make for easier session management inside your client.[1:1]

SupabaseにおけるGoTrueのAPIはEndpointsに書いてあります.

GoTrueAPIクラス

データ構造

GotTrueApiクラスは以下のようになっている.

export default class GoTrueApi {
  protected url: string
  protected headers: {
    [key: string]: string
  }
  protected cookieOptions: CookieOptions
  protected fetch?: Fetch
  ...
}
フィールド名 説明
url SupabaseのベースURL e.g. https://xscduanzzfseqszwzhcy.supabase.co/auth/v1
headers HTTPヘッダーのキー・バリュー・ペア
cookieOptions SSRするときに必要らしい[2][3]
fetch デフォルトでcross-fetchが使われる

このクラスにメソッドが定義されているだけというシンプルな構造です. RustではHTTP通信用のクライアントとしてreqwestを利用します.[4]

use reqwest::header::HeaderMap;

pub struct GoTrueApi {
    url: String,
    headers: HeaderMap,
}

impl GoTrueApi {
    fn new<T>(url: T, headers: HeaderMap) -> Self
    where
        T: Into<String>,
    {
        Self {
            url: url.into(),
            headers: headers,
        }
    }
}

文字列がIntoになっているのは以下を参考にしました.

ApiError

エラーはインターフェースで定義されています.

interface ApiError {
  message: string
  status: number
}

Rustの場合エラー処理は少し面倒です. thiserrorを利用しました.

#[derive(Debug, Error)]
#[error("ApiError: {} {}", message, status)]
pub struct ApiError {
    message: String,
    status: i64,
}

また各メソッドではanyhowを使うことで非同期メソッドからの戻り値型を簡潔に表現できます.

signupメソッド

エンドポイントは以下のような感じです.

e-mail & パスワード
curl -X POST 'https://xscduanzzfseqszwzhcy.supabase.co/auth/v1/signup' \
-H "apikey: SUPABASE_KEY" \
-H "Content-Type: application/json" \
-d '{
  "email": "someone@email.com",
  "password": "IbRhYBsyIZXSzLPwlJDr"
}'

TypeScriptでは以下のようになる.

signUpWithEmail
async signUpWithEmail(
  email: string,
  password: string,
): Promise<{ data: Session | User | null; error: ApiError | null }> {
  try {
    const headers = { ...this.headers }
    const data = await post(
      this.fetch,
      `${this.url}/signup${queryString}`,
      { email, password },
      { headers }
    )
    const session = { ...data }
    if (session.expires_in) session.expires_at = expiresAt(data.expires_in)
    return { data: session, error: null }
  } catch (e) {
    return { data: null, error: e as ApiError }
  }
}

例外処理でthrowでエラーを投げるのではなく

const {data, error } = signUpWithEmail("example@email.com", "xyz")

となるのはちょっとGoっぽいですね. 後このsignUpWithEmailの戻り値はUserとなるはずなんですがSessionになっています.

pub async fn singup<T>(&self, email: T, password: T) -> Result<User>
where
    T: Into<String>,
{
    let base_url =
        Url::parse(&self.url).context(format!("filed to parse url: {}", self.url))?;
    let signup_path = "/auth/v1/signup";
    let api_url = base_url
        .join(signup_path)
        .context(format!("failed to join {}", signup_path))?;
    let mut body: HashMap<&str, String> = HashMap::new();
    body.insert("email", email.into());
    body.insert("password", password.into());
    let fetcher = reqwest::Client::new();
    let request_builder = fetcher
        .post(api_url)
        .headers(self.headers.clone())
        .json(&body);

    let response = request_builder.send().await?;
   
    match response.error_for_status() {
        Ok(res) => Ok(res.json::<User>().await?),
        Err(err) => Err(ApiError {
            message: "Error happend".to_string(),
            status: err.status(),
        })?,
    }
}

error_for_statusはクライアント・エラーやサーバー・エラーの場合はステータス・コードを含んだエラー・オブジェクトを返してくれます. エラーがない場合は普通にResponse自身を返します. これを使ってApiErrorに置き換えています.

POJO(Plain Old JavaScript Object)[5]はHasMapに置き換えました. jsonメソッドを使うと内部でapplication/jsonをヘッダーも付けてくれます.

次はresponseのJSONをどうやって変換するかです. これもserdeserde_jsonを使うと簡単でした.

deriveを有効にしておくと簡潔に記述できます.

serde = { version="1.0", features=["derive"]}

Any type that implements Serde's Deserialize trait can be deserialized this way. This includes built-in Rust standard library types like Vec<T> and HashMap<K, V>, as well as any structs or enums annotated with #[derive(Deserialize)].[6]

データ型としてanyはHashMapで代用します.

クライアント

dotenvを使って.envファイルに必要な情報を書いておきます.

URL=
ANON_KEY=
EMAIL=
PASSWORD=

後はこれを読み取ってapiに渡すだけです.

use qdo_gotrue_api::GoTrueApi;

use dotenv::dotenv;
use std::env;

use reqwest::header::{HeaderMap, HeaderValue};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenv().ok();

    let url = env::var("URL").expect("URL is not found");
    let anon_key = env::var("ANON_KEY").expect("ANON_KEY is not found");
    let email = env::var("EMAIL").expect("EMAIL is not found");
    let password = env::var("PASSWORD").expect("PASSWORD is not found");

    let mut headers = HeaderMap::new();
    let apikey_value = HeaderValue::from_str(&anon_key).unwrap();
    headers.insert("apiKey", apikey_value);
    let api = GoTrueApi::new(url, headers);

    let user = api.singup(email, password).await;

    match user {
        Ok(u) => println!("User: {:#?}", u),
        Err(err) => println!("{:#?}", err.root_cause()),
    }

    Ok(())
}

まとめ

エラー処理が良く分からない.

補足

Supabaseの概要

SupabaseはオープンソースのFirebase代替と公称しています. よってBaaS(Backend as a Service)かmBaaS(mobile Backend as a Service)となります. Supabaseというソフトウェアを一から作っているわけではなく, PostgreSQLを中心に既存の技術をうまく利用してBaaSに必要な機能を提供しています.

Supabaseは認証サービスとしてGoTrueを利用しています. クライアントのsupabase/gotrue-jsというのが用意されている.

本体はGoTrueApi.tsで認証用のAPIへのラッパーメソッドを提供しています.

認証後はPostgrestを使ってPostgreSQLをWeb APIから操作できます.

認証APIもPostgrestによるAPIもKongが提供する単一のAPIゲートウェイからアクセスできるようになっています.

Supabaseにおける認証(メンタルモデル)

Supabaseの認証や認可はPostgreSQLのロールを基本に構築されている. ロール

  • ユーザー
  • ユーザー・グループ

のことを指します. Row Level Security(RLS)によりロールごとにポリシーと呼ばれるアクセス権限を設定します.

標準ではAPIキーによって大きく二つのアクセス権限が設定されています.

APIキー 説明
anonキー 公開用でブラウザなどのクライアントから利用する
service_roleキー サーバー管理者用で公開してはいけない

ローカルで運用する場合はAPI Keysセクションで生成して使うこともできる.

さてanonキーをapikeyとして渡すことでcolorsというテーブルから全ての名前を取り出す場合以下のようにするようです.

curl 'https://xscduanzzfseqszwzhcy.supabase.co/rest/v1/colors?select=name' \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxNDIwNTE3NCwiZXhwIjoxOTI5NzgxMTc0fQ.-NBR1WnZyQGpRLdXJfgfpszoZ0EeE6KHatJsDPLIX8c"

またログイン・ユーザーには別にトークンを発行することもできる. こちらはログイン時に発行されAuthorization: Bearerに渡します.

curl 'https://xscduanzzfseqszwzhcy.supabase.co/rest/v1/colors?select=name' \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxNDIwNTE3NCwiZXhwIjoxOTI5NzgxMTc0fQ.-NBR1WnZyQGpRLdXJfgfpszoZ0EeE6KHatJsDPLIX8c" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjE1ODI0Mzg4LCJzdWIiOiIwMzM0NzQ0YS1mMmEyLTRhYmEtOGM4YS02ZTc0OGY2MmExNzIiLCJlbWFpbCI6InNvbWVvbmVAZW1haWwuY29tIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwifSwidXNlcl9tZXRhZGF0YSI6bnVsbCwicm9sZSI6ImF1dGhlbnRpY2F0ZWQifQ.I-_oSsJamtinGxniPETBf-ezAUwDW2sY9bJIThvdX9s"

serviceキーでやってしまっても問題はないと思うがスーパー・ユーザーで実行することになるので危険性はある. この場合RLSによるアクセス制御は無視される.

Reference

脚注
  1. Gotrue Server ↩︎ ↩︎

  2. supabase/examples/nextjs-auth/pages/index.js ↩︎

  3. nextjs with typescript:29 SSRとCSRとCookie ↩︎

  4. postgrest-rsが使っているのでそうしました. ↩︎

  5. 中括弧を使ったオブジェクトの生成はPOJOじゃないという話もあるようです. ↩︎

  6. [Parsing JSON as strongly typed data structures](Parsing JSON as strongly typed data structures) ↩︎

Discussion