[Flutter] 楽天APIでDVD/BD情報を取得する

# はじめに

Flutter の勉強として、DVD/BD管理アプリ風のものを作っています。

https://qiita.com/grhg/items/936a63c35146eb6fbb49

前回は、ローカルDBへの永続化を実施しました。
今回は、楽天APIを使ったDVD/BD情報を取得してみたいと思います。

# 楽天APIの選定理由

映画情報の取得であれば、TMDbのAPIが使えそうでしたが、DVD/BD管理アプリというコンセプトから、商品検索が可能なAPIを選びました。
Amazon API も後々試してみたいと思いますが、ひとまず今回は楽天APIを使ってみます。

:::note warn

楽天API には 1秒に1回までのリクエスト制限があるため、超えないように注意しましょう
https://webservice.faq.rakuten.net/hc/ja/articles/900001974383-%E5%90%84API%E3%81%AE%E5%88%A9%E7%94%A8%E5%88%B6%E9%99%90%E3%82%92%E6%95%99%E3%81%88%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84

Amazon API の方も同様
https://affiliate.amazon.co.jp/help/node/topic/GLL6HEVVWUKMQDDQ

:::

# 楽天アプリケーションIDの取得

## アプリケーションの登録

https://webservice.rakuten.co.jp

の「+ New App」からアプリケーションを登録します。

すると、Application IDが発行されます。これをリクエスト時に利用するので控えておきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/2bb48fc7-fe01-6333-e304-45df41a86869.png)

## 試し撃ち

https://webservice.rakuten.co.jp/documentation/books-dvd-search

「楽天ブックスDVD/Blu-ray検索API」を使ってみます。

以下のコマンドを実行します。 ([API Key]のところは取得したApplication IDに置き換えてください)

```bash
https://app.rakuten.co.jp/services/api/BooksDVD/Search/20170404?applicationId=[API Key]&title=%E3%83%9D%E3%83%83%E3%82%BF%E3%83%BC&sort=%2BitemPrice
```

実行結果

```bash
{"GenreInformation":[],"Items":[{"Item":{"affiliateUrl":"","artistName":"クリス・コロンバス/ダニエル・ラドクリフ/ルパート・グリント/エマ・ワトソン","artistNameKana":"コロンバス クリス/ラドクリフ ダニエル/グリント ルパート/ワトソン エマ","availability":"4","booksGenreId":"003201003","discountPrice":0,"discountRate":0,"itemCaption":"","itemPrice":983,"itemUrl":"https://books.rakuten.co.jp/rb/4044773/?rafcid=wsc_b_ds_1073712914928508395","jan":"4988135577450","label":"ワーナーエンターテイメントジャパン(株)","largeImageUrl":"https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/7450/4988135577450.jpg?_ex=200x200","limitedFlag":0,"listPrice":0,"makerCode":"NFPA-74953","mediumImageUrl":"https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/7450/4988135577450.jpg?_ex=120x120","postageFlag":2,"reviewAverage":"5.0","reviewCount":1,"salesDate":"2006年07月21日","smallImageUrl":"https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/7450/4988135577450.jpg?_ex=64x64","title":"ハリー・ポッターと秘密の部屋【UMD】","titleKana":"ハリーポッタートヒミツノヘヤ"}}...
```

無事に取れているようです。

# Flutter アプリで利用

## 依存関係の追加

以下を実行します。

```bash
flutter pub add http
```

## HTTPリクエストの許可 (macOS)

macOS でネットワークリクエストを許可するための設定をします。

https://docs.flutter.dev/platform-integration/macos/building#setting-up-entitlements

上記ドキュメントに従い、
`./macos/Runner/DebugProfile.entitlements` に以下を追加。

```diff_xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.network.server</key>
	<true/>
+   <key>com.apple.security.network.client</key>
+   <true/>
</dict>
</plist>
```

## 楽天APIサービスの追加

楽天APIにリクエストするためのサービスを作成します。
`apiKey` には最初に取得したアプリケーションIDを入力してください。

:::note
本対応は暫定的なもので、本来はキー情報をソースに入れるべきではありません
:::

```dart
// rakuten_api.dart

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

class RakutenAPI {
  static const String apiKey = '[API Key]'; // 取得したAPIキーをここに入れる
  static const String endpoint =
      'https://app.rakuten.co.jp/services/api/BooksDVD/Search/20170404';

  static Future<List<dynamic>> searchItems(String query) async {
    // URLエンコード
    final url = Uri.parse(
        '$endpoint?format=json&formatVersion=2&title=$query&applicationId=$apiKey&sort=-releaseDate');

    final response = await http.get(url);
    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      return data['Items'] ?? [];
    } else {
      throw Exception('Failed to load data from Rakuten API');
    }
  }
}
```

タイトルで検索し、リリース日降順で取得するようにしています。
パラメータの詳細、その他指定可能なパラメータは以下を参照してください。
 
https://webservice.rakuten.co.jp/documentation/books-dvd-search

## 検索用のページを作成

検索ボックスからタイトルで検索して、結果リストを表示する単純なページを作成します。

```dart
// ./main.dart
// ...

import 'package:media_manager_app/services/rakuten_api.dart';

// ...

class SearchPage extends StatefulWidget {
  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  // 検索入力フィールドのコントローラー
  final _searchController = TextEditingController();

  List<dynamic> _searchResults = [];

  bool _isLoading = false;

  /// 楽天APIを使って検索を実行する関数.
  /// 検索中は _isLoading に true を設定する
  /// 検索結果を _searchResults に格納する
  void _performSearch() async {
    // 検索開始時にローディング状態をtrueに設定
    setState(() {
      _isLoading = true;
    });

    try {
      // 楽天APIを呼び出して検索結果を取得
      final results = await RakutenAPI.searchItems(_searchController.text);
      // 検索結果を状態に反映
      setState(() {
        _searchResults = results;
      });
    } catch (e) {
      // エラーが発生した場合にエラーメッセージを出力
      print('Error: $e');
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Search DVDs/Blu-rays'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 検索入力フィールド
            TextField(
              controller: _searchController,
              decoration: InputDecoration(
                labelText: 'Search',
                suffixIcon: IconButton(
                  icon: Icon(Icons.search),
                  onPressed: _performSearch,
                ),
              ),
            ),
            // ローディング中はプログレスインジケーターを表示
            if (_isLoading)
              Center(child: CircularProgressIndicator())
            else
              // ローディング中以外は検索結果を表示
              Expanded(
                child: ListView.builder(
                  itemCount: _searchResults.length,
                  itemBuilder: (context, index) {
                    final item = _searchResults[index];
                    return ListTile(
                      title: Text(item['title']), //< 作品タイトル
                      subtitle: Text(item['salesDate']), //< 販売日
                      leading: Image.network(item['mediumImageUrl']), //< 商品画像
                    );
                  },
                ),
              ),
          ],
        ),
      ),
    );
  }
}
```


## 検索ページの組み込み

https://qiita.com/grhg/items/936a63c35146eb6fbb49

で作ったアプリに新規ページを追加してみます。

```diff_dart
class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = MyMediaPage();
        break;
+     case 2:
+       page = SearchPage();
+       break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }

    return LayoutBuilder(builder: (context, constraints) {
      return Scaffold(
        body: Row(
          children: [
            SafeArea(
              child: NavigationRail(
                extended: constraints.maxWidth >= 600,
                destinations: [
                  NavigationRailDestination(
                    icon: Icon(Icons.home),
                    label: Text('Home'),
                  ),
                  NavigationRailDestination(
                    icon: Icon(Icons.favorite),
                    label: Text('My Media'),
                  ),
+                 NavigationRailDestination(
+                   icon: Icon(Icons.search),
+                   label: Text('Search'),
+                 ),
                ],
                selectedIndex: selectedIndex,
                onDestinationSelected: (value) {
                  setState(() {
                    selectedIndex = value;
                  });
                },
              ),
            ),
            Expanded(
              child: Container(
                color: Theme.of(context).colorScheme.primaryContainer,
                child: page,
              ),
            ),
          ],
        ),
      );
    });
  }
}
```

## 完成！

できました！　以下は完成イメージです。

![test4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/8fb10a3a-2c72-67dd-2a28-e2bb634cf167.gif)

詳細な変更箇所は [PR](https://github.com/tgrhg/media_manager_app/pull/1/files) を参照してください。

# 終わりに

商品検索ページを作ってみました。
まだ、検索するだけで登録も何もできないので、ブラッシュアップしていきたいと思います。