API + エラーハンドリング + 認証

エラーハンドリング: 転んだときの受け身 🤸
認証: 家の鍵 🔐

アプリを安全で壊れにくくします!

目次

エラーハンドリングって何?

エラーの種類:

ネットワークエラー通信失敗Wi-Fiが切れた
タイムアウト応答が遅いサーバーが重い
404 Not Foundデータがない削除済みの投稿
401 Unauthorized認証エラーログインが必要
500 Server Errorサーバー側の問題サーバーダウン
JSONパースエラーデータ形式が違う予期しない形式

基本の try-catch

Future<void> fetchData() async {
  try {
    // ========================================
    // API リクエスト
    // ========================================
    final url = Uri.parse('https://api.example.com/data');
    final response = await http.get(url);
    
    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      print(data);
    } else {
      throw Exception('エラー: ${response.statusCode}');
    }
    
  } on SocketException {
    // ========================================
    // ネットワークエラー
    // ========================================
    print('ネットワーク接続がありません');
    
  } on TimeoutException {
    // ========================================
    // タイムアウト
    // ========================================
    print('タイムアウトしました');
    
  } on FormatException {
    // ========================================
    // JSONパースエラー
    // ========================================
    print('データ形式が正しくありません');
    
  } catch (e) {
    // ========================================
    // その他のエラー
    // ========================================
    print('予期しないエラー: $e');
  }
}

例1: エラーハンドリング付き API サービス

import 'dart:io';
import 'dart:async';
import 'package:http/http.dart' as http;
import 'dart:convert';

class ApiService {
  static const String baseUrl = 'https://api.example.com';
  
  // ========================================
  // GET リクエスト (エラーハンドリング完全版)
  // ========================================
  Future<dynamic> get(String endpoint) async {
    try {
      final url = Uri.parse('$baseUrl$endpoint');
      
      // タイムアウト設定
      final response = await http.get(url).timeout(
        Duration(seconds: 10),
        onTimeout: () {
          throw TimeoutException('リクエストがタイムアウトしました');
        },
      );
      
      // ステータスコード別処理
      return _handleResponse(response);
      
    } on SocketException {
      throw NetworkException('インターネット接続を確認してください');
      
    } on TimeoutException {
      throw NetworkException('サーバーの応答が遅すぎます');
      
    } on FormatException {
      throw ApiException('データ形式が正しくありません');
      
    } catch (e) {
      throw ApiException('予期しないエラー: $e');
    }
  }
  
  // ========================================
  // レスポンス処理
  // ========================================
  dynamic _handleResponse(http.Response response) {
    switch (response.statusCode) {
      case 200:
      case 201:
        // 成功
        try {
          return json.decode(response.body);
        } catch (e) {
          throw ApiException('JSONパースエラー');
        }
        
      case 400:
        throw ApiException('リクエストが不正です', statusCode: 400);
        
      case 401:
        throw UnauthorizedException('認証が必要です');
        
      case 403:
        throw UnauthorizedException('アクセス権限がありません');
        
      case 404:
        throw ApiException('データが見つかりません', statusCode: 404);
        
      case 500:
      case 502:
      case 503:
        throw ApiException('サーバーエラーが発生しました', statusCode: response.statusCode);
        
      default:
        throw ApiException(
          'エラーが発生しました',
          statusCode: response.statusCode,
        );
    }
  }
  
  // ========================================
  // POST リクエスト
  // ========================================
  Future<dynamic> post(String endpoint, Map<String, dynamic> data) async {
    try {
      final url = Uri.parse('$baseUrl$endpoint');
      
      final response = await http.post(
        url,
        headers: {'Content-Type': 'application/json'},
        body: json.encode(data),
      ).timeout(Duration(seconds: 10));
      
      return _handleResponse(response);
      
    } on SocketException {
      throw NetworkException('インターネット接続を確認してください');
      
    } on TimeoutException {
      throw NetworkException('サーバーの応答が遅すぎます');
      
    } catch (e) {
      throw ApiException('予期しないエラー: $e');
    }
  }
}

例2: UI でのエラー表示

class StudentListPage extends StatefulWidget {
  @override
  _StudentListPageState createState() => _StudentListPageState();
}

class _StudentListPageState extends State<StudentListPage> {
  final ApiService _apiService = ApiService();
  List<Student> _students = [];
  bool _isLoading = false;
  String? _errorMessage;
  
  // ========================================
  // データ取得 (エラーハンドリング付き)
  // ========================================
  Future<void> fetchStudents() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });
    
    try {
      final data = await _apiService.get('/students');
      
      setState(() {
        _students = (data as List)
            .map((json) => Student.fromJson(json))
            .toList();
        _isLoading = false;
      });
      
    } on NetworkException catch (e) {
      setState(() {
        _errorMessage = e.message;
        _isLoading = false;
      });
      
      _showErrorSnackBar('ネットワークエラー: ${e.message}');
      
    } on UnauthorizedException catch (e) {
      setState(() {
        _errorMessage = e.message;
        _isLoading = false;
      });
      
      _showErrorSnackBar('認証エラー: ${e.message}');
      // ログイン画面へ遷移
      _navigateToLogin();
      
    } on ApiException catch (e) {
      setState(() {
        _errorMessage = e.message;
        _isLoading = false;
      });
      
      _showErrorSnackBar(e.message);
      
    } catch (e) {
      setState(() {
        _errorMessage = '予期しないエラーが発生しました';
        _isLoading = false;
      });
      
      _showErrorSnackBar('エラー: $e');
    }
  }
  
  // ========================================
  // エラー表示
  // ========================================
  void _showErrorSnackBar(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.red,
        action: SnackBarAction(
          label: '再試行',
          textColor: Colors.white,
          onPressed: fetchStudents,
        ),
      ),
    );
  }
  
  void _navigateToLogin() {
    Navigator.pushReplacementNamed(context, '/login');
  }
  
  @override
  void initState() {
    super.initState();
    fetchStudents();
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('生徒一覧')),
      body: _buildBody(),
    );
  }
  
  // ========================================
  // 状態別UI
  // ========================================
  Widget _buildBody() {
    // ローディング中
    if (_isLoading) {
      return Center(child: CircularProgressIndicator());
    }
    
    // エラー表示
    if (_errorMessage != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, size: 60, color: Colors.red),
            SizedBox(height: 16),
            Text(
              _errorMessage!,
              style: TextStyle(fontSize: 16, color: Colors.grey[700]),
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 24),
            ElevatedButton.icon(
              icon: Icon(Icons.refresh),
              label: Text('再試行'),
              onPressed: fetchStudents,
            ),
          ],
        ),
      );
    }
    
    // データ表示
    if (_students.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.inbox, size: 60, color: Colors.grey),
            SizedBox(height: 16),
            Text('生徒がいません'),
          ],
        ),
      );
    }
    
    return ListView.builder(
      itemCount: _students.length,
      itemBuilder: (context, index) {
        return StudentCard(student: _students[index]);
      },
    );
  }
}

例3: リトライ機能

class ApiService {
  // ========================================
  // リトライ付きリクエスト
  // ========================================
  Future<dynamic> getWithRetry(
    String endpoint, {
    int maxRetries = 3,
    Duration retryDelay = const Duration(seconds: 2),
  }) async {
    int attempt = 0;
    
    while (attempt < maxRetries) {
      try {
        return await get(endpoint);
        
      } on NetworkException catch (e) {
        attempt++;
        
        if (attempt >= maxRetries) {
          rethrow;  // 最後の試行で失敗したら、エラーを投げる
        }
        
        print('リトライ $attempt/$maxRetries...');
        await Future.delayed(retryDelay);
        
      } on ApiException catch (e) {
        // APIエラーはリトライしない
        rethrow;
      }
    }
    
    throw NetworkException('リトライ回数を超えました');
  }
}

// 使い方
try {
  final data = await apiService.getWithRetry('/students');
  print(data);
} catch (e) {
  print('全てのリトライが失敗: $e');
}

認証 (Authentication)

方式説明用途
API キー固定の文字列簡易API
Basic 認証ユーザー名+パスワードWordPress
Bearer トークントークン文字列REST API
OAuth 2.0外部サービス連携Google, Facebook
JWTJSON Web TokenモダンAPI

🔑 1. API キー認証

class ApiService {
  static const String apiKey = 'YOUR_API_KEY_HERE';
  
  Future<dynamic> get(String endpoint) async {
    final url = Uri.parse('https://api.example.com$endpoint');
    
    final response = await http.get(
      url,
      headers: {
        'X-API-Key': apiKey,  // ← API キー
        'Content-Type': 'application/json',
      },
    );
    
    return _handleResponse(response);
  }
}

注意: API キーはコードに直接書かない!

🔑 2. Basic 認証 (WordPress)

import 'dart:convert';

class WordPressService {
  static const String baseUrl = 'https://your-site.com';
  static const String username = 'your-username';
  static const String appPassword = 'xxxx xxxx xxxx xxxx';  // アプリケーションパスワード
  
  // ========================================
  // Basic認証ヘッダー
  // ========================================
  Map<String, String> get authHeaders {
    String credentials = '$username:$appPassword';
    String encoded = base64Encode(utf8.encode(credentials));
    
    return {
      'Content-Type': 'application/json',
      'Authorization': 'Basic $encoded',
    };
  }
  
  // ========================================
  // 投稿を取得
  // ========================================
  Future<List<dynamic>> getPosts() async {
    final url = Uri.parse('$baseUrl/wp-json/wp/v2/posts');
    
    final response = await http.get(
      url,
      headers: authHeaders,  // ← Basic認証
    );
    
    if (response.statusCode == 200) {
      return json.decode(response.body);
    } else if (response.statusCode == 401) {
      throw UnauthorizedException('認証に失敗しました');
    } else {
      throw ApiException('エラー: ${response.statusCode}');
    }
  }
  
  // ========================================
  // 投稿を作成
  // ========================================
  Future<dynamic> createPost({
    required String title,
    required String content,
  }) async {
    final url = Uri.parse('$baseUrl/wp-json/wp/v2/posts');
    
    final response = await http.post(
      url,
      headers: authHeaders,
      body: json.encode({
        'title': title,
        'content': content,
        'status': 'publish',
      }),
    );
    
    if (response.statusCode == 201) {
      return json.decode(response.body);
    } else if (response.statusCode == 401) {
      throw UnauthorizedException('認証に失敗しました');
    } else {
      throw ApiException('投稿作成失敗: ${response.statusCode}');
    }
  }
}

🔑 3. Bearer トークン認証

class ApiService {
  String? _accessToken;
  
  // ========================================
  // トークンを設定
  // ========================================
  void setAccessToken(String token) {
    _accessToken = token;
  }
  
  // ========================================
  // 認証ヘッダー
  // ========================================
  Map<String, String> get authHeaders {
    if (_accessToken == null) {
      throw UnauthorizedException('トークンが設定されていません');
    }
    
    return {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer $_accessToken',  // ← Bearer トークン
    };
  }
  
  // ========================================
  // ログイン
  // ========================================
  Future<void> login(String email, String password) async {
    final url = Uri.parse('https://api.example.com/auth/login');
    
    final response = await http.post(
      url,
      headers: {'Content-Type': 'application/json'},
      body: json.encode({
        'email': email,
        'password': password,
      }),
    );
    
    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      _accessToken = data['token'];  // トークンを保存
      
      // SharedPreferences に保存
      final prefs = await SharedPreferences.getInstance();
      await prefs.setString('access_token', _accessToken!);
      
    } else {
      throw UnauthorizedException('ログイン失敗');
    }
  }
  
  // ========================================
  // 保存されたトークンを読み込み
  // ========================================
  Future<void> loadToken() async {
    final prefs = await SharedPreferences.getInstance();
    _accessToken = prefs.getString('access_token');
  }
  
  // ========================================
  // ログアウト
  // ========================================
  Future<void> logout() async {
    _accessToken = null;
    
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove('access_token');
  }
  
  // ========================================
  // 認証が必要なリクエスト
  // ========================================
  Future<dynamic> getProfile() async {
    final url = Uri.parse('https://api.example.com/user/profile');
    
    final response = await http.get(
      url,
      headers: authHeaders,  // ← Bearer トークン
    );
    
    if (response.statusCode == 200) {
      return json.decode(response.body);
    } else if (response.statusCode == 401) {
      // トークンが無効
      await logout();
      throw UnauthorizedException('セッションが切れました。再ログインしてください。');
    } else {
      throw ApiException('エラー: ${response.statusCode}');
    }
  }
}

🔑 4. OAuth 2.0 (Google)

import 'package:google_sign_in/google_sign_in.dart';

class GoogleAuthService {
  final GoogleSignIn _googleSignIn = GoogleSignIn(
    scopes: [
      'email',
      'https://www.googleapis.com/auth/calendar',
    ],
  );
  
  // ========================================
  // ログイン
  // ========================================
  Future<GoogleSignInAccount?> signIn() async {
    try {
      final account = await _googleSignIn.signIn();
      
      if (account != null) {
        // 認証成功
        final auth = await account.authentication;
        print('Access Token: ${auth.accessToken}');
        print('ID Token: ${auth.idToken}');
        
        return account;
      }
      
      return null;
      
    } catch (e) {
      print('ログインエラー: $e');
      throw UnauthorizedException('Googleログインに失敗しました');
    }
  }
  
  // ========================================
  // ログアウト
  // ========================================
  Future<void> signOut() async {
    await _googleSignIn.signOut();
  }
  
  // ========================================
  // 認証ヘッダーを取得
  // ========================================
  Future<Map<String, String>> getAuthHeaders() async {
    final account = _googleSignIn.currentUser;
    
    if (account == null) {
      throw UnauthorizedException('ログインしていません');
    }
    
    final auth = await account.authentication;
    
    return {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ${auth.accessToken}',
    };
  }
}

🔑 5. JWT (JSON Web Token)

import 'package:jwt_decoder/jwt_decoder.dart';

class JwtService {
  String? _token;
  
  // ========================================
  // トークンを保存
  // ========================================
  Future<void> saveToken(String token) async {
    _token = token;
    
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('jwt_token', token);
  }
  
  // ========================================
  // トークンを読み込み
  // ========================================
  Future<void> loadToken() async {
    final prefs = await SharedPreferences.getInstance();
    _token = prefs.getString('jwt_token');
  }
  
  // ========================================
  // トークンが有効か確認
  // ========================================
  bool isTokenValid() {
    if (_token == null) return false;
    
    try {
      // トークンの有効期限を確認
      bool isExpired = JwtDecoder.isExpired(_token!);
      return !isExpired;
    } catch (e) {
      return false;
    }
  }
  
  // ========================================
  // トークンをデコード
  // ========================================
  Map<String, dynamic>? decodeToken() {
    if (_token == null) return null;
    
    try {
      return JwtDecoder.decode(_token!);
    } catch (e) {
      return null;
    }
  }
  
  // ========================================
  // ユーザーIDを取得
  // ========================================
  String? getUserId() {
    final decoded = decodeToken();
    return decoded?['userId'];
  }
  
  // ========================================
  // 認証ヘッダー
  // ========================================
  Map<String, String> getAuthHeaders() {
    if (_token == null || !isTokenValid()) {
      throw UnauthorizedException('トークンが無効です');
    }
    
    return {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer $_token',
    };
  }
  
  // ========================================
  // トークンを削除
  // ========================================
  Future<void> clearToken() async {
    _token = null;
    
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove('jwt_token');
  }
}

セキュリティ対策

1)トークンを安全に保存

// ❌ 危険
String token = 'abc123';  // コードに直接書く

// ✅ 安全
// SharedPreferences に暗号化して保存
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorage {
  final _storage = FlutterSecureStorage();
  
  Future<void> saveToken(String token) async {
    await _storage.write(key: 'access_token', value: token);
  }
  
  Future<String?> getToken() async {
    return await _storage.read(key: 'access_token');
  }
  
  Future<void> deleteToken() async {
    await _storage.delete(key: 'access_token');
  }
}

2)環境変数を使う

# pubspec.yaml
dependencies:
  flutter_dotenv: ^5.1.0

# .env ファイル
API_KEY=your_api_key_here
API_SECRET=your_secret_here
import 'package:flutter_dotenv/flutter_dotenv.dart';

Future<void> main() async {
  await dotenv.load();  // .env を読み込む
  runApp(MyApp());
}

// 使い方
String apiKey = dotenv.env['API_KEY'] ?? '';

注意: .env ファイルは .gitignore に追加!

3)HTTPS を使う

// ❌ 危険
final url = Uri.parse('http://api.example.com/data');  // HTTP

// ✅ 安全
final url = Uri.parse('https://api.example.com/data');  // HTTPS

まとめ: エラーハンドリング + 認証

エラーハンドリング:

  • try-catch で種類別に処理
  • カスタム例外を作る
  • ユーザーにわかりやすいメッセージ
  • リトライ機能

認証:

方式使い所
API キー簡易API
BasicWordPress
BearerREST API
OAuthGoogle, Facebook
JWTモダンAPI

セキュリティ:

  • トークンは暗号化して保存
  • 環境変数を使う
  • HTTPS を使う
  • .env は Git に入れない

一言まとめ:
エラーハンドリングでアプリを壊れにくく!
try-catch で種類別処理、カスタム例外でわかりやすく。
認証はAPI キー、Basic、Bearer、OAuth など用途に応じて選択。
トークンは暗号化して保存、HTTPS を使う!

目次