Zenn
Open7

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

0y00y0

実装

GPT-4o1と対話しながら作成してみた。

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 HashSet<string>();

    public HttpApiClient(ITokenManager tokenManager, IVersionProvider versionProvider)
    {
        this.tokenManager = tokenManager;
        this.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 cancellationToken = default
    )
    {
        // リトライのたびに同じIDを使うなら、外部から渡す設計もアリ。
        // ここでは1回の呼び出しにつき1つ生成している例。
        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
            {
                using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
                {
                    cts.CancelAfterSlim(TimeSpan.FromSeconds(timeoutSeconds));

                    // アクセストークン取得
                    string token = await tokenManager.GetAccessTokenAsync(cts.Token);

                    // 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}");

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

                        // 通信エラー判定
                        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)
            {
                // キャンセル or タイムアウト
                Debug.LogWarning($"[SendRequestAsync] Request canceled or timed out. Method={method}, Url={url}");
                throw;
            }
            catch (Exception ex) when (ShouldRetry(ex))
            {
                retryCount++;
                if (retryCount > maxRetries)
                {
                    Debug.LogError($"[SendRequestAsync] Max retries reached. Last error: {ex.Message}");
                    throw;
                }

                Debug.LogWarning($"[SendRequestAsync] Retrying {retryCount}/{maxRetries} in {interval:F1} seconds... : {ex.Message}");
                await UniTask.Delay(TimeSpan.FromSeconds(interval), cancellationToken: cancellationToken);
                interval *= 2; // 指数的にインターバルを伸ばす
            }
            catch (Exception ex)
            {
                Debug.LogError($"[SendRequestAsync] Request failed. Method={method}, Url={url}, Error={ex.Message}");
                throw;
            }
            finally
            {
                processedRequestIds.Remove(requestId); // どんな場合でも削除
            }
        }

        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 cancellationToken = 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 (cancellationToken.IsCancellationRequested)
                {
                    Debug.LogWarning("[DownloadWithProgressAsync] Canceled by user.");
                    throw new OperationCanceledException(cancellationToken);
                }

                // 進捗が増えたら時間を更新
                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:
            {
                var req = UnityWebRequest.Delete(url);
                // Deleteでもボディを送る場合はカスタムメソッド化が必要
                // (一部サーバーやライブラリがDELETE + bodyに対応していない可能性あり)
                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');

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('/', async (req, res) => {
    try {
        switch (req.method) {
            case 'GET':
                handleGet(req, res);
                break;
            case 'POST':
                handlePost(req, res);
                break;
            case 'PUT':
            case 'PATCH':
                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 メソッドの処理(新規作成)
 */
const handlePost = (req, res) => {
    const newRecord = req.body;
    if (!newRecord || Object.keys(newRecord).length === 0) {
        return sendError(res, 400, "Invalid request body");
    }
    const id = Date.now().toString(); // 実際は UUID などの利用が望ましい
    mockDatabase[id] = newRecord;
    res.status(201).json({ action: "create", id: id, data: newRecord });
};

/**
 * PUT/PATCH メソッドの処理(更新)
 */
const handleUpdate = (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");
    }
    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;
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;

    // Cloud Functions の CRUD API エンドポイント(適宜変更)
    private const string BaseUrl = "https://<your-region>-<your-project-id>.cloudfunctions.net/crudApi";

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

        try
        {
            // Firebase の初期化とサインインを実施
            await InitializeFirebaseAsync();
        }
        catch (Exception ex)
        {
            Debug.LogError($"[CrudDemoRunner] Firebase 初期化またはサインインに失敗しました: {ex.Message}");
            return;
        }

        // CRUD 操作の実行
        await ExecuteCrudOperationsAsync();
    }

    /// <summary>
    /// Firebase の初期化とユーザ認証、HttpApiClient の依存注入を行います。
    /// </summary>
    private async UniTask InitializeFirebaseAsync()
    {
        _firebaseAuthManager = new FirebaseAuthManager();
        try
        {
            await _firebaseAuthManager.InitializeAsync(_cts.Token);
            await _firebaseAuthManager.SignInUserAsync("your_email@example.com", "your_password", _cts.Token);
        }
        catch (Exception ex)
        {
            Debug.LogError($"[CrudDemoRunner] Firebase 認証エラー: {ex.Message}");
            throw;
        }

        // 認証後に HttpApiClient の依存注入
        ITokenManager tokenMgr = new FirebaseTokenManager();
        IVersionProvider versionMgr = new VersionManager();
        _apiClient = new HttpApiClient(tokenMgr, versionMgr);
    }

    /// <summary>
    /// CRUD の各操作を順次実行します。
    /// </summary>
    private async UniTask ExecuteCrudOperationsAsync()
    {
        try
        {
            // Create: 新規レコード作成
            string createResponse = await CreateRecordAsync(new { title = "Hello", body = "World" });
            Debug.Log("[CrudDemoRunner] Create Response: " + createResponse);
            CrudResponse createResult = JsonUtility.FromJson<CrudResponse>(createResponse);
            string recordId = createResult.id;
            Debug.Log("[CrudDemoRunner] Created record id: " + recordId);

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

            // Update: レコードの更新
            string updateResponse = await UpdateRecordAsync(recordId, new { title = "Updated", body = "Content" });
            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}");
        }
    }

    /// <summary>
    /// POST リクエストにより新規レコードを作成します。
    /// </summary>
    private async UniTask<string> CreateRecordAsync(object data)
    {
        return await _apiClient.SendRequestAsync(
            HttpMethod.POST,
            BaseUrl,
            data,
            maxRetries: 3,
            initialInterval: 2f,
            timeoutSeconds: 10f,
            cancellationToken: _cts.Token
        );
    }

    /// <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, object data)
    {
        string url = $"{BaseUrl}?id={id}";
        return await _apiClient.SendRequestAsync(
            HttpMethod.PUT,
            url,
            data,
            maxRetries: 3,
            initialInterval: 2f,
            timeoutSeconds: 10f,
            cancellationToken: _cts.Token
        );
    }

    /// <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
        );
    }

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

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

作成者以外のコメントは許可されていません