ブログ一覧へ戻る

Cycle 1 でダッシュボード・DB・認証の「ハコ」を作った。しかし、APIはまだモックデータを返すだけだった。

今回の Sprint Cycle 2 では、モックAPIを本物のバックエンドに置き換える。Rust の sqlx で PostgreSQL に直接クエリを投げ、Supabase の JWT で認証を検証する。

カメラエージェントからダッシュボードまで、データが本物のパイプラインを通る。

1. Before / After -- モックから本物へ

まず、Cycle 1 と Cycle 2 の違いを明確にする。言葉で説明するより、テーブルで見た方が早い。

項目 Cycle 1 (Before) Cycle 2 (After)
データソース ハードコードされたモックJSON Supabase PostgreSQL (sqlx 0.8)
認証 なし / Supabase JS SDK のみ JWT Bearer Token 検証 (サーバーサイド)
API実装 固定レスポンスを返す stub 7エンドポイント、所有権チェック付き
デプロイ Web (nginx) のみ Web + API (Dockerfile.api, fly.api.toml)
データの流れ カメラ → (断絶) → ダッシュボード カメラ → API → PostgreSQL → ダッシュボード

最も重要な変化はデータパイプラインの接続だ。推論結果が PostgreSQL に永続化され、ダッシュボードから参照できるようになる。

2. アーキテクチャ

Cycle 2 データパイプライン

Camera AgentRTSP + AI推論
API Serveraxum + sqlx + JWT
Supabase PGPostgreSQL + RLS
DashboardChart.js + Supabase JS

中央のAPI Serverが今回の主役だ。axum + sqlx で PostgreSQL に接続し、Supabase JWT で認証する。ブラウザで実行できないAI推論と、所有権チェック・集計などのビジネスロジックをサーバーサイドに集約する。

3. 実装の詳細

3つの Rust ソースファイルと2つのインフラファイルを作成した。

crates/api/src/auth.rs

JWT Bearer Token 認証

Supabase Auth が発行する JWT を検証し、認証済みユーザーの UUID を抽出する axum extractor。

/// axum extractor: Authorization ヘッダーから JWT を検証
#[derive(Debug, Clone)]
pub struct AuthUser(pub Uuid);

#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
    JwtSecret: FromRef<S>,
{
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts, state: &S
    ) -> Result<Self, Self::Rejection> {
        let secret = JwtSecret::from_ref(state);

        // Bearer トークンを抽出
        let token = parts.headers
            .get(header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .ok_or(AuthError::MissingHeader)?
            .strip_prefix("Bearer ")
            .ok_or(AuthError::MissingHeader)?;

        // JWT をデコード・検証 (HS256)
        let mut validation = Validation::new(Algorithm::HS256);
        validation.validate_aud = false; // Supabase独自のaud値に対応

        let data = decode::<Claims>(
            token,
            &DecodingKey::from_secret(secret.0.as_bytes()),
            &validation,
        ).map_err(|e| AuthError::InvalidToken(e.to_string()))?;

        // sub クレームを UUID にパース
        let user_id = Uuid::parse_str(&data.claims.sub)
            .map_err(|e| AuthError::InvalidSubject(e.to_string()))?;

        Ok(AuthUser(user_id))
    }
}

axum の extractors パターンにより、ハンドラの引数に AuthUser(user_id): AuthUser と書くだけで JWT 検証が自動実行される。認証に失敗すれば401が返り、ハンドラのコードは実行されない。validate_aud = false はSupabase JWT の独自 aud 値 ("authenticated") に対応するための設定だ。

crates/api/src/db.rs

sqlx PostgreSQL クエリ層

stores, cameras, visitor_counts, daily_reports の4テーブルに対するクエリ関数群。

/// フレーム解析結果をDBに書き込み (INSERT...SELECT)
pub async fn insert_visitor_count(
    pool: &PgPool, camera_id: &Uuid,
    people_count: i32,
    demographics_json: serde_json::Value,
    zones_json: serde_json::Value,
) -> Result<(), sqlx::Error> {
    // cameras テーブルから store_id を自動取得
    sqlx::query(
        "INSERT INTO visitor_counts \
         (camera_id, store_id, counted_at, people_count, \
          demographics_json, zones_json) \
         SELECT $1, c.store_id, NOW(), $2, $3, $4 \
         FROM cameras c WHERE c.id = $1",
    )
    .bind(camera_id).bind(people_count)
    .bind(&demographics_json).bind(&zones_json)
    .execute(pool).await?;
    Ok(())
}

INSERT ... SELECT パターンでカメラIDから店舗IDをDB側で解決する。アプリケーション層での余計なクエリが不要になる。get_store_stats_db は当日の SUM(people_count) とオンラインカメラ数を2クエリで集計し、タプルで返す設計だ。

crates/api/src/main.rs

7エンドポイントの API サーバー

axum Router に認証付きエンドポイントを7本登録。AppState に PgPool と JwtSecret を保持する。

// main.rs -- AppState とルーター構成

#[derive(Clone, FromRef)]
struct AppState {
    pool: PgPool,
    jwt_secret: JwtSecret,
}

let app = Router::new()
    // 認証必須ルート
    .route("/api/v1/frames", post(receive_frame))
    .route("/api/v1/stores/me/stats", get(get_my_store_stats))
    .route("/api/v1/stores/me/daily", get(get_my_daily_report))
    .route("/api/v1/stores/me/cameras", get(get_my_cameras))
    .route("/api/v1/stores/:store_id/stats", get(get_store_stats))
    .route("/api/v1/stores/:store_id/daily", get(get_daily_report))
    // 公開ルート
    .route("/api/v1/health", get(health_check))
    .layer(cors)
    .with_state(state);

エンドポイント一覧

Method Path 説明 認証
POST /api/v1/frames フレーム受信 + AI推論 + DB永続化 JWT
GET /api/v1/stores/me/stats 自店舗の当日来客数・カメラ数 JWT
GET /api/v1/stores/me/daily 自店舗の最新デイリーレポート JWT
GET /api/v1/stores/me/cameras 自店舗のカメラ一覧 JWT
GET /api/v1/stores/:store_id/stats 指定店舗の統計 (所有権検証) JWT
GET /api/v1/stores/:store_id/daily 指定店舗のレポート (所有権検証) JWT
GET /api/v1/health ヘルスチェック (バージョン情報) Public

/me 系はダッシュボード向け、/:store_id 系は管理ツール・外部連携向け。どちらも JWT の sub クレームで所有権を検証する。

Dockerfile.api + fly.api.toml

APIデプロイメント基盤

マルチステージビルドの Dockerfile と Fly.io 設定ファイル。

# Dockerfile.api -- マルチステージビルド
FROM rust:1.86-slim-bookworm AS builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY crates/ crates/
RUN cargo build --release --package api

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3
COPY --from=builder /app/target/release/api /usr/local/bin/miseban-api
ENV PORT=3000
EXPOSE 3000
CMD ["miseban-api"]

4. 技術的判断 -- なぜこの選択か

重要な設計判断

5. 成果と数字

Cycle 2 の定量的な成果を振り返る。

7
APIエンドポイント
~500
Rust コード
7
ファイル作成/更新
~1時間
実装時間

作成・更新したファイル

  1. crates/api/src/auth.rs -- JWT認証レイヤー (113行)
  2. crates/api/src/db.rs -- sqlx クエリ関数群 (175行)
  3. crates/api/src/main.rs -- APIサーバー本体 (474行)
  4. crates/api/Cargo.toml -- 依存関係定義 (22行)
  5. Dockerfile.api -- マルチステージビルド (26行)
  6. fly.api.toml -- Fly.io デプロイ設定 (22行)
  7. .env.example -- 環境変数テンプレート

合計約500行の Rust コードで、認証付き・DB接続済みの本番APIが完成した。

モックAPIと本物のAPIの差は、行数ではなく「データが流れるかどうか」だ。500行でその壁を越えた。

6. 次のサイクルで何をするか

Cycle 2 で API → DB のパイプラインが開通した。データが流れ始めたことで、次のステップが明確になる。

Cycle 1
ダッシュボード・DB・認証 完了 -- ダッシュボードUI、6テーブル、Supabase Auth
Cycle 2
Rust API × Supabase (sqlx + JWT) 完了 -- 本記事の内容
Cycle 3
LINE通知Bot + リアルタイムアラート 来客急増・不審行動・営業時間外をLINEで即時通知
Cycle 4
Stripe決済統合 フリープラン → スタータープラン → プロプランの課金フロー
Cycle 5
クローズドベータテスト 実店舗での動作検証。実データによるチューニング

Cycle 3 の LINE通知Bot は、今回構築した API の上に載る。visitor_counts への INSERT を監視し、閾値超過時に LINE で店舗オーナーに通知する。データの流れが出来上がった今、通知は「出口を増やすだけ」だ。

モックを捨て、本物のデータパイプラインを通したことで、Cycle 3以降の全機能が「接続するだけ」の状態になった。基盤を先に作る。それが1時間サイクルの正しい順番だ。

1時間で構築した本番APIを体験しませんか?

ミセバンAIは、スプリントサイクルで高速に進化しています。
既存の防犯カメラがAI店長に変わる、小規模店舗のための次世代AI分析。