Next.jsでLINE Messaging APIのバリデーターを作ってChrome拡張で使う

以下の記事でLINE Messging APIの検証APIを試しました。

https://qiita.com/tokkun5552/items/184b8845b29fbc669d30

弊社の開発メンバー以外がこれを気軽に使えるように、Chrome拡張機能としてNext.jsで実装したので紹介しようと思います。

## 使い方
Chromeウェブストアのページから、Chrome拡張機能を有効化してください。

https://chrome.google.com/webstore/detail/line-message-validator/iefgnoepjbddkpjagicbefmackfjjidk/related?hl=ja&authuser=0

channel accese tokenとmessagesを入れて`validate`ボタンを押すとresultに結果が表示されます :tada: 
![スクリーンショット 2022-12-23 17.39.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/fa9e4d62-b078-8f2a-b2a6-c0390bf2d8ca.png)

- コードからビルドする方法
リポジトリは以下で公開しています。

https://github.com/tokku5552/line-message-validator

使い方は[こちら](https://zenn.dev/tokku5552/articles/how-to-make-chrome-extension#%E4%BD%BF%E3%81%84%E6%96%B9(%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%83%93%E3%83%AB%E3%83%89))を参考に以下のコマンドでビルドした後、`extensions`フォルダを読み込んでください。
```bash:
yarn install
yarn export
```

## Chrome拡張機能の作り方
以前、Reactで拡張機能を作ったことがありました。

https://zenn.dev/tokku5552/articles/how-to-make-chrome-extension

今回はNext.jsで作ってみました。まずはプロジェクトを作成します
```
yarn create next-app
```

色々[記事](https://qiita.com/yushimatenjin/items/8f476dd018550922f1e9)を見ていると、`extensions`というフォルダに一式吐き出す設定にしている方が多そうだったので、それに倣います。

`package.json`に以下のスクリプトを追加します。
```json:
"export": "next build && next export -o extensions/dist"
```

`next.config.js`に以下の設定を追加します。
```ts:
swcMinify: true,
trailingSlash: true,
assetPrefix: "./",
```

`manifest.json`は`extensions`フォルダの下に作っておきます。
```json:extensions/manifest.json
{
  "manifest_version": 3,
  "name": "LINE Message Validator",
  "description": "LINE Message Validator",
  "version": "1.0",
  "action": {
    "default_icon": "./dist/icon.png",
    "default_popup": "./dist/index.html"
  },
  "permissions": ["storage"],
  "host_permissions": ["https://api.line.me/v2/bot/message/validate/*"]
}
```

今回は設定値を保存しておくために`permissioins`に`storage`を追加しています。

## メインページのコード
機能としては単純で、チャネルアクセストークンとメッセージオブジェクトを入れて`validate`ボタンを押すと、検証結果が返ってきます。
ほとんど以前Reactで作った拡張機能からコードを拝借しているので、違う部分だけコメントで解説します。
- [Reactでサクッとchromeの拡張機能をつくる](https://zenn.dev/tokku5552/articles/how-to-make-chrome-extension#react%E3%81%A7%E4%BD%9C%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E9%83%A8%E5%88%86)

```tsx:pages/index.tsx
/* eslint-disable react-hooks/exhaustive-deps */
import {
  Box,
  Button,
  FormControl,
  FormErrorMessage,
  FormLabel,
  Heading,
  Input,
  Textarea,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { validator } from "../api/validator";
import { FormData } from "../types";

const placeholder = `
{
  "messages": [
    {
      "type": "text",
      "text": "Hello, world"
    }
  ]
}
`.trim();

export default function Home() {
  const [result, setResult] = useState("");
  const {
    register,
    setValue,
    handleSubmit,
    formState,
    formState: { errors },
  } = useForm<FormData>();

  const onSubmit = handleSubmit(async (data) => {
    // 拡張機能で使えるストレージに受け取ったデータを保存しておく
    await chrome.storage.local.set(data);
    // api/validator.tsに実装してあるvalidatorを呼んで、検証用エンドポイントにアクセスする。
    await validator(
      data.token,
      JSON.parse(data.body),
      () => setResult("Success!"),
      (validateError) => setResult(JSON.stringify(validateError, null, 2)),
      (_) => setResult("unknown error")
    );
  });

  useEffect(() => {
    // 初期化時にまずストレージを確認し、値があれば初期値として設定する。
    chrome.storage.local.get(["token", "body"], (items) => {
      const data = items as FormData;
      const body = data && data.body ? data.body : placeholder;
      setValue("token", data.token);
      setValue("body", body);
    });
  }, []);

  return (
    <>
      <Box w="540px">
        <Box bg="#4299E1" w="100%" p={4} color="white">
          <Heading as="h3" size="xl" isTruncated>
            LINE Message Validator
          </Heading>
        </Box>
        <form onSubmit={onSubmit}>
          <FormControl isInvalid={!!errors.body || !!errors.token} isRequired>
            <FormLabel>channel access token</FormLabel>
            <Input
              placeholder="CHANNEL ACCESS TOKEN"
              {...register("token", { required: true })}
            />
            <FormErrorMessage>
              {!!errors.token && "channel access token is required"}
            </FormErrorMessage>
            <FormLabel>messages(JSON)</FormLabel>
            <Textarea
              placeholder={placeholder}
              h="200"
              {...register("body", { required: true })}
            />
            <FormErrorMessage>
              {!!errors.body && "messages(JSON) is required"}
            </FormErrorMessage>
          </FormControl>
          <FormLabel>result</FormLabel>
          <Textarea value={result} h="100" />
          <Button
            mt={2}
            bg="#4299E1"
            color="white"
            isLoading={formState.isSubmitting}
            type="submit"
          >
            validate
          </Button>
        </form>
      </Box>
    </>
  );
}
```

`validator`はこちらの記事で書いたものを少し改良しています。
```ts:api/validator.ts
import axios, { AxiosResponse } from "axios";
import { ValidateError } from "../types";

// 成功時、失敗時の動作はsuccess,invalid,errorのコールバックで外から制御できるようにしている。
export const validator = async (
  token: string,
  body: string,
  success: (response: AxiosResponse) => void,
  invalid: (validateError: ValidateError) => void,
  error: (reason: any) => void
) => {
  console.log(`validator token=${token},body=${JSON.stringify(body)}`);
  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  };
  await axios
    .post("https://api.line.me/v2/bot/message/validate/reply", body, {
      headers: headers,
    })
    .then((response) => {
      success(response);
    })
    .catch((reason) => {
      console.log(reason);
      if (!!reason && !!reason.response && !!reason.response.data) {
        const validateError: ValidateError = reason.response.data;
        invalid(validateError);
      } else {
        error(reason);
      }
    });
};
```

## まとめ
今まで書いた記事の内容を組み合わせて一つものを作るのはなんだか面白かったです。
これ結構便利だと思うので、みなさんもぜひ使ってください :bow: