Open8

Unityクライアントにおける通信ハンドリング処理の実装(Firebase利用)

0y00y0

実装

using Cysharp.Threading.Tasks;
using System.Threading;

/// <summary>
/// REST通信を行う共通インターフェース
/// </summary>
public interface IRequestSender
{
    /// <summary>
    /// REST APIリクエストを送信し、レスポンス文字列を返す。
    /// 例: GET, POST, PUT, PATCH, DELETE に対応。
    /// </summary>
    /// <param name="method">HTTPメソッド</param>
    /// <param name="url">URL</param>
    /// <param name="data">POST/PUT/PATCH等のボディデータ</param>
    /// <param name="maxRetries">最大リトライ回数</param>
    /// <param name="initialInterval">リトライ時の初期インターバル(秒)</param>
    /// <param name="timeoutSeconds">タイムアウト(秒)</param>
    /// <param name="cancellationToken">キャンセル用トークン</param>
    UniTask<string> SendRequestAsync(
        HttpMethod method,
        string url,
        object data = null,
        int maxRetries = 3,
        float initialInterval = 2.0f,
        float timeoutSeconds = 10.0f,
        CancellationToken cancellationToken = default
    );

    /// <summary>
    /// 進捗監視付きの大容量ダウンロード(GET想定)
    /// </summary>
    /// <param name="url">ダウンロード先URL</param>
    /// <param name="noProgressTimeout">進捗が変化しない場合のタイムアウト秒数</param>
    /// <param name="cancellationToken">キャンセル用トークン</param>
    UniTask DownloadWithProgressAsync(
        string url,
        float noProgressTimeout = 5.0f,
        CancellationToken cancellationToken = default
    );
}
using Cysharp.Threading.Tasks;
using System.Threading;

/// <summary>
/// アクセストークン管理のインターフェース
/// </summary>
public interface ITokenManager
{
    UniTask<string> GetAccessTokenAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// バージョン情報提供のインターフェース
/// </summary>
public interface IVersionProvider
{
    string AppVersion { get; }
    string MasterDataVersion { get; }
}
using Cysharp.Threading.Tasks;
using System;
using System.Threading;

/// <summary>
/// 排他制御のインターフェース
/// </summary>
public interface IAsyncLock
{
    UniTask<IDisposable> LockAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// HTTPメソッド (GET, POST, PUT, PATCH, DELETE)
/// </summary>
public enum HttpMethod
{
    GET,
    POST,
    PUT,
    PATCH,
    DELETE
}
using System;

// カスタム例外クラス
public class NetworkException : Exception
{
    public NetworkException(string message) : base(message) { }
}

public class VersionMismatchException : Exception
{
    public VersionMismatchException(string message) : base(message) { }
}

public class MasterDataMismatchException : Exception
{
    public MasterDataMismatchException(string message) : base(message) { }
}

public class DuplicateRequestException : Exception
{
    public DuplicateRequestException(string message) : base(message) { }
}
using System;
using System.Threading;
using Cysharp.Threading.Tasks;

// 排他制御クラス
public class AsyncLock : IAsyncLock
    {
        private readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);

        public async UniTask<IDisposable> LockAsync(CancellationToken cancellationToken = default)
        {
            await semaphore.WaitAsync(cancellationToken);
            return new Releaser(semaphore);
        }

        private class Releaser : IDisposable
        {
            private SemaphoreSlim semaphore;
            public Releaser(SemaphoreSlim semaphore)
            {
                this.semaphore = semaphore;
            }
            public void Dispose()
            {
                semaphore.Release();
            }
        }
    }
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

// TokenManager (アクセストークン + 再認証 + 排他制御)
public class TokenManager : ITokenManager
{
    private string accessToken;
    private DateTime expirationTime;

    private readonly AsyncLock asyncLock = new AsyncLock();

    public async UniTask<string> GetAccessTokenAsync(CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrEmpty(accessToken) || DateTime.UtcNow >= expirationTime)
        {
            await RefreshTokenIfNeededAsync(cancellationToken);
        }
        return accessToken;
    }

    private async UniTask RefreshTokenIfNeededAsync(CancellationToken cancellationToken)
    {
        using (await asyncLock.LockAsync())
        {
            // 他のリクエストが既に更新を済ませた場合のガード
            if (!string.IsNullOrEmpty(accessToken) && DateTime.UtcNow < expirationTime)
            {
                return;
            }

            Debug.Log("Refreshing token...");

            // 例: ログインAPI
            string url = "https://example.com/api/login";
            var loginData = new { username = "user", password = "pass" };
            string jsonData = JsonUtility.ToJson(loginData);

            using (UnityWebRequest request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST))
            {
                request.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(jsonData));
                request.downloadHandler = new DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");

                await request.SendWebRequest().ToUniTask(cancellationToken: cancellationToken);

                if (request.result == UnityWebRequest.Result.ConnectionError ||
                    request.result == UnityWebRequest.Result.ProtocolError)
                {
                    throw new NetworkException($"Failed to refresh token: {request.error}");
                }

                // レスポンスをパースしてtokenとexpiresInを取得する想定
                var response = JsonUtility.FromJson<LoginResponse>(request.downloadHandler.text);
                accessToken = response.token;
                expirationTime = DateTime.UtcNow.AddSeconds(response.expiresIn);

                Debug.Log("Token refreshed successfully.");
            }
        }
    }

    [Serializable]
    private class LoginResponse
    {
        public string token;
        public int expiresIn;
    }
}
// VersionManager ( バージョン情報を提供)
public class VersionManager : IVersionProvider
{
    public string AppVersion { get; } = "1.0.0";
    public string MasterDataVersion { get; } = "20230101";
}
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

// HttpApiClient
//  すべてのRESTメソッドをまとめて扱う汎用クラス
//  リトライ、タイムアウト、キャンセル、二重リクエスト防止、バージョンチェックなどを網羅
public class HttpApiClient : IRequestSender
{
    private readonly ITokenManager _tokenManager;
    private readonly IVersionProvider _versionProvider;

    // 二重リクエスト防止用にIDを管理(本来はサーバ側で制御)
    private readonly HashSet<string> _processedRequestIds = new();

    public HttpApiClient(ITokenManager tokenManager, IVersionProvider versionProvider)
    {
        _tokenManager = tokenManager;
        _versionProvider = versionProvider;
    }

    public async UniTask<string> SendRequestAsync(
        HttpMethod method,
        string url,
        object data = null,
        int maxRetries = 3,
        float initialInterval = 2.0f,
        float timeoutSeconds = 10.0f,
        CancellationToken ct = default
    )
    {
        string requestId = Guid.NewGuid().ToString();

        if (_processedRequestIds.Contains(requestId))
        {
            throw new DuplicateRequestException($"Request [{requestId}] has already been processed.");
        }
        _processedRequestIds.Add(requestId);

        int retryCount = 0;
        float interval = initialInterval;

        while (retryCount <= maxRetries)
        {
            try
            {
                // アクセストークン取得
                string token = await _tokenManager.GetAccessTokenAsync(ct);

                // UnityWebRequest生成
                using (UnityWebRequest request = CreateUnityWebRequest(method, url, data))
                {
                    // 共通ヘッダー(バージョン情報, リクエストID, アクセストークン等)
                    request.SetRequestHeader("App-Version", _versionProvider.AppVersion);
                    request.SetRequestHeader("Master-Data-Version", _versionProvider.MasterDataVersion);
                    request.SetRequestHeader("Request-Id", requestId);
                    request.SetRequestHeader("Authorization", $"Bearer {token}");

                    // タイムアウト設定
                    request.timeout = Mathf.CeilToInt(timeoutSeconds);

                    // 通信実行
                    await request.SendWebRequest().ToUniTask(cancellationToken: ct);

                    // 通信エラー判定
                    if (request.result == UnityWebRequest.Result.ConnectionError ||
                        request.result == UnityWebRequest.Result.ProtocolError)
                    {
                        throw new NetworkException($"Request error: {request.error}");
                    }

                    // サーバーからバージョンエラーを表すステータスコードが返る想定
                    if (request.responseCode == 426) // 例: 426 Upgrade Required
                    {
                        throw new VersionMismatchException("App version mismatch. Please update the application.");
                    }
                    if (request.responseCode == 428) // 例: 428 Precondition Required
                    {
                        throw new MasterDataMismatchException("Master data version mismatch. Please refresh the data.");
                    }

                    // 成功
                    string responseText = request.downloadHandler.text;
                    Debug.Log($"[SendRequestAsync] {method} {url} -> {responseText}");
                    return responseText;
                }
            }
            catch (OperationCanceledException)
            {
                Debug.LogWarning($"[SendRequestAsync] Request canceled or timed out. Method={method}, Url={url}");
                throw;
            }
            catch (Exception ex) when (ShouldRetry(ex))
            {
                retryCount++;
                if (retryCount > maxRetries)
                {
                    throw new Exception($"[SendRequestAsync] Max retries reached. Last error: {ex.Message}");
                }

                Debug.LogWarning($"[SendRequestAsync] Retrying {retryCount}/{maxRetries} in {interval:F1} seconds... : {ex.Message}");
                await UniTask.Delay(TimeSpan.FromSeconds(interval), cancellationToken: ct);
                interval *= 2;
            }
            catch (Exception ex)
            {
                throw new Exception($"[SendRequestAsync] Request failed. Method={method}, Url={url}, Error={ex.Message}");
            }
        }

        throw new Exception($"[SendRequestAsync] Request failed after all retries. Method={method}, Url={url}");
    }

    /// <summary>
    /// 大容量ダウンロード + 進捗監視
    /// </summary>
    public async UniTask DownloadWithProgressAsync(
        string url,
        float noProgressTimeout = 5.0f,
        CancellationToken ct = default
    )
    {
        using (UnityWebRequest request = UnityWebRequest.Get(url))
        {
            float lastProgress = 0f;
            float lastProgressTime = Time.time;

            request.downloadHandler = new DownloadHandlerBuffer();
            var sendOp = request.SendWebRequest();

            while (!sendOp.isDone)
            {
                if (ct.IsCancellationRequested)
                {
                    Debug.LogWarning("[DownloadWithProgressAsync] Canceled by user.");
                    throw new OperationCanceledException(ct);
                }

                // 進捗が増えたら時間を更新
                if (request.downloadProgress > lastProgress)
                {
                    lastProgress = request.downloadProgress;
                    lastProgressTime = Time.time;
                    Debug.Log($"Downloading... {lastProgress * 100f:F1}%");
                }
                // 一定時間進捗が変化しない場合タイムアウト
                else if (Time.time - lastProgressTime > noProgressTimeout)
                {
                    throw new TimeoutException($"Download no progress for {noProgressTimeout}s. Aborting.");
                }

                await UniTask.Yield();
            }

            if (request.result == UnityWebRequest.Result.ConnectionError ||
                request.result == UnityWebRequest.Result.ProtocolError)
            {
                throw new NetworkException($"[DownloadWithProgressAsync] Download failed: {request.error}");
            }

            Debug.Log("[DownloadWithProgressAsync] Download completed successfully.");
        }
    }

    /// <summary>
    /// HttpMethodとデータに応じてUnityWebRequestを生成
    /// </summary>
    private UnityWebRequest CreateUnityWebRequest(HttpMethod method, string url, object data)
    {
        // 本来は multipart や form-data 等も考慮が必要だが、ここでは簡易化
        switch (method)
        {
            case HttpMethod.GET:
                return UnityWebRequest.Get(url);

            case HttpMethod.POST:
                {
                    var req = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST);
                    if (data != null)
                    {
                        string json = JsonUtility.ToJson(data);
                        req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json));
                        req.SetRequestHeader("Content-Type", "application/json");
                    }
                    req.downloadHandler = new DownloadHandlerBuffer();
                    return req;
                }
            case HttpMethod.PUT:
                {
                    var req = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPUT);
                    if (data != null)
                    {
                        string json = JsonUtility.ToJson(data);
                        req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json));
                        req.SetRequestHeader("Content-Type", "application/json");
                    }
                    req.downloadHandler = new DownloadHandlerBuffer();
                    return req;
                }
            case HttpMethod.PATCH:
                {
                    // UnityWebRequestにパッチ専用メソッドはないが、カスタムメソッドで対応
                    var req = new UnityWebRequest(url, "PATCH");
                    if (data != null)
                    {
                        string json = JsonUtility.ToJson(data);
                        req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json));
                        req.SetRequestHeader("Content-Type", "application/json");
                    }
                    req.downloadHandler = new DownloadHandlerBuffer();
                    return req;
                }
            case HttpMethod.DELETE:
                {
                    // Deleteでもボディを送る場合はカスタムメソッド化が必要
                    // (一部サーバーやライブラリがDELETE + bodyに対応していない可能性あり)
                    var req = UnityWebRequest.Delete(url);
                    req.downloadHandler = new DownloadHandlerBuffer();
                    return req;
                }
            default:
                throw new NotSupportedException($"Method {method} is not supported.");
        }
    }

    /// <summary>
    /// リトライ対象となる例外かどうか
    /// </summary>
    private bool ShouldRetry(Exception ex)
    {
        return ex is NetworkException;
    }
}
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

// 7. 動作確認用サンプル
public class DemoRunner : MonoBehaviour
{
    private IRequestSender apiClient;
    private CancellationTokenSource cts;

    private async void Start()
    {
        // 依存注入(SOLID: DIP, インターフェースに対して実装を注入)
        ITokenManager tokenMgr = new TokenManager();
        IVersionProvider versionMgr = new VersionManager();
        apiClient = new HttpApiClient(tokenMgr, versionMgr);

        cts = new CancellationTokenSource();

        // 例: GETリクエスト
        try
        {
            string url = "https://example.com/api/resource";
            string response = await apiClient.SendRequestAsync(
                HttpMethod.GET,
                url,
                data: null,           // GETなので通常ボディ不要
                maxRetries: 3,
                initialInterval: 2f,
                timeoutSeconds: 10f,
                cancellationToken: cts.Token
            );

            Debug.Log($"[DemoRunner] GET Response: {response}");
        }
        catch (Exception ex)
        {
            Debug.LogError($"[DemoRunner] GET failed: {ex.Message}");
        }

        // 例: POSTリクエスト
        try
        {
            string url = "https://example.com/api/create";
            var postData = new { title = "Hello", body = "World" };
            string response = await apiClient.SendRequestAsync(
                HttpMethod.POST,
                url,
                data: postData,
                maxRetries: 3,
                initialInterval: 2f,
                timeoutSeconds: 10f,
                cancellationToken: cts.Token
            );

            Debug.Log($"[DemoRunner] POST Response: {response}");
        }
        catch (Exception ex)
        {
            Debug.LogError($"[DemoRunner] POST failed: {ex.Message}");
        }

        // 例: 大容量ファイルダウンロード(進捗監視付き)
        /*
        try
        {
            string downloadUrl = "https://example.com/largefile.zip";
            await apiClient.DownloadWithProgressAsync(
                downloadUrl,
                noProgressTimeout: 5f,
                cancellationToken: cts.Token
            );
        }
        catch (Exception ex)
        {
            Debug.LogError($"[DemoRunner] Download failed: {ex.Message}");
        }
        */
    }

    private void Update()
    {
        // キャンセルしたい場合、Escキーなどでキャンセル
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            Debug.Log("[DemoRunner] Canceling all requests...");
            cts.Cancel();
        }
    }
}
0y00y0

Firebase認証の場合のトークン管理

using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Firebase.Auth;
using UnityEngine;

public class FirebaseTokenManager : ITokenManager
{
    private string idToken;
    private DateTime expirationTime;

    private readonly FirebaseAuth firebaseAuth;
    private readonly AsyncLock asyncLock = new();

    public FirebaseTokenManager()
    {
        // FirebaseAuth.DefaultInstance は FirebaseAuthManager.InitializeAsync() によって初期化済みであることが前提
        firebaseAuth = FirebaseAuth.DefaultInstance;
    }

    public async UniTask<string> GetAccessTokenAsync(CancellationToken ct)
    {
        // IDトークンが期限切れの場合のみ更新
        if (string.IsNullOrEmpty(idToken) || DateTime.UtcNow >= expirationTime)
        {
            await RefreshTokenIfNeededAsync(ct);
        }
        return idToken;
    }

    private async UniTask RefreshTokenIfNeededAsync(CancellationToken ct)
    {
        using (await asyncLock.LockAsync(ct))
        {
            // 他のリクエストでトークンが更新済みか確認
            if (!string.IsNullOrEmpty(idToken) && DateTime.UtcNow < expirationTime)
            {
                return;
            }

            Debug.Log("Refreshing Firebase ID token...");

            FirebaseUser user = firebaseAuth.CurrentUser;
            if (user == null)
            {
                throw new NetworkException("No authenticated user. Please sign in first.");
            }

            try
            {
                idToken = await user.TokenAsync(true).AsUniTask().AttachExternalCancellation(ct);
                // Firebase の ID トークンは通常 60 分有効。少し早めに更新するため 55 分とする
                expirationTime = DateTime.UtcNow.AddMinutes(55);
                Debug.Log("Firebase ID Token refreshed successfully.");
            }
            catch (Exception ex)
            {
                throw new NetworkException($"Failed to refresh Firebase ID Token: {ex.Message}");
            }
        }
    }
}

0y00y0

Firebaseによるユーザ登録/ログイン

https://firebase.google.com/docs/auth/unity/start?hl=ja

IFirebaseAuthManager インターフェース

using System.Threading;
using Cysharp.Threading.Tasks;
using Firebase.Auth;


/// <summary>
/// Firebase 認証マネージャが実装するインターフェース
/// </summary>
public interface IFirebaseAuthManager
{
    // Firebase Authentication のインスタンス
    FirebaseAuth Auth { get; }

    // 現在サインインしているユーザ
    FirebaseUser CurrentUser { get; }

    // Firebase の依存関係をチェックし、初期化する
    UniTask InitializeAsync(CancellationToken cancellationToken = default);

    // メールアドレスとパスワードでユーザ登録を行う
    UniTask RegisterUserAsync(string email, string password, CancellationToken cancellationToken = default);

    // メールアドレスとパスワードでユーザのサインインを行う
    UniTask SignInUserAsync(string email, string password, CancellationToken cancellationToken = default);
}

FirebaseAuthManager クラス

using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Firebase;
using Firebase.Auth;
using UnityEngine;


/// <summary>
/// Firebase 認証マネージャ
/// </summary>
public class FirebaseAuthManager : IFirebaseAuthManager
{
    public FirebaseAuth Auth { get; private set; }
    public FirebaseUser CurrentUser { get; private set; }

    // コンストラクタ
    public FirebaseAuthManager() { }

    // Firebase の依存関係をチェックし、初期化する
    public async UniTask InitializeAsync(CancellationToken cancellationToken = default)
    {
        var dependencyStatus = await FirebaseApp.CheckAndFixDependenciesAsync()
                                                .WithCancellation(cancellationToken);
        if (dependencyStatus == DependencyStatus.Available)
        {
            Debug.Log("Firebase dependencies are available.");
            Auth = FirebaseAuth.DefaultInstance;
        }
        else
        {
            Debug.LogError($"Could not resolve all Firebase dependencies: {dependencyStatus}");
            throw new Exception($"Firebase dependencies are not available: {dependencyStatus}");
        }
    }

    // メールアドレスとパスワードでユーザ登録を行う
    public async UniTask RegisterUserAsync(string email, string password, CancellationToken cancellationToken = default)
    {
        try
        {
            FirebaseUser newUser = await Auth.CreateUserWithEmailAndPasswordAsync(email, password)
                                             .WithCancellation(cancellationToken);
            Debug.Log($"User registered: {newUser.Email}");
            CurrentUser = newUser;
        }
        catch (Exception ex)
        {
            Debug.LogError($"Registration failed: {ex.Message}");
            throw;
        }
    }

    // メールアドレスとパスワードでユーザのサインインを行う
    public async UniTask SignInUserAsync(string email, string password, CancellationToken cancellationToken = default)
    {
        try
        {
            FirebaseUser signedInUser = await Auth.SignInWithEmailAndPasswordAsync(email, password)
                                                  .WithCancellation(cancellationToken);
            Debug.Log($"User signed in: {signedInUser.Email}");
            CurrentUser = signedInUser;
        }
        catch (Exception ex)
        {
            Debug.LogError($"Sign in failed: {ex.Message}");
            throw;
        }
    }
}
0y00y0

Firebase AuthenticationによるREST API連携

クライアント側

  • Firebase Authentication SDK を用いてユーザーがログイン(ソーシャル認証やメール/パスワード認証など)
  • ログイン後、firebase.auth().currentUser.getIdToken() で ID トークンを取得する
  • API 呼び出し時、HTTP リクエストの Authorization ヘッダに Bearer <idToken> を付与して送信する

サーバ側(REST API)

  • リクエストを受信したら、HTTP ヘッダから Authorization 値を取り出す
  • Bearer の後に続く文字列を Firebase の Admin SDK や JWT ライブラリを用いて検証する
    • 例えば、Firebase Admin SDK の verifyIdToken() を利用すれば、トークンの署名検証や有効期限の確認が自動で行われる
  • 検証に成功すれば、ユーザーの UID やその他の認証情報を利用してリクエスト処理を続行、失敗すれば 401 Unauthorized を返す
0y00y0

FirebaseAuthManagerの検証

動作した

using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using Firebase.Auth;

public class FirebaseAuthTester : MonoBehaviour
{
    private IFirebaseAuthManager firebaseAuthManager;
    private CancellationTokenSource cts;

    private async void Start()
    {
        cts = new CancellationTokenSource();

        // Firebase の初期化
        firebaseAuthManager = new FirebaseAuthManager();
        try
        {
            await firebaseAuthManager.InitializeAsync(cts.Token);
        }
        catch (Exception ex)
        {
            Debug.LogError($"[FirebaseAuthTester] Firebase initialization failed: {ex.Message}");
            return;
        }

        // サインインの実行(メール・パスワードは適宜置き換えてください)
        try
        {
            await firebaseAuthManager.SignInUserAsync("your_email@example.com", "your_password", cts.Token);
            Debug.Log("[FirebaseAuthTester] Firebase sign in successful");
        }
        catch (Exception ex)
        {
            Debug.LogError($"[FirebaseAuthTester] Firebase sign in failed: {ex.Message}");
            return;
        }
    }

    private void OnDestroy()
    {
        cts?.Cancel();
        cts?.Dispose();
    }
}

0y00y0

APIリクエストの検証

動作した

Firebase Cloud Functions側

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const bodyParser = require('body-parser');
const { body, validationResult } = require('express-validator');

admin.initializeApp();

// 定数として必要なバージョン情報とマスターデータ(実際はDBなどで管理)
const REQUIRED_APP_VERSION = "1.0.0";
const CURRENT_MASTER_DATA_VERSION = "1.0.0";
const mockMasterData = {
    config: "Default configuration",
    items: ["item1", "item2", "item3"]
};

// シンプルなメモリ上のDB(永続化はしません)
let mockDatabase = {};

// Express アプリケーションの作成
const app = express();
app.use(bodyParser.json());

/**
 * エラーレスポンスを返すためのヘルパー関数
 */
const sendError = (res, status, message, extra = {}) => {
    res.status(status).json({ error: message, ...extra });
};

/**
 * 認証ミドルウェア
 * - Authorization ヘッダーの存在チェック
 * - Firebase の ID トークン検証
 */
const authMiddleware = async (req, res, next) => {
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return sendError(res, 403, 'Unauthorized: No token provided.');
    }
    const idToken = authHeader.split('Bearer ')[1];
    try {
        await admin.auth().verifyIdToken(idToken);
        next();
    } catch (error) {
        return sendError(res, 403, `Unauthorized: ${error.message}`);
    }
};

/**
 * バージョンチェックミドルウェア
 * - クライアント送信の "app-version" と "master-data-version" の検証
 */
const versionCheckMiddleware = (req, res, next) => {
    const clientAppVersion = req.headers["app-version"];
    const clientMasterDataVersion = req.headers["master-data-version"];

    if (clientAppVersion !== REQUIRED_APP_VERSION) {
        return res.status(426).json({
            error: "App version outdated",
            requiredAppVersion: REQUIRED_APP_VERSION
        });
    }
    if (clientMasterDataVersion !== CURRENT_MASTER_DATA_VERSION) {
        return res.status(428).json({
            error: "Master data version mismatch",
            currentMasterDataVersion: CURRENT_MASTER_DATA_VERSION,
            masterData: mockMasterData
        });
    }
    next();
};

/**
 * バージョン情報エンドポイント
 * - 認証通過後に、最新のバージョン情報とマスターデータを提供
 */
app.get('/version', authMiddleware, (req, res) => {
    res.status(200).json({
        appVersion: REQUIRED_APP_VERSION,
        masterDataVersion: CURRENT_MASTER_DATA_VERSION,
        masterData: mockMasterData
    });
});

// 認証ミドルウェアは /version 以外のエンドポイントにも適用
app.use(authMiddleware);
// バージョンチェックは認証後、/version以外に適用
app.use(versionCheckMiddleware);

/**
 * CRUD 操作用のルートハンドラ
 *  
 * app.all('/') の中で、メソッドごとに処理を分岐し、POST と PUT/PATCH に対しては
 * express-validator を用いたバリデーションを実行しています。
 */
app.all('/', async (req, res) => {
    try {
        switch (req.method) {
            case 'GET':
                handleGet(req, res);
                break;
            case 'POST':
                await handlePost(req, res);
                break;
            case 'PUT':
            case 'PATCH':
                await handleUpdate(req, res);
                break;
            case 'DELETE':
                handleDelete(req, res);
                break;
            default:
                res.status(405).send("Method not allowed");
        }
    } catch (error) {
        console.error("Error processing request:", error);
        sendError(res, 500, "Internal Server Error");
    }
});

/**
 * GET メソッドの処理
 */
const handleGet = (req, res) => {
    const id = req.query.id;
    if (id) {
        const record = mockDatabase[id];
        if (record) {
            res.status(200).json({ action: "read", id: id, data: record });
        } else {
            sendError(res, 404, "Record not found");
        }
    } else {
        res.status(200).json({ action: "read_all", data: mockDatabase });
    }
};

/**
 * POST メソッドの処理(新規作成)
 * express-validator を用いて、title と body の入力バリデーションを実施します。
 */
const handlePost = async (req, res) => {
    // バリデーションルールを実行
    await body('title')
        .exists().withMessage('タイトルは必須です。')
        .isLength({ min: 1, max: 100 }).withMessage('タイトルは1~100文字で入力してください。')
        .run(req);
    await body('body')
        .exists().withMessage('本文は必須です。')
        .notEmpty().withMessage('本文は必須です。')
        .run(req);

    // バリデーション結果のチェック
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return sendError(res, 400, "Validation error", { errors: errors.array() });
    }
    
    const newRecord = req.body;
    const id = Date.now().toString(); // 実際は UUID などの利用が望ましい
    mockDatabase[id] = newRecord;
    res.status(201).json({ action: "create", id: id, data: newRecord });
};

/**
 * PUT/PATCH メソッドの処理(更新)
 * 更新時は、入力されている場合のみチェック(optional)する例です。
 */
const handleUpdate = async (req, res) => {
    const id = req.query.id;
    if (!id) {
        return sendError(res, 400, "Missing id for update");
    }
    if (!mockDatabase[id]) {
        return sendError(res, 404, "Record not found for update");
    }
    
    // バリデーションルール(更新時は任意項目の場合のチェック)
    await body('title')
        .optional()
        .isLength({ min: 1, max: 100 }).withMessage('タイトルは1~100文字で入力してください。')
        .run(req);
    await body('body')
        .optional()
        .run(req);

    // バリデーション結果のチェック
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return sendError(res, 400, "Validation error", { errors: errors.array() });
    }
    
    const updatedData = req.body;
    // ※ここでは更新データ全体で上書きしていますが、部分更新の場合は既存データとのマージ処理などが必要です
    mockDatabase[id] = updatedData;
    res.status(200).json({ action: "update", id: id, data: updatedData });
};

/**
 * DELETE メソッドの処理
 */
const handleDelete = (req, res) => {
    const id = req.query.id;
    if (!id) {
        return sendError(res, 400, "Missing id for delete");
    }
    if (!mockDatabase[id]) {
        return sendError(res, 404, "Record not found for delete");
    }
    delete mockDatabase[id];
    res.status(200).json({ action: "delete", id: id });
};

exports.crudApi = functions.https.onRequest(app);

Unity側

using System;

[Serializable]
public class RecordModel
{
    [Required(ErrorMessage = "タイトルは必須です。")]
    [StringLength(100, ErrorMessage = "タイトルは1~100文字で入力してください。")]
    public string title { get; set; }

    [Required(ErrorMessage = "本文は必須です。")]
    public string body { get; set; }
}

// カスタムバリデーション属性の定義
public class ValidationAttribute : Attribute
{
    public string ErrorMessage { get; set; }
    public virtual bool IsValid(object value) => true;
}

public class RequiredAttribute : ValidationAttribute
{
    public RequiredAttribute()
    {
        ErrorMessage = "この項目は必須です。";
    }

    public override bool IsValid(object value)
    {
        return value != null && !string.IsNullOrWhiteSpace(value.ToString());
    }
}

public class StringLengthAttribute : ValidationAttribute
{
    private readonly int _maxLength;
    private readonly int _minLength;

    public StringLengthAttribute(int maxLength, int minLength = 0)
    {
        _maxLength = maxLength;
        _minLength = minLength;
    }

    public override bool IsValid(object value)
    {
        if (value == null) return _minLength == 0;
        string str = value.ToString();
        return str.Length >= _minLength && str.Length <= _maxLength;
    }

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using UnityEngine;

public class ModelValidator
{
    /// <summary>
    /// 指定されたモデルのバリデーションを行い、失敗した場合はエラーメッセージをログ出力して例外を発生させます。
    /// </summary>
    /// <param name="model">検証対象のモデル</param>
    /// <exception cref="ValidationException">検証に失敗した場合に送出される例外</exception>
    public void Validate(object model)
    {
        var context = new ValidationContext(model, serviceProvider: null, items: null);
        var results = new List<ValidationResult>();

        if (!Validator.TryValidateObject(model, context, results, true))
        {
            foreach (var validationResult in results)
            {
                Debug.LogError(validationResult.ErrorMessage);
            }
            throw new ValidationException("モデルの検証に失敗しました。");
        }
    }
}

using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

[Serializable]
public class CrudResponse
{
    public string action;
    public string id;
    public string data;
}

public class DemoRunner : MonoBehaviour
{
    private IRequestSender _apiClient;
    private CancellationTokenSource _cts;
    private IFirebaseAuthManager _firebaseAuthManager;
    private ModelValidator _validator;
    private bool _isProcessing;

    // Cloud Functions の CRUD API エンドポイント(適宜変更)
    private const string BaseUrl = "https://xxxx";

    private async void Start()
    {
        await InitializeAndRunAsync();
    }

    private async UniTask InitializeAndRunAsync()
    {
        try
        {
            _isProcessing = true;
            _cts = new CancellationTokenSource();
            _validator = new ModelValidator();

            // Firebase の初期化とサインインを実施
            await InitializeFirebaseAsync();

            // CRUD 操作の実行
            await ExecuteCrudOperationsAsync();
        }
        catch (OperationCanceledException)
        {
            Debug.Log("[CrudDemoRunner] Operation was cancelled.");
        }
        catch (Exception ex)
        {
            Debug.LogError($"[CrudDemoRunner] 初期化またはCRUD操作中にエラーが発生しました: {ex.Message}");
        }
        finally
        {
            _isProcessing = false;
        }
    }

    private void OnDisable()
    {
        if (_isProcessing && _cts != null && !_cts.IsCancellationRequested)
        {
            _cts.Cancel();
        }
    }

    private void OnDestroy()
    {
        if (_cts != null)
        {
            if (!_cts.IsCancellationRequested)
            {
                _cts.Cancel();
            }
            _cts.Dispose();
            _cts = null;
        }
    }

    private void Update()
    {
        // Escape キーで全リクエストのキャンセルを実行
        if (Input.GetKeyDown(KeyCode.Escape) && _isProcessing && _cts != null && !_cts.IsCancellationRequested)
        {
            Debug.Log("[CrudDemoRunner] キャンセル要求を受信しました。");
            _cts.Cancel();
        }
    }

    /// <summary>
    /// Firebase の初期化とユーザ認証、HttpApiClient の依存注入を行います。
    /// </summary>
    private async UniTask InitializeFirebaseAsync()
    {
        try
        {
            _firebaseAuthManager = new FirebaseAuthManager();
            await _firebaseAuthManager.InitializeAsync(_cts.Token);
            await _firebaseAuthManager.SignInUserAsync("xxxk.jp", "xxxx", _cts.Token);

            // 認証後に HttpApiClient の依存注入
            ITokenManager tokenMgr = new FirebaseTokenManager();
            IVersionProvider versionMgr = new VersionManager();
            _apiClient = new HttpApiClient(tokenMgr, versionMgr);
        }
        catch (OperationCanceledException)
        {
            Debug.Log("[CrudDemoRunner] Firebase 初期化がキャンセルされました。");
            throw;
        }
        catch (Exception ex)
        {
            Debug.LogError($"[CrudDemoRunner] Firebase 認証エラー: {ex.Message}");
            throw;
        }
    }

    /// <summary>
    /// CRUD の各操作を順次実行します。
    /// </summary>
    private async UniTask ExecuteCrudOperationsAsync()
    {
        try
        {
            // Create: 新規レコード作成
            var createData = new RecordModel
            {
                title = "Hello",
                body = "World"
            };

            string createResponse = await CreateRecordAsync(createData);
            Debug.Log("[CrudDemoRunner] Create Response: " + createResponse);

            var createResult = JsonUtility.FromJson<CrudResponse>(createResponse);
            if (createResult == null || string.IsNullOrEmpty(createResult.id))
            {
                Debug.LogError("[CrudDemoRunner] Failed to parse create response or invalid ID");
                return;
            }

            string recordId = createResult.id;
            Debug.Log("[CrudDemoRunner] Created record id: " + recordId);

            // Read: 作成したレコードを取得
            string readResponse = await ReadRecordAsync(recordId);
            Debug.Log("[CrudDemoRunner] Read Response: " + readResponse);

            // Update: レコードの更新
            var updateData = new RecordModel
            {
                title = "Updated Hello",
                body = "Updated World"
            };

            string updateResponse = await UpdateRecordAsync(recordId, updateData);
            Debug.Log("[CrudDemoRunner] Update Response: " + updateResponse);

            // Delete: レコードの削除
            string deleteResponse = await DeleteRecordAsync(recordId);
            Debug.Log("[CrudDemoRunner] Delete Response: " + deleteResponse);

            // Read All: 全レコードの取得(削除後の確認)
            string readAllResponse = await ReadAllRecordsAsync();
            Debug.Log("[CrudDemoRunner] Read All Response: " + readAllResponse);
        }
        catch (Exception ex)
        {
            Debug.LogError($"[CrudDemoRunner] CRUD 操作中にエラーが発生しました: {ex.Message}");
            throw;
        }
    }

    /// <summary>
    /// POST リクエストにより新規レコードを作成します。
    /// </summary>
    private async UniTask<string> CreateRecordAsync(RecordModel data)
    {
        try
        {
            _validator.Validate(data);
            var requestData = new { data = data }; // Wrap the data in a container object
            return await _apiClient.SendRequestAsync(
                HttpMethod.POST,
                BaseUrl,
                requestData,
                maxRetries: 3,
                initialInterval: 2f,
                timeoutSeconds: 10f,
                cancellationToken: _cts.Token
            );
        }
        catch (Exception ex)
        {
            Debug.LogError($"[CrudDemoRunner] Create operation failed: {ex.Message}");
            throw;
        }
    }

    /// <summary>
    /// GET リクエストにより特定のレコードを取得します。
    /// </summary>
    private async UniTask<string> ReadRecordAsync(string id)
    {
        string url = $"{BaseUrl}?id={id}";
        return await _apiClient.SendRequestAsync(
            HttpMethod.GET,
            url,
            data: null,
            maxRetries: 3,
            initialInterval: 2f,
            timeoutSeconds: 10f,
            cancellationToken: _cts.Token
        );
    }

    /// <summary>
    /// PUT リクエストにより特定のレコードを更新します。
    /// </summary>
    private async UniTask<string> UpdateRecordAsync(string id, RecordModel data)
    {
        try
        {
            _validator.Validate(data);
            string url = $"{BaseUrl}?id={id}";
            var requestData = new { data = data }; // Wrap the data in a container object
            return await _apiClient.SendRequestAsync(
                HttpMethod.PUT,
                url,
                requestData,
                maxRetries: 3,
                initialInterval: 2f,
                timeoutSeconds: 10f,
                cancellationToken: _cts.Token
            );
        }
        catch (Exception ex)
        {
            Debug.LogError($"[CrudDemoRunner] Update operation failed: {ex.Message}");
            throw;
        }
    }

    /// <summary>
    /// DELETE リクエストにより特定のレコードを削除します。
    /// </summary>
    private async UniTask<string> DeleteRecordAsync(string id)
    {
        string url = $"{BaseUrl}?id={id}";
        return await _apiClient.SendRequestAsync(
            HttpMethod.DELETE,
            url,
            data: null,
            maxRetries: 3,
            initialInterval: 2f,
            timeoutSeconds: 10f,
            cancellationToken: _cts.Token
        );
    }

    /// <summary>
    /// GET リクエストにより全レコードを取得します。
    /// </summary>
    private async UniTask<string> ReadAllRecordsAsync()
    {
        return await _apiClient.SendRequestAsync(
            HttpMethod.GET,
            BaseUrl,
            data: null,
            maxRetries: 3,
            initialInterval: 2f,
            timeoutSeconds: 10f,
            cancellationToken: _cts.Token
        );
    }
}

0y00y0
using System;

public class ValidationAttribute : Attribute
{
    public string ErrorMessage { get; set; }
    public virtual bool IsValid(object value) => true;
}

public class RequiredAttribute : ValidationAttribute
{
    public RequiredAttribute()
    {
        ErrorMessage = "この項目は必須です。";
    }

    public override bool IsValid(object value)
    {
        return value != null && !string.IsNullOrWhiteSpace(value.ToString());
    }
}

public class StringLengthAttribute : ValidationAttribute
{
    private readonly int _maxLength;
    private readonly int _minLength;

    public StringLengthAttribute(int maxLength, int minLength = 0)
    {
        _maxLength = maxLength;
        _minLength = minLength;
    }

    public override bool IsValid(object value)
    {
        if (value == null) return _minLength == 0;
        string str = value.ToString();
        return str.Length >= _minLength && str.Length <= _maxLength;
    }
}