8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RCC (立命館コンピュータークラブ)Advent Calendar 2024

Day 23

郵便番号APIで遊んでみた!誰でも作れる郵便番号から住所を出力できるformの作成方法!!

Posted at

はじめに

みなさん!Amazonやフリマアプリなどで住所登録するときに郵便番号を入力するだけで、都道府県から市区町村まで自動的に入力されるのを見たことがありますよね?実は、郵便番号には対応する住所情報が紐づいていて、それを参照して自動入力が行われているんです。

でも、そんな大量のデータを自分で用意するのは大変ですよね。そんなときに便利なのがWeb APIというものです。Web APIとは、誰かが整理したデータを公開して、「自由に使っていいよ」と提供しているサービスのことです。

今回は、その中でもZipCloudさんが提供しているWeb APIを使わせてもらいました。このAPIを使えば、郵便番号から住所情報を簡単に取得できるんです。

郵便APIについてまとめてくれている記事があったので,載せておきます。

OpenAPIにはそれぞれ利用規約があるので,確認して使いましょう

使用技術

  • Next.js(TypeScript)
    • pnpm(パッケージマネージャー)
    • shadcn/ui(uiライブラリ)
    • zod(バリデーション)
    • axios(API接続)
  • GitHub(バージョン管理)

実行

詳しい使い方などは,zipCloudのページに記載されているので,今回は数字を打ち込みボタンをおすと,住所がでるというものを作ります!

導入

Nextの立ち上げ

作成したリポジトリをローカルにクローンし、そのディレクトリに移動します。そして、以下のコマンドを実行します。

npx create-next-app@latest .

今回は現在のディレクトリ(リポジトリのルート)にNextを作成したいため,アプリ名を.としています。

本来は以下のようなふうにアプリ名を記入すると
アプリ名のディレクトリを作成してその中にNextを作成してくれる。

npx create-next-app@latest <アプリ名>

コマンド実行後、いくつかの質問が表示されます。
以下のように回答してください:

  1. TypeScriptを使用するか? → Yes
  2. ESLintを使用するか? → Yes
  3. Tailwind CSSを使用するか? → Yes
  4. src/ ディレクトリを使用するか? → Yes
  5. App Routerを使用するか? → Yes
  6. next dev のために Turbopack を使用しますか? → Yes
  7. カスタムインポートエイリアスを追加するか? → No
    Yes: 好みのエイリアスにカスタマイズします。
    No: デフォルトの @/* を使用します。

各種ライブラリのインストール

  • shadcn/ui (UIライブラリ)

    pnpm dlx shadcn@latest init
    

    コマンド実行後、いくつかの質問が表示されます。自分好みに選んでください。

    例)

    • スタイル: Default
    • ベースカラー: Gray
    • テーマ用のCSS変数: Yes

    ./src/lib/utils.ts./components.jsonが追加されていたら、インストール成功です!

    今回使うコンポーネント

    pnpm dlx shadcn@latest add button card alert input-otp label form select input
    

    コマンドを実行すると、src/components/uiにインストールしたコンポーネントが追加されています。

  • zod (バリデーションライブラリ)

    pnpm add zod @hookform/resolvers react-hook-form
    
  • axios (API接続ライブラリ)

    pnpm add axios
    

準備

各コンポーネントを格納するディレクトリを作成します。今回はfeaturesディレクトリで作成します。

参考記事

~/srcにfeaturesディレクトリを作成
さまざまな機能を持つWebアプリを作る際、機能ごとにディレクトリを作ると便利です。
~/src/featuresにaddressディレクトリを作成
ここに今回の住所取得機能を実装していきます。
~/src/features/addressにcomponents、api、typesディレクトリを作成
componentsはコンポーネントを、apiはAPI接続のためのファイルを、typesは型定義ファイルを格納するためのディレクトリです。

結果的に以下のようなディレクトリ構造になっていればOKです。

tree -d srcの出力:

src
├── app
├── components
│   └── ui
├── features
│   └── address
│       ├── api
│       ├── components
│       └── types
└── lib

コードの実装

1. 型定義ファイルを作成する

まず、都道府県と住所検索のための型定義ファイルを作成します。

src/features/address/types/prefecture.ts:

import { z } from "zod";

export type PrefectureType = z.infer<typeof PrefectureSchema>;
export const PrefectureSchema = z.enum([
  "NA",
  "hokkaido",
  "aomori",
  "iwate",
  "miyagi",
  "akita",
  "yamagata",
  "fukushima",
  "ibaraki",
  "tochigi",
  "gunma",
  "saitama",
  "chiba",
  "tokyo",
  "kanagawa",
  "niigata",
  "toyama",
  "ishikawa",
  "fukui",
  "yamanashi",
  "nagano",
  "gifu",
  "shizuoka",
  "aichi",
  "mie",
  "shiga",
  "kyoto",
  "osaka",
  "hyogo",
  "nara",
  "wakayama",
  "tottori",
  "shimane",
  "okayama",
  "hiroshima",
  "yamaguchi",
  "tokushima",
  "kagawa",
  "ehime",
  "kochi",
  "fukuoka",
  "saga",
  "nagasaki",
  "kumamoto",
  "oita",
  "miyazaki",
  "kagoshima",
  "okinawa"
]);

export const prefectureLabelMap: Readonly<Record<PrefectureType, string>> = {
  NA: "未選択",
  hokkaido: "北海道",
  aomori: "青森県",
  iwate: "岩手県",
  miyagi: "宮城県",
  akita: "秋田県",
  yamagata: "山形県",
  fukushima: "福島県",
  ibaraki: "茨城県",
  tochigi: "栃木県",
  gunma: "群馬県",
  saitama: "埼玉県",
  chiba: "千葉県",
  tokyo: "東京都",
  kanagawa: "神奈川県",
  niigata: "新潟県",
  toyama: "富山県",
  ishikawa: "石川県",
  fukui: "福井県",
  yamanashi: "山梨県",
  nagano: "長野県",
  gifu: "岐阜県",
  shizuoka: "静岡県",
  aichi: "愛知県",
  mie: "三重県",
  shiga: "滋賀県",
  kyoto: "京都府",
  osaka: "大阪府",
  hyogo: "兵庫県",
  nara: "奈良県",
  wakayama: "和歌山県",
  tottori: "鳥取県",
  shimane: "島根県",
  okayama: "岡山県",
  hiroshima: "広島県",
  yamaguchi: "山口県",
  tokushima: "徳島県",
  kagawa: "香川県",
  ehime: "愛媛県",
  kochi: "高知県",
  fukuoka: "福岡県",
  saga: "佐賀県",
  nagasaki: "長崎県",
  kumamoto: "熊本県",
  oita: "大分県",
  miyazaki: "宮崎県",
  kagoshima: "鹿児島県",
  okinawa: "沖縄県"
};

src/features/address/types/search.ts:

import { PrefectureSchema } from "./prefecture";
import { z } from "zod";

export type AddressResultType = z.infer<typeof AddressResultSchema>;
export const AddressResultSchema = z.union([
  z.object({
    status: z.union([z.literal("idle"), z.literal("progress")]),
  }),
  z.object({
    status: z.literal("done"),
    prefecture: PrefectureSchema,
    city: z.string(),
  }),
]);

export const ZipCloudResponseSchema = z.object({
  message: z.string().nullable(),
  results: z
    .array(
      z.object({
        address1: z.string(),
        address2: z.string(),
        address3: z.string(),
      })
    )
    .nullable(),
  status: z.number(),
});

2. 住所検索のAPI接続を実装する

src/features/address/api/getAddress.ts:

import axios from "axios";
import { z } from "zod";
import { AddressResultType, ZipCloudResponseSchema } from "../types/search";
import { prefectureLabelMap, PrefectureSchema } from "../types/prefecture";

const zipCodeSchema = z.string().regex(/^\d{7}$/, "郵便番号は7桁の数字である必要があります");

export async function getAddress(
  zipCode: z.infer<typeof zipCodeSchema>
): Promise<AddressResultType> {
  try {
    zipCodeSchema.parse(zipCode);

    const response = await axios.get("https://zipcloud.ibsnet.co.jp/api/search", {
      params: { zipcode: zipCode },
    });

    const validatedResponse = ZipCloudResponseSchema.parse(response.data);

    if (validatedResponse.results && validatedResponse.results.length > 0) {
      const result = validatedResponse.results[0];
      const prefectureEntry = Object.entries(prefectureLabelMap).find(
        ([_, value]) => value === result.address1
      );

      if (!prefectureEntry) {
        return { status: "idle" };
      }

      const [prefectureCode] = prefectureEntry;
      return {
        status: "done",
        prefecture: PrefectureSchema.parse(prefectureCode),
        city: `${result.address2}${result.address3}`,
      };
    }

    return { status: "idle" };
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error("バリデーションエラー:", error.errors);
    } else {
      console.error("郵便番号検索でエラーが発生しました:", error);
    }
    return { status: "idle" };
  }
}

3. 住所フォームのコンポーネントを作成する

src/features/address/components/AddressForm.tsx:

"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue
} from "@/components/ui/select";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage
} from "@/components/ui/form";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot
} from "@/components/ui/input-otp";
import { getEntries } from "@/lib/map";
import { prefectureLabelMap, PrefectureSchema } from "../types/prefecture";
import { getAddress } from "../api/getAddress";
import { AddressResultType } from "../types/search";

const FormSchema = z.object({
  postal_code: z.string().length(7, "郵便番号は7桁で入力してください"),
  prefecture: PrefectureSchema,
  city: z.string(),
  address1: z.string(),
  address2: z.string()
});

export const AddressForm = () => {
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const form = useForm<z.infer<typeof FormSchema>>({
    resolver: zodResolver(FormSchema),
    defaultValues: {
      postal_code: "",
      prefecture: "NA",
      city: "",
      address1: "",
      address2: ""
    }
  });

  const handleSubmit = () => {
    //submit
  };

  const searchAddress = async (zipCode: string): Promise<AddressResultType> => {
    setIsLoading(true);
    try {
      const address = await getAddress(zipCode);
      if (address.status !== "done") {
        setIsLoading(false);
        return { status: "idle" };
      }
      return { ...address, status: "done" };
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Card className="w-[600px]">
      <CardHeader>
        <CardTitle>住所</CardTitle>
      </CardHeader>
      <CardContent>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(handleSubmit)}>
            <FormField
              control={form.control}
              name="postal_code"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>
                    郵便番号
                  </FormLabel>
                  <FormControl>
                    <InputOTP
                      maxLength={7}
                      value={field.value}
                      onChange={async value => {
                        field.onChange(value);
                        if (value.length === 7) {
                          const address = await searchAddress(value);
                          if (address.status !== "done") {
                            return;
                          }
                          form.setValue("prefecture", address.prefecture);
                          form.setValue("city", address.city);
                        }
                      }}
                    >
                      <InputOTPGroup>
                        <InputOTPSlot index={0} />
                        <InputOTPSlot index={1} />
                        <InputOTPSlot index={2} />
                      </InputOTPGroup>
                      <InputOTPSeparator>-</InputOTPSeparator>
                      <InputOTPGroup>
                        <InputOTPSlot index={3} />
                        <InputOTPSlot index={4} />
                        <InputOTPSlot index={5} />
                        <InputOTPSlot index={6} />
                      </InputOTPGroup>
                    </InputOTP>
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name="prefecture"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>
                    都道府県
                  </FormLabel>
                  <Select onValueChange={field.onChange} value={field.value}>
                    <FormControl>
                      <SelectTrigger className="w-[180px]">
                        <SelectValue />
                      </SelectTrigger>
                    </FormControl>
                    <SelectContent>
                      {getEntries(prefectureLabelMap).map(([value, label]) => (
                        <SelectItem key={value} value={value}>
                          {label}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="city"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>
                    市区町村
                  </FormLabel>
                  <FormControl>
                    <Input type="text" placeholder="OO市OO町" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="address1"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>丁目・番地・号(数字は半角数字)</FormLabel>
                  <FormControl>
                    <Input type="text" placeholder="1-2-3" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="address2"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>建物名/会社名・部屋番号</FormLabel>
                  <FormControl>
                    <Input type="text" placeholder="OOマンション101" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit" disabled={isLoading} className="mt-5">
              {isLoading ? "読み込み中..." : "保存"}
            </Button>
          </form>
        </Form>
      </CardContent>
    </Card>
  );
};

4. コンポーネントをページに配置する

src/app/page.tsx:

import { AddressForm } from "@/features/address/components/AddressForm";

export default function Home() {
  return (
    <main>
      <AddressForm />
    </main>
  );
}

5. サポート関数を追加する

src/lib/map.ts:

export function getEntries<T extends object>(obj: T): [keyof T, T[keyof T]][] {
  return Object.entries(obj) as [keyof T, T[keyof T]][];
}

結果

image.png

新宿区を調べた時
image.png

まとめ

今回は郵便番号から住所を出力するformを作成しました。
私たちが日常的に使っているwebアプリは色々ユーザーが使いやすいように考えられているので、それを参考に実際に作ってみるのは大変勉強になりました。
今後も実際に使ってみたいと思うデザインなどは模倣してみたいと思います

ディレクトリ構成のおさらい

src
├── app
│   └── page.tsx
├── components
│   └── ui
├── features
│   └── address
│       ├── api
│       │   └── getAddress.ts
│       ├── components
│       │   └── AddressForm.tsx
│       └── types
│           ├── prefecture.ts
│           └── search.ts
└── lib
    └── map.ts

参考

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?