公開日 2026-07-11

FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)

Flutter から json-server の REST API を呼び、http パッケージで GET / POST、JSON 変換、エラー処理を最小構成で確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. まずは通信全体の流れを掴む
  6. 3. 動かす環境を整える
  7. 3-1. Flutter プロジェクトを作る
  8. 3-2. エミュレーターを起動する
  9. 3-3. json-server でローカル API を立てる
  10. コードのポイント
  11. 3-4. http パッケージを追加する
  12. 4. 一覧取得と登録を lib/main.dart にまとめる
  13. コードのポイント
  14. 5. どこでエラーを拾うかを 4 パターンに分ける
  15. 6. まとめ

Flutterのルーティング入門(Navigator と go_router の使い分け) の次に、実際の画面から外部データを取りに行く入口が REST API 通信です。ここで止まりやすいのは http.get の書き方そのものではありません。ローカルの API をどう用意するか、Android Emulator からどの URL を叩くか、失敗したときにどこで止めるかが曖昧なまま書き始めるところにあります。この記事では json-server を使って Docker なしで API を立て、Flutter から GET / POST、JSON デシリアライズ、一覧表示、エラー処理までを最小構成で確認します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築、Dart 入門、基本 UI、一覧 UI、ルーティング入門までは終わった人
  • Flutter から API を呼びたいが、何をどこまで用意すればよいかまだ曖昧な人
  • http パッケージで JSON 通信を始め、失敗時の見せ方も最初に揃えたい人

この記事で到達する状態

  • json-server でローカル API を起動できる
  • http パッケージで GET /shipmentsPOST /shipments を呼べる
  • JSON を Dart の Model に変換できる
  • 一覧表示、再読込、登録後の反映を最小構成で確認できる
  • statusCode、接続失敗、タイムアウト、JSON 形式不正の切り分け方を説明できる

非対象

  • Riverpod / Bloc などの状態管理ライブラリ
  • JWT やログイン状態保持
  • ページング、検索条件、無限スクロール
  • Repository パターンやファイル分割の設計
  • 本番 API サーバーや HTTPS 証明書の話

今回は通信の入口を作るところに絞ります。状態管理や認証は後続記事へ回し、まずは「ローカル API を呼んで、失敗を UI で受け止める」最小形を固めます。

2. まずは通信全体の流れを掴む

今回の流れは次の 4 層です。

flowchart LR
  A[Flutter 画面] --> B[ShipmentApiClient]
  B -->|GET /shipments| C[json-server]
  B -->|POST /shipments| C
  C --> D[db.json]
  B --> E{結果を判定}
  E -->|200 / 201| F[一覧更新]
  E -->|statusCode異常| G[エラー表示]
  E -->|接続失敗 / timeout| G
  E -->|JSON形式不正| G

先に図で見ておくと、エラー処理の置き場が分かりやすくなります。

  • API サーバー未起動時は接続失敗
  • URL が誤っている場合は 404500
  • JSON の形が違う場合はデシリアライズで停止
  • タイムアウトは接続できていても発生する

見るべき点は「通信に成功したか」だけではありません。どの層で失敗したかを分けておくと、読者が次に確認する場所をすぐ特定できます。

3. 動かす環境を整える

3-1. Flutter プロジェクトを作る

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

次のコマンドでプロジェクトを作成します。

flutter create my_api_app
cd my_api_app

3-2. エミュレーターを起動する

利用可能なエミュレーター一覧を確認します。

flutter emulators

表示されたIDを指定して起動します。

flutter emulators --launch <emulator_id>

3-3. json-server でローカル API を立てる

Flutter から呼び出す API をローカルで用意します。今回は json-server を使います。

my_api_app/db.json を作成します。内容は次の通りです。

このファイルは、一覧取得と登録で使う最小データを json-server に渡すための定義です。注目点は、Flutter 側で扱う Shipment モデルと同じキー構成をここで固定していることです。

{
  "shipments": [
    {
      "id": 1,
      "code": "S-1001",
      "customer": "東京商事",
      "status": "未出荷"
    },
    {
      "id": 2,
      "code": "S-1002",
      "customer": "大阪物産",
      "status": "ピッキング中"
    },
    {
      "id": 3,
      "code": "S-1003",
      "customer": "名古屋販売",
      "status": "確認待ち"
    }
  ]
}

コードのポイント

shipments 配列がそのまま API の一覧レスポンスになる

{
  "shipments": [
    {
      "id": 1,
      "code": "S-1001",
      "customer": "東京商事",
      "status": "未出荷"
    }
  ]
}

json-server では shipments 配列がそのまま GET /shipments のレスポンスになります。Flutter 側は配列を前提に読んでいるため、ここをオブジェクトへ変えると FormatException を確認できる構成です。

② 各要素のキー名を Flutter 側のモデルと揃えている

{
  "id": 1,
  "code": "S-1001",
  "customer": "東京商事",
  "status": "未出荷"
}

id code customer status の 4 キーが、後段の Shipment.fromJson() で必須値として読まれます。ローカル API を先にこの形へ揃えておくと、通信確認とモデル変換の切り分けがしやすくなります。

my_api_app/ ディレクトリで次のコマンドを実行します。

npx json-server --watch db.json --port 3000

起動後にブラウザで http://localhost:3000/shipments を開けば、JSON 配列を確認できます。

ブラウザで json-server のレスポンスを確認している画面

Android Emulator からホスト PC の localhost へは、そのままでは届きません。

  • Android Emulator: http://10.0.2.2:3000
  • Windows デスクトップ実行、iOS Simulator、Web: http://localhost:3000

今回のサンプルコードでは、この違いを baseUrl の切り替えで吸収します。localhost のまま進めると Android Emulator だけ接続失敗になるので、ここは早めに押さえたほうが安全です。

3-4. http パッケージを追加する

プロジェクト直下で次のコマンドを実行します。

flutter pub add http

4. 一覧取得と登録を lib/main.dart にまとめる

lib/main.dart は次の内容で作成します。

このファイルは、一覧取得、登録、再読込、エラー表示までを 1 画面で確認する最小サンプルです。注目点は、ShipmentApiClient に通信を寄せ、画面側は状態表示と入力操作へ集中していることです。

import 'dart:async';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter REST API sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const ShipmentPage(),
    );
  }
}

class ApiException implements Exception {
  const ApiException(this.message);

  final String message;

  @override
  String toString() => message;
}

class Shipment {
  const Shipment({
    required this.id,
    required this.code,
    required this.customer,
    required this.status,
  });

  final int id;
  final String code;
  final String customer;
  final String status;

  factory Shipment.fromJson(Map<String, dynamic> json) {
    final id = json['id'];
    final code = json['code'];
    final customer = json['customer'];
    final status = json['status'];

    if (id == null || code == null || customer == null || status == null) {
      throw const FormatException('shipment の必須キーが不足しています。');
    }

    return Shipment(
      id: _parseId(id),
      code: code.toString(),
      customer: customer.toString(),
      status: status.toString(),
    );
  }

  Map<String, dynamic> toCreateJson() {
    return {
      'code': code,
      'customer': customer,
      'status': status,
    };
  }

  static int _parseId(Object value) {
    if (value is int) {
      return value;
    }

    if (value is num) {
      return value.toInt();
    }

    return int.parse(value.toString());
  }
}

class ShipmentApiClient {
  ShipmentApiClient({http.Client? client}) : _client = client ?? http.Client();

  final http.Client _client;

  String get baseUrl {
    if (kIsWeb) {
      return 'http://localhost:3000';
    }

    if (defaultTargetPlatform == TargetPlatform.android) {
      return 'http://10.0.2.2:3000';
    }

    return 'http://localhost:3000';
  }

  Future<List<Shipment>> fetchShipments() async {
    try {
      final response = await _client
          .get(Uri.parse('$baseUrl/shipments'))
          .timeout(const Duration(seconds: 5));

      if (response.statusCode != 200) {
        throw ApiException(
          '一覧取得に失敗しました。statusCode: ${response.statusCode}',
        );
      }

      final decoded = jsonDecode(utf8.decode(response.bodyBytes));

      if (decoded is! List) {
        throw const FormatException('配列 JSON を期待しました。');
      }

      return decoded
          .map((item) => Shipment.fromJson(item as Map<String, dynamic>))
          .toList();
    } catch (error) {
      throw _normalizeError(error);
    }
  }

  Future<Shipment> createShipment({
    required String code,
    required String customer,
  }) async {
    try {
      final response = await _client
          .post(
            Uri.parse('$baseUrl/shipments'),
            headers: {'Content-Type': 'application/json; charset=UTF-8'},
            body: jsonEncode(
              Shipment(
                id: 0,
                code: code,
                customer: customer,
                status: '未出荷',
              ).toCreateJson(),
            ),
          )
          .timeout(const Duration(seconds: 5));

      if (response.statusCode != 201) {
        throw ApiException(
          '登録に失敗しました。statusCode: ${response.statusCode}',
        );
      }

      final decoded = jsonDecode(utf8.decode(response.bodyBytes));

      if (decoded is! Map<String, dynamic>) {
        throw const FormatException('オブジェクト JSON を期待しました。');
      }

      return Shipment.fromJson(decoded);
    } catch (error) {
      throw _normalizeError(error);
    }
  }

  void dispose() {
    _client.close();
  }

  ApiException _normalizeError(Object error) {
    if (error is ApiException) {
      return error;
    }

    if (error is TimeoutException) {
      return const ApiException(
        '通信がタイムアウトしました。json-server が起動しているか確認してください。',
      );
    }

    if (error is http.ClientException) {
      return const ApiException(
        'API へ接続できません。json-server の起動と、Android Emulator では 10.0.2.2 を使っているかを確認してください。',
      );
    }

    if (error is FormatException) {
      return ApiException(
        'JSON を読み取れませんでした。db.json の構造を確認してください。詳細: $error',
      );
    }

    return ApiException('予期しないエラーが発生しました。詳細: $error');
  }
}

class ShipmentPage extends StatefulWidget {
  const ShipmentPage({super.key});

  @override
  State<ShipmentPage> createState() => _ShipmentPageState();
}

class _ShipmentPageState extends State<ShipmentPage> {
  final _codeController = TextEditingController();
  final _customerController = TextEditingController();
  late final ShipmentApiClient _apiClient;

  List<Shipment> _shipments = const [];
  String? _loadError;
  bool _isLoading = true;
  bool _isSubmitting = false;

  @override
  void initState() {
    super.initState();
    _apiClient = ShipmentApiClient();
    _loadShipments();
  }

  @override
  void dispose() {
    _apiClient.dispose();
    _codeController.dispose();
    _customerController.dispose();
    super.dispose();
  }

  Future<void> _loadShipments() async {
    setState(() {
      _isLoading = true;
      _loadError = null;
    });

    try {
      final shipments = await _apiClient.fetchShipments();

      if (!mounted) {
        return;
      }

      setState(() {
        _shipments = shipments;
      });
    } catch (error) {
      if (!mounted) {
        return;
      }

      setState(() {
        _loadError = error.toString();
      });
    } finally {
      if (!mounted) {
        return;
      }

      setState(() {
        _isLoading = false;
      });
    }
  }

  Future<void> _submit() async {
    final code = _codeController.text.trim();
    final customer = _customerController.text.trim();

    if (code.isEmpty || customer.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('出荷番号と取引先名を入力してください。')),
      );
      return;
    }

    setState(() {
      _isSubmitting = true;
    });

    try {
      final created = await _apiClient.createShipment(
        code: code,
        customer: customer,
      );

      if (!mounted) {
        return;
      }

      setState(() {
        _shipments = [created, ..._shipments];
        _codeController.clear();
        _customerController.clear();
      });

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('${created.code} を登録しました。')),
      );
    } catch (error) {
      if (!mounted) {
        return;
      }

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(error.toString())),
      );
    } finally {
      if (!mounted) {
        return;
      }

      setState(() {
        _isSubmitting = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('出荷一覧 API サンプル'),
        actions: [
          IconButton(
            onPressed: _isLoading ? null : _loadShipments,
            icon: const Icon(Icons.refresh),
            tooltip: '再読込',
          ),
        ],
      ),
      body: RefreshIndicator(
        onRefresh: _loadShipments,
        child: ListView(
          physics: const AlwaysScrollableScrollPhysics(),
          padding: const EdgeInsets.all(16),
          children: [
            _InfoCard(baseUrl: _apiClient.baseUrl),
            const SizedBox(height: 16),
            TextField(
              controller: _codeController,
              decoration: const InputDecoration(
                labelText: '出荷番号',
                border: OutlineInputBorder(),
                hintText: 'S-2001',
              ),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _customerController,
              decoration: const InputDecoration(
                labelText: '取引先名',
                border: OutlineInputBorder(),
                hintText: '福岡商店',
              ),
            ),
            const SizedBox(height: 12),
            FilledButton.icon(
              onPressed: _isSubmitting ? null : _submit,
              icon: _isSubmitting
                  ? const SizedBox(
                      width: 18,
                      height: 18,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : const Icon(Icons.add),
              label: Text(_isSubmitting ? '登録中...' : 'POST で登録する'),
            ),
            const SizedBox(height: 24),
            Text(
              '出荷一覧',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 12),
            if (_isLoading)
              const Padding(
                padding: EdgeInsets.symmetric(vertical: 48),
                child: Center(child: CircularProgressIndicator()),
              )
            else if (_loadError != null)
              _ErrorPanel(
                message: _loadError!,
                onRetry: _loadShipments,
              )
            else if (_shipments.isEmpty)
              const _EmptyPanel()
            else
              ..._shipments.map((shipment) => _ShipmentCard(shipment: shipment)),
          ],
        ),
      ),
    );
  }
}

class _InfoCard extends StatelessWidget {
  const _InfoCard({required this.baseUrl});

  final String baseUrl;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '接続先',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Text(baseUrl),
            const SizedBox(height: 8),
            const Text(
              'Android Emulator では 10.0.2.2、その他のローカル実行では localhost を使います。',
            ),
          ],
        ),
      ),
    );
  }
}

class _ShipmentCard extends StatelessWidget {
  const _ShipmentCard({required this.shipment});

  final Shipment shipment;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: ListTile(
        leading: CircleAvatar(child: Text('${shipment.id}')),
        title: Text(shipment.code),
        subtitle: Text('${shipment.customer} / ${shipment.status}'),
      ),
    );
  }
}

class _ErrorPanel extends StatelessWidget {
  const _ErrorPanel({required this.message, required this.onRetry});

  final String message;
  final Future<void> Function() onRetry;

  @override
  Widget build(BuildContext context) {
    return Card(
      color: Colors.red.shade50,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '一覧を取得できませんでした',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Text(message),
            const SizedBox(height: 12),
            OutlinedButton.icon(
              onPressed: onRetry,
              icon: const Icon(Icons.refresh),
              label: const Text('再読込する'),
            ),
          ],
        ),
      ),
    );
  }
}

class _EmptyPanel extends StatelessWidget {
  const _EmptyPanel();

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: const [
            Icon(Icons.inbox_outlined, size: 36),
            SizedBox(height: 8),
            Text('データが 0 件です。POST で 1 件追加してみてください。'),
          ],
        ),
      ),
    );
  }
}

コードのポイント

ShipmentApiClient が GET と POST の責務を引き受けている

class ShipmentApiClient {
  ShipmentApiClient({http.Client? client}) : _client = client ?? http.Client();

  final http.Client _client;

  String get baseUrl {
    if (kIsWeb) {
      return 'http://localhost:3000';
    }

    if (defaultTargetPlatform == TargetPlatform.android) {
      return 'http://10.0.2.2:3000';
    }

    return 'http://localhost:3000';
  }

  Future<List<Shipment>> fetchShipments() async {
    try {
      final response = await _client
          .get(Uri.parse('$baseUrl/shipments'))
          .timeout(const Duration(seconds: 5));

      if (response.statusCode != 200) {
        throw ApiException(
          '一覧取得に失敗しました。statusCode: ${response.statusCode}',
        );
      }
    } catch (error) {
      throw _normalizeError(error);
    }
  }

  Future<Shipment> createShipment({

一覧取得と登録の HTTP 通信を ShipmentApiClient に集めることで、Widget 側は「いつ呼ぶか」と「結果をどう見せるか」に集中できます。さらに _normalizeError() で失敗理由を 1 か所へ集約しているため、UI には次の確認ポイントが分かる文言だけを返せます。

② 画面側は状態ごとに表示を切り替える

  Future<void> _loadShipments() async {
    setState(() {
      _isLoading = true;
      _loadError = null;
    });

    try {
      final shipments = await _apiClient.fetchShipments();

      if (!mounted) {
        return;
      }

      setState(() {
        _shipments = shipments;
      });
    } catch (error) {
      if (!mounted) {
        return;
      }

      setState(() {
        _loadError = error.toString();
      });
    } finally {
      if (!mounted) {
        return;
      }

      setState(() {
        _isLoading = false;
      });
    }
  }

_isLoading_loadError_shipments を分けることで、読込中、失敗、空、一覧ありの各状態を 1 画面で切り替えられます。FutureBuilder を使わずこの形にしているのは、再読込と登録処理を同じ画面で追いやすくするためです。

コードを貼り付けたら json-server を起動したままエミュレーターが立ち上がっている状態で、次のコマンドを実行します。

flutter run
出荷一覧 API サンプル:一覧3件と接続先カード・POST 入力欄

5. どこでエラーを拾うかを 4 パターンに分ける

今回のサンプルでは、失敗を次の 4 パターンに分けています。

パターンどこで拾うかまず確認すること
404500response.statusCodeURL、エンドポイント、json-server の起動内容
タイムアウト.timeout(...)サーバーが起動しているか、通信が止まっていないか
接続失敗http.ClientExceptionAndroid Emulator なら 10.0.2.2 を使っているか
JSON 形式不正FormatExceptiondb.json のキー名、配列 / オブジェクトの形

この 4 つを分ける理由は、読者が次の確認場所を迷わないようにするためです。全部を catch (e) でまとめて「通信に失敗しました」にしてしまうと、サーバー未起動なのか、URL 間違いなのか、JSON 構造違いなのかが見えません。

試しに json-server を止めて再読込すると、一覧部分は _ErrorPanel に切り替わります。逆に db.jsonshipments をオブジェクトへ変えてしまうと、今度は JSON 形式不正として落ちます。どの分岐が動くかは、失敗を 1 回ずつ試すのが最も早い確認方法。

エラーパネルと再読込ボタン

6. まとめ

Flutter から REST API を呼ぶ最初の構成として、json-serverGET /shipmentsPOST /shipments を用意し、Flutter 側では http パッケージ、Model 変換、一覧表示、エラー処理までを 1 ファイルで確認しました。

この入口が固まったら、次は手書き fromJson をどう減らすかを見るとつながりやすくなります。Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う では、今回のようなレスポンスを生成コードへ置き換える流れを扱います。まずは今回のサンプルを手元で動かし、json-server を止めたときと db.json を崩したときの違いまで確認しておくこと。その差が分かると、モデル生成へ進んだあとも迷いにくくなります。

シリーズ 17/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) 現在の記事
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)