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 データパイプライン
中央のAPI Serverが今回の主役だ。axum + sqlx で PostgreSQL に接続し、Supabase JWT で認証する。ブラウザで実行できないAI推論と、所有権チェック・集計などのビジネスロジックをサーバーサイドに集約する。
3. 実装の詳細
3つの Rust ソースファイルと2つのインフラファイルを作成した。
JWT Bearer Token 認証
Supabase Auth が発行する JWT を検証し、認証済みユーザーの UUID を抽出する axum extractor。
JwtSecret-- AppState に埋め込む JWT シークレットのラッパー型AuthUser(Uuid)-- ハンドラの引数に追加するだけで認証が強制されるAuthError-- MissingHeader / InvalidToken / InvalidSubject の3パターンFromRef<S>制約で AppState から JwtSecret を透過的に抽出
/// 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") に対応するための設定だ。
sqlx PostgreSQL クエリ層
stores, cameras, visitor_counts, daily_reports の4テーブルに対するクエリ関数群。
create_pool()-- PgPool を作成 (max_connections=5)get_store_by_owner()-- ユーザーIDから店舗を取得user_owns_store()-- 所有権チェック (store_id + owner_id)get_store_stats_db()-- 当日の来客数 + オンラインカメラ数を集計get_daily_report_db()-- 最新のデイリーレポートを取得insert_visitor_count()-- フレーム解析結果をDBに書き込みget_cameras()-- 店舗のカメラ一覧を取得
/// フレーム解析結果を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クエリで集計し、タプルで返す設計だ。
7エンドポイントの API サーバー
axum Router に認証付きエンドポイントを7本登録。AppState に PgPool と JwtSecret を保持する。
POST /api/v1/frames-- フレーム受信 + AI推論 + DB書き込みGET /stores/me/stats-- 自店舗の統計 (owner_idで自動解決)GET /stores/me/daily-- 自店舗のデイリーレポートGET /stores/me/cameras-- 自店舗のカメラ一覧GET /stores/:store_id/stats-- 指定店舗の統計 (所有権チェック)GET /stores/:store_id/daily-- 指定店舗のレポート (所有権チェック)GET /api/v1/health-- ヘルスチェック (認証不要)
// 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 クレームで所有権を検証する。
APIデプロイメント基盤
マルチステージビルドの Dockerfile と Fly.io 設定ファイル。
- ビルドステージ:
rust:1.86-slim-bookwormでcargo build --release --package api - ランタイムステージ:
debian:bookworm-slimに最小限の依存 (ca-certificates, libssl3) のみ - Fly.io: nrt (東京) リージョン、512MB RAM、auto_stop/auto_start で低コスト運用
- 環境変数: DATABASE_URL, SUPABASE_JWT_SECRET は
fly secretsで管理
# 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. 技術的判断 -- なぜこの選択か
重要な設計判断
- sqlx のランタイムクエリ --
sqlx::query!()マクロ (コンパイル時SQL検証) ではなくsqlx::query_as()を採用。CI環境にDBを用意する手間を避け、誰でもすぐにビルドできる設計を優先 - Supabase -- Managed PG + Auth + RLS がワンパッケージ。1時間スプリントでは「自分で立てない」が正解
- JWT 検証フロー -- Supabase Auth がトークン発行 → クライアントが Authorization ヘッダーに付与 → SUPABASE_JWT_SECRET で検証 →
subから UUID 抽出。セッションストア不要 - FromRef パターン --
#[derive(FromRef)]で AppState から PgPool/JwtSecret を個別抽出。AuthUser は AppState の構造を知らなくてよい - DB書き込みは non-fatal --
insert_visitor_count失敗は warn ログのみ。推論結果は返しつつ永続化は best-effort。可用性優先
5. 成果と数字
Cycle 2 の定量的な成果を振り返る。
作成・更新したファイル
crates/api/src/auth.rs-- JWT認証レイヤー (113行)crates/api/src/db.rs-- sqlx クエリ関数群 (175行)crates/api/src/main.rs-- APIサーバー本体 (474行)crates/api/Cargo.toml-- 依存関係定義 (22行)Dockerfile.api-- マルチステージビルド (26行)fly.api.toml-- Fly.io デプロイ設定 (22行).env.example-- 環境変数テンプレート
合計約500行の Rust コードで、認証付き・DB接続済みの本番APIが完成した。
モックAPIと本物のAPIの差は、行数ではなく「データが流れるかどうか」だ。500行でその壁を越えた。
6. 次のサイクルで何をするか
Cycle 2 で API → DB のパイプラインが開通した。データが流れ始めたことで、次のステップが明確になる。
Cycle 3 の LINE通知Bot は、今回構築した API の上に載る。visitor_counts への INSERT を監視し、閾値超過時に LINE で店舗オーナーに通知する。データの流れが出来上がった今、通知は「出口を増やすだけ」だ。
モックを捨て、本物のデータパイプラインを通したことで、Cycle 3以降の全機能が「接続するだけ」の状態になった。基盤を先に作る。それが1時間サイクルの正しい順番だ。
1時間で構築した本番APIを体験しませんか?
ミセバンAIは、スプリントサイクルで高速に進化しています。
既存の防犯カメラがAI店長に変わる、小規模店舗のための次世代AI分析。