【Rails】郵便番号による住所検索機能を作る ※ gem 無し

## 概要

巷でよく見かける「郵便番号を入力すると該当の住所が番地以前まで自動で入力される」といった機能を Rails で作ってみました。

こういった記事はすでに世の中に溢れていますが、JQurey やら gem やらを使ったものが多く、個人的にはもっとシンプルな実装にしたかったため今回備忘録として残します。

## 下準備

※ Rails の環境構築はすでに済んでいるものとして話を進めます。

- Address モデルの作成
- Prefecture モデルの作成

住所に関する各種情報を持たせるための Address モデル、都道府県用のマスターデータとして Prefecture モデルを作成します。

|  Address  |
| ---- |
|  postal_code: 郵便番号  |
|  prefecture_id: 都道府県ID  |
|  city: 市区町村  |
|  house_number: 番地  |
|  building: 建物名・部屋番号  |

```
$ rails g model Address postal_code:integer prefecture_id:integer city:string house_number:string building:string
```

```ruby:./app/models/address.rb
class Address < ApplicationRecord
  belongs_to :prefecture
end
```

|  Prefecture  |
| ---- |
|  name: 名前  |

```
$ rails g model Prefecture name:string
```

```ruby:./app/models/address.rb
class Prefecture < ApplicationRecord
  has_many :addresses
end
```

マイグレーション。

```
$ rails db:migrate
```

都道府県データを作成。

```ruby
$ rails c

prefectures = [
  { id: 1, name: "北海道", created_at: Time.current, updated_at: Time.current },
  { id: 2, name: "青森県", created_at: Time.current, updated_at: Time.current },
  { id: 3, name: "岩手県", created_at: Time.current, updated_at: Time.current },
  { id: 4, name: "宮城県", created_at: Time.current, updated_at: Time.current },
  { id: 5, name: "秋田県", created_at: Time.current, updated_at: Time.current },
  { id: 6, name: "山形県", created_at: Time.current, updated_at: Time.current },
  { id: 7, name: "福島県", created_at: Time.current, updated_at: Time.current },
  { id: 8, name: "茨城県", created_at: Time.current, updated_at: Time.current },
  { id: 9, name: "栃木県", created_at: Time.current, updated_at: Time.current },
  { id: 10, name: "群馬県", created_at: Time.current, updated_at: Time.current },
  { id: 11, name: "埼玉県", created_at: Time.current, updated_at: Time.current },
  { id: 12, name: "千葉県", created_at: Time.current, updated_at: Time.current },
  { id: 13, name: "東京都", created_at: Time.current, updated_at: Time.current },
  { id: 14, name: "神奈川県", created_at: Time.current, updated_at: Time.current },
  { id: 15, name: "新潟県", created_at: Time.current, updated_at: Time.current },
  { id: 16, name: "富山県", created_at: Time.current, updated_at: Time.current },
  { id: 17, name: "石川県", created_at: Time.current, updated_at: Time.current },
  { id: 18, name: "福井県", created_at: Time.current, updated_at: Time.current },
  { id: 19, name: "山梨県", created_at: Time.current, updated_at: Time.current },
  { id: 20, name: "長野県", created_at: Time.current, updated_at: Time.current },
  { id: 21, name: "岐阜県", created_at: Time.current, updated_at: Time.current },
  { id: 22, name: "静岡県", created_at: Time.current, updated_at: Time.current },
  { id: 23, name: "愛知県", created_at: Time.current, updated_at: Time.current },
  { id: 24, name: "三重県", created_at: Time.current, updated_at: Time.current },
  { id: 25, name: "滋賀県", created_at: Time.current, updated_at: Time.current },
  { id: 26, name: "京都府", created_at: Time.current, updated_at: Time.current },
  { id: 27, name: "大阪府", created_at: Time.current, updated_at: Time.current },
  { id: 28, name: "兵庫県", created_at: Time.current, updated_at: Time.current },
  { id: 29, name: "奈良県", created_at: Time.current, updated_at: Time.current },
  { id: 30, name: "和歌山県", created_at: Time.current, updated_at: Time.current },
  { id: 31, name: "鳥取県", created_at: Time.current, updated_at: Time.current },
  { id: 32, name: "島根県", created_at: Time.current, updated_at: Time.current },
  { id: 33, name: "岡山県", created_at: Time.current, updated_at: Time.current },
  { id: 34, name: "広島県", created_at: Time.current, updated_at: Time.current },
  { id: 35, name: "山口県", created_at: Time.current, updated_at: Time.current },
  { id: 36, name: "徳島県", created_at: Time.current, updated_at: Time.current },
  { id: 37, name: "香川県", created_at: Time.current, updated_at: Time.current },
  { id: 38, name: "愛媛県", created_at: Time.current, updated_at: Time.current },
  { id: 39, name: "高知県", created_at: Time.current, updated_at: Time.current },
  { id: 40, name: "福岡県", created_at: Time.current, updated_at: Time.current },
  { id: 41, name: "佐賀県", created_at: Time.current, updated_at: Time.current },
  { id: 42, name: "長崎県", created_at: Time.current, updated_at: Time.current },
  { id: 43, name: "熊本県", created_at: Time.current, updated_at: Time.current },
  { id: 44, name: "大分県", created_at: Time.current, updated_at: Time.current },
  { id: 45, name: "宮崎県", created_at: Time.current, updated_at: Time.current },
  { id: 46, name: "鹿児島県", created_at: Time.current, updated_at: Time.current },
  { id: 47, name: "沖縄県", created_at: Time.current, updated_at: Time.current }
]

Prefecture.insert_all(prefectures)
```


## フォームの作成

適当にフォームを作成します。

```
$ rails g controller addresses new create
```

```ruby:./config/routes.rb
Rails.application.routes.draw do
  resources :addresses, only: %i[new create]
end
```

```ruby:./app/controllers/addresses_controller.rb
class AddressesController < ApplicationController
  def new
    @address = Address.new
  end

  def create
    Address.create!(address_params)
  end

  private

  def address_params
    params.require(:address).permit(
      :prefecture_code,
      :prefecture_id,
      :city,
      :house_number,
      :building
    )
  end
end
```

```erb:./app/views/addresses/new.html.erb
<div style="padding: 3rem;">
  <%= form_with url: @address, local: true do |f| %>
    <table>
      <tr>
        <td>
          <%= f.label "郵便番号" %>      
          <span style="color: red;">*</span>
        </td>
        <td>
          <%= f.text_field :postal_code, required: true, id: "postal_code_field" %>
          <button type="button" id="search_address_button">住所検索</button>
          <small style="color: red; display: none;" id="search_address_error_message">
            ※ 郵便番号に誤りがあります
          </small>
        </td>
      </tr>
      <tr>
        <td>
          <%= f.label "都道府県" %>
          <span style="color: red;">*</span>
        </td>
        <td>
          <%= f.collection_select(:prefecture_id, Prefecture.all, :id, :name, { include_blank: true, required: true }, { class:"form-select", id: "prefecture_id_field" }) %>
        </td>
      </tr>
      <tr>
        <td>
          <%= f.label "市区町村" %>
          <span style="color: red;">*</span>
        </td>
        <td>
          <%= f.text_field :city, required: true, id: "city_field" %>
        </td>
      </tr>
      <tr>
        <td>
          <%= f.label "番地" %>
          <span style="color: red;">*</span>
        </td>
        <td>
          <%= f.text_field :house_number, required: true, id: "house_number_field" %>
        </td>
      </tr>
      <tr>
        <td>
          <%= f.label "建物名・部屋番号" %>
        </td>
        <td>
          <%= f.text_field :building %>
        </td>
      </tr>
    </table>
    <%= f.submit "送信" %>
  <% end %>
</div>

<script>
  // 住所検索ボタンを指定
  const searchAddressButton = document.getElementById("search_address_button");
  // ボタンのクリックをトリガーに searchAddress 関数を実行
  searchAddressButton.addEventListener("click", searchAddress);

  async function searchAddress() {
    const searchAddressErrorMessage = document.getElementById("search_address_error_message");
    const postalCodeField = document.getElementById("postal_code_field");
    const prefectureIdField = document.getElementById("prefecture_id_field");
    const cityField = document.getElementById("city_field");
    const housNumberField = document.getElementById("house_number_field");
    
    // zip cloud API にリクエスト
    const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${postalCodeField.value}`;
    const response = await fetch(url);
    const json = await response.json();

    if (json.status == 200 && json.results) {
      // 成功時の処理
      const result = json.results[0]

      prefectureIdField.value = result.prefcode;
      cityField.value = result.address2;
      housNumberField.value = result.address3;

      searchAddressErrorMessage.style.display = "none";
    } else {
      // 失敗時の処理   
      prefectureIdField.value = null;
      cityField.value = null;
      housNumberField.value = null;

      searchAddressErrorMessage.style.display = "block";
    }
  }
</script>
```

- 入力された郵便番号（postal_code）をもとに zip cloud API を叩く
- ステータス 200 かつ　results が存在する場合、その中身を都道府県ID（prefecture_id）、市区町村（city）、番地（house_number）に割り当てていく
- ステータスが200以外、もしくは results が空だった場合、エラーメッセージを表示

zip cloud API のレスポンスはこんな感じで返って来ます。

**成功（正しい郵便番号を入力）**
```json:https://zipcloud.ibsnet.co.jp/api/search?zipcode=1710022
{
	"message": null,
	"results": [
		{
			"address1": "東京都",
			"address2": "豊島区",
			"address3": "南池袋",
			"kana1": "ﾄｳｷｮｳﾄ",
			"kana2": "ﾄｼﾏｸ",
			"kana3": "ﾐﾅﾐｲｹﾌﾞｸﾛ",
			"prefcode": "13",
			"zipcode": "1710022"
		}
	],
	"status": 200
}
```

**失敗（不正な郵便番号を入力）**

```json:https://zipcloud.ibsnet.co.jp/api/search?zipcode=0000000
{
	"message": null,
	"results": null,
	"status": 200
}
```

[http://localhost:3000/addresses/new](http://localhost:3000/addresses/new) にアクセスして次のような動きになっていれば完成です。

![画面収録-2023-02-09-23.46.53.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/6b676ca4-821d-adeb-077f-a54ecb988422.gif)