【個人開発】絵本を検索して図書館で「貸出可能」か確認できるサービスを作ってみた

この記事はadvent calendar 2022 15日目のものです。

こんにちは。ザキと申します。~~プログラミングスクールの「RUNTEQ」でRuby on Railsを中心に学習しています！~~
→RUNTEQ卒業後、2023年2月よりWEBエンジニアとして働いています。

この記事では、私が作成した [図書館情報を表示する絵本検索サービス「Kariteyomu」](https://www.kariteyomu.net) と、その使用技術について紹介させてください。

https://www.kariteyomu.net/

# 開発の背景
私には小さな子供がおり、頻繁に図書館で絵本を借りて読み聞かせをしています。  
読み聞かせをすることで、子供と一緒に笑ったり、興味があることを追求したり、親子のコミューケーションもとっています。  

一方で子供の興味が年齢やタイミングで移り変わることもあり、最適な絵本を探すことには苦労しています。

**特に図書館で絵本を借りるのは難しい。なんでこんなにハードモードなんだ！！と心の中で叫んだのは私だけではないはずです！**  


**サクッと絵本を検索できて、図書館の貸出情報を表示できるサービスがあれば楽になりそうだ！** 

**・・・作れないかな！？**
**・・・3つAPIを使用すればできそうだから作ってしまおう！**

とういうわけで作りました。

https://www.kariteyomu.net

https://github.com/gentaroooo/kariteyomu

# 主な機能

## ログイン

|ゲストログインまたは<br>アカウントの作成|メールアドレスまたは<br>Twitterでログイン|
|---|---|
|<img src="https://gyazo.com/86841a35562c21b13d625b76f680207f.png" width="200px" height="370px">|<img src="https://gyazo.com/ea997d93f6b238618e7163265bb93962.png" width="200px" height="370px">
|サクッと絵本を検索したりできるよう<br>ゲストログイン機能を導入しています|登録したメールアドレスまたは<br>Twitterでログインどちらでもログインできます|

## 図書館エリアを登録する

|現在地から登録|住所または郵便番号から登録する|
|---|---|
|<img src="https://gyazo.com/93a6a4a9b7629725f9427f924a1a815f.gif" width="200px" height="370px">|<img src="https://gyazo.com/eb26bda6823acf1265202af9e7ff429b.gif" width="200px" height="370px">
|現在地付近の図書館エリアを表示して登録|住所または郵便番号を入力して登録|

 ## 絵本を検索してよみたいに追加する

 |絵本検索|よみたい|よみたい　詳細画面|
|---|---|---|
|<img src="https://gyazo.com/d4b7de0b7bb69895727b7acef3674057.gif" width="200px" height="370px">|<img src="https://gyazo.com/859127328ffe83e67b14cf9fb5094d64.gif" width="200px" height="370px">|<img src="https://gyazo.com/e852a3f73303576efa571ed4b170ef7b.gif" width="200px" height="370px">
|Google Books APIsにて絵本の検索を行います|よみたいリストに追加ボタンを押します| 図書館の貸出状況が表示されます（予約ボタンにて予約もできます）|

 ## 絵本を検索してレビューをする

 |絵本検索|レビューをする|レビュー　詳細画面|
|---|---|---|
|<img src="https://gyazo.com/d4b7de0b7bb69895727b7acef3674057.gif" width="200px" height="370px">|<img src="https://gyazo.com/2c7b6a916a9ea96f3c1a6de84317314a.gif" width="200px" height="370px">|<img src="https://gyazo.com/f9145373b32409da6ee347d5014b2080.gif" width="200px" height="370px">
|Google Books APIにて絵本の検索を行います|レビューをするボタンを押してレビューを入力します| レビュー詳細画面からも貸出情報が確認でき、レビューに対してコメントできます|

 ## 管理者機能
管理者のみユーザーや投稿の情報を編集可能にしています。
 |ユーザー情報編集|レビュー・読みたいリストの編集|
|---|---|
|<img src="https://gyazo.com/4a7debc68471e82069f8499263b22864.png" width="600px" height="500px">|<img src="https://gyazo.com/621e98f31072106257e4d430ecb21b66.png" width="600px" height="500px">| 

# 使用技術
 - Ruby 3.1.2・Rails6.1.7・JavaScript・jQuery・RSpec
## 使用したAPI
 - Google Books APIs(絵本検索）
 - 図書館API・カーリル（図書館の貸出情報、図書館検索）
 - GeolocationAPI（現在地取得） 
 - TwitterAPI(ログイン認証）
## インフラ
 - Heroku・AWS(S3)
## テーブル設計・ER図
![Image from Gyazo](https://gyazo.com/268d4de456d2c65564e8efece8f191d3.png)

# 工夫した点
## ①ゲストログイン、Twitterログイン機能の導入
個人サービスでもあり、ポートフォリオでもあるので採用担当の方の手間を減らせるよう、ゲストログイン機能を導入しました。
また、一般ユーザーも気軽にログインできるようTwitterログイン機能も合わせて導入しました。Twitter Develpersの申請が思ったより手間取ったので、次に実装する方に力添えできればと思い、以下記事に実装方法を記載しました。

https://qiita.com/zakirun/items/da012e6d8f7282c3c912

## ②Google Books APIsを使用した絵本検索
ユーザーの入力事項を減らすために、絵本の検索ではGoogle Books APIsを使用しました。
JSONを取得した後に、アプリで使用したい形式となるようにデータを加工する必要があり、試行錯誤しながら実装しました。実装方法の詳細は以下記事にも書いています。

https://qiita.com/zakirun/items/1ac6dc5e55b18becc650

## ③図書館の貸出情報表示
エリアを登録するとエリア内の図書館で貸出可能か、レビュー詳細とよみたい詳細に表示されるようにしました。

図書館情報の取得は[図書館API（カーリル）](https://calil.jp/doc/api.html)を使わせてもらい、実際にリクエストをしながら確認する以下仕様でした（API仕様書にも記載があります）
- **貸出情報の取得には10〜20秒ほどかかる場合がある**
- **data.continueの値が1だとJSONを取得できないので、data.continueの値が0になるまでリトライする必要がある**

上記の仕様をユーザーにストレスなく表示するため、JavaScriptのAJax通信を使用してフロント側で表示する方針としました。
・・・とはいえ「**JavaScriptのAJax通信ナニモワカラナイ**」状態からネット記事を見て実装しようとすると、更に分からなくなり「**・・・ワタシダレ**」状態となったので、「**この際ここでしっかり理解しよう！**」と思い立ち、以下書籍にてキャッチアップをしながら実装を行いました。結果的にJavaScript自体の理解が深まり良い経験になったと思っています。

| 1 |[確かな力が身につくJavaScript「超」入門](https://www.amazon.co.jp/%E7%A2%BA%E3%81%8B%E3%81%AA%E5%8A%9B%E3%81%8C%E8%BA%AB%E3%81%AB%E3%81%A4%E3%81%8FJavaScript%E3%80%8C%E8%B6%85%E3%80%8D%E5%85%A5%E9%96%80-%E7%AC%AC2%E7%89%88-%E7%8B%A9%E9%87%8E-%E7%A5%90%E6%9D%B1/dp/4815601577)|
| -- | -- |
| **2** |[**独習JavaScript**](https://www.amazon.co.jp/%E7%8B%AC%E7%BF%92JavaScript-%E6%96%B0%E7%89%88-CodeMafia-%E5%A4%96%E6%9D%91-%E5%B0%86%E5%A4%A7/dp/479816027X)|

冗長なコードになっている部分があるのでリファクタリングしたい箇所NO1ですが、現時点では最終的に以下コードになっています。

<details><summary>コード</summary>

```ruby:app/javascript/packs/calil.js
function promiseFactory(count) {
  return new Promise((resolve, reject) => {
    timer_id = setTimeout(() => {
      count++;
      //本の貸出情報をGETリクエストする
      $.ajax({
        type: 'GET',
        url: 'https://api.calil.jp/check',
        data:{
          appkey: gon.calil_key,
          isbn: Number(gon.book.systemid),
          systemid: gon.library,
          callback: 'no',
        },
        dataType: 'json',
      })

      // 成功した場合dataにJSONを格納
      .done(function(data){
        // JSONから図書館、貸出情報を取得してsituationに代入
        const situation = data.books[Number(gon.book.systemid)][gon.library].libkey
        // JSONから予約情報を取得してreserveurlに代入
        const reserveurl = data.books[Number(gon.book.systemid)][gon.library].reserveurl
        // JSONからリクエスト結果を取得してstatusに代入
        const status = data.books[Number(gon.book.systemid)][gon.library].status
        // data.continueが0だった場合
        if (data.continue === 0) {
          if (status === 'Error') {
              $("#search").remove();
              $("#choice").prepend(`<div>
              <button type="button" class="btn btn-dark">図書館から応答がありません<p>時間を空けてお試しください</button>
              </div>`)
              clearTimeout( timer_id );
          } else {
            if (Object.keys(situation).length === 0 && situation.constructor === Object) {
                $("#search").remove();
                $("#choice").prepend(`<h4>
                <button type="button" class="badge bg-secondary">図書館に本がありません</button>
                </h4>`)
                clearTimeout( timer_id );
            } else {
              // situationからひとつずつ値を取り出してvalueに代入
              Object.keys(situation).forEach( function(value) {
                //searchに値があれば削除からひとつずつ値を取り出してvalueに代入
                $("#search").remove();
                //choiceにvalue（図書館名）、this[value](貸出情報)を出力
                if (this[value] === "貸出可") {
                  $("#choice").prepend(`<h4>
                  <button type="button" class="badge bg-outline text-info">${value} : ${this[value]}</button>
                  </h4>`)
                } else {

                $("#choice").prepend(`<h4>
                <button type="button" class="badge bg-outline">${value} : ${this[value]}</button>
                </h4>`)
                }
              }, situation)
              $("#choice2").prepend(`<h4>
                <button type="button" class="badge"><a href="${reserveurl}">予約する</a></button>
                </h4>`)
              //data.continueが0だった場合にループ（setTimeout）を抜ける
              clearTimeout( timer_id );
              }
          }
        //data.continueが1だった場合は10回リトライをする 
        } else {
          // 10回目のコールでエラー
          if (count === 10 ) {
          reject(count);
          } else {
          resolve(count);
          }
        }
      })
      //https://api.calil.jp/checkへのGETに失敗した場合
      .fail(function(data){
        $("#jsonp").append("エラーです");
      });
    }, 2000);
  });
}

async function execute() {
  try {
      let count = await promiseFactory(0);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
      count = await promiseFactory(count);
  } catch (errorCount) {
    console.error(`エラーに飛びました。現在のカウントは ${errorCount} です。`);
    $("#search").remove();
    $("#choice").prepend(`<h4><button type="button" class="badge bg-secondary">図書館情報の表示に失敗しました</button></h4>`)
  } finally {
    $("#search").remove();
    $("#choice").prepend(`<h4><button type="button" class="badge bg-secondary">図書館情報の表示に失敗しました</button></h4>`)
      console.log("処理を終了します。");
  }
}
execute();
```
</details>

- Promise(async/await)を使って、data.continue=0の値になるまで2秒ごとに10回までリトライすることで
貸出情報を表示させています。
- gon.〇〇の変数は[gemのgon](https://github.com/gazay/gon)を使用してrails側のコントローラーから変数を渡しています。
```ruby:app/controllers/books_controller.rb
def show
    @book = Book.find(params[:id])
    gon.book = @book
    @library = current_user.library
    if @library.present?
      gon.library = @library.name
    end
end
```
- data.continue=0となった場合は値を出力後にclearTimeout( timer_id );にて処理を抜けています。

## 参考にさせていただいた記事・書籍
RSpecの実装では[伊藤　淳一さん](https://qiita.com/jnchito)の記事がとても勉強になりました。
いつも有益な情報を下さり、ありがとうございます！

https://qiita.com/jnchito/items/607f956263c38a5fec24

Qiita記事のみでもかなり有益な情報があるのですが、書籍[Everyday Rails - RSpecによるRailsテスト入門](https://leanpub.com/everydayrailsrspec-jp)も購入しキャッチアップしながら実装しました。
書籍ではコントローラーのspecやAPIのspecなど、独学だと厳しい部分も解説があるのと、なぜテストコードが必要か？について伊藤さんの思いが書かれている有益な本です。伊藤さんのQiita記事に助かっている人は多いと思うのでリスペクトも含めて購入することをおすすめします。

https://leanpub.com/everydayrailsrspec-jp

書籍も参考にしながらRSpecを実装し、[SimpleCov](https://github.com/simplecov-ruby/simplecov)でガバレッジ率を計測すると96.7%になりました。（テスト件数134件）。ガバレッジ率が全てではないと思いますし、一部実現できていないテストコードもあるのですが、概ねのテストは実装できたかと思います。今後も安心して、挑戦的な開発ができるようにRSpecの充実を進めていきたいと思います。

<img src="https://gyazo.com/92a5ffb7faac712ce904d61ceebb5db2.png">

## 最後に
長文にも関わらず最後までご覧いただきありがとうございました！
このサービスやコードを通じて、絵本選びが少し楽になったり、初学者の方の学習の何かの力になれればとても嬉しいです！