GASでPOSTした時にCORSエラーが出てしまう時の対処法[Next.js, GAS]

あるプロジェクトでNext.jsとGASでWebアプリケーションを作っていたのですが、あるエラーに遭遇したのでその解決策を備忘録として残しておこうと思います。

# GASで出てしまったエラー
あるランキングの新規データ登録用の管理者画面を作ろうとして下のようなコードになっていました。
下のようなフォームを送信しようとしていました。
```tsx
export const apiClient = axios.create({
    baseURL: process.env.NEXT_PUBLIC_GAS_URL,
    headers:{
        'Content-Type': 'application/json',
    }
})

const AdminPage = () => {
    const form = useForm<z.infer<typeof formSchema>>({
        resolver: zodResolver(formSchema),
        defaultValues: {
            name: "",
            avatar: "panda",
            score: "0",
        },
    })

    const onSubmit = (data: z.infer<typeof formSchema>) =>{
        apiClient.post("", {
            name: data?.name,
            animal: data?.avatar,
            score: Number(data?.score),
        }).then((res)=>{
            console.log(res.data)
        }).catch((err)=>{
            console.error(err);
        })
    }
    return(
        <div >
        <div className="my-6 text-center text-green-600">管理画面</div>
        <Card className="m-auto w-2xl">
            <CardHeader>
                <CardTitle className="text-center">新規レコード登録</CardTitle>
                <CardContent>
                    <Form {...form}>
                        <form onSubmit={form.handleSubmit(onSubmit)}>
                            <>
                    <div className="grid gap-4 sm:grid-cols-2">
                        <div className="grid gap-2">
                            <FormField
                                control={form.control}
                                name="name"
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>名前</FormLabel>
                                    <FormControl>

                                        <Input placeholder="プレイヤー名" {...field} />
                                    </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />
                        </div>

                        <div className="grid gap-2">
                            <FormField
                                control={form.control}
                                name={`avatar`}
                                render={({ field }) => (
                                    <div>
                                        <FormItem>
                                        <FormLabel>
                                            アバター <span className="text-red-500">*</span>
                                        </FormLabel>
                                        <Select onValueChange={field.onChange} defaultValue={field.value}>
                                            <FormControl>
                                                <SelectTrigger>
                                                    <SelectValue placeholder="アバターを選択" />
                                                </SelectTrigger>
                                            </FormControl>
                                            <SelectContent>
                                                <SelectItem value="panda">🐼</SelectItem>
                                                <SelectItem value="bird">🐦</SelectItem>
                                                <SelectItem value="rabbit">🐰</SelectItem>
                                                <SelectItem value="mouse">🐭</SelectItem>
                                            </SelectContent>
                                        </Select>
                                            <FormMessage />
                                            </FormItem>
                                    </div>
                                )}
                            />
                        </div>

                        <div className="grid gap-2">
                            <FormField
                                control={form.control}
                                name="score"
                                render={({ field }) => (
                                        <FormItem>
                                            <FormLabel>スコア</FormLabel>
                                            <FormControl>
                                                <Input
                                                    type="number"
                                                    {...field}
                                                />
                                            </FormControl>
                                            <FormMessage />
                                        </FormItem>
                                )}
                            />
                        </div>
                    </div>
                    <div className="flex my-5 justify-center items-center">
                        <Button className="bg-green-600 hover:bg-green-700" type="submit">
                            <Save className="mr-2 h-4 w-4" />
                            プレイヤーを登録する
                        </Button>
                    </div>
                            </>
                        </form>
                    </Form>
                </CardContent>
            </CardHeader>
        </Card>
        </div>
    )
}

export default AdminPage;
```

GASのコードは下のようにdoPost関数でpostリクエストで処理できるようにしていました。
```js
function doPost(e) {
  try {
    const req = JSON.parse(e.postData.contents)

    const ss = SpreadsheetApp.getActive();
    const sheet = ss.getSheetByName('シート1');


    const maxRow = sheet.getLastRow();


    sheet.appendRow([
      maxRow,
      req.name,
      req.animal,
      req.score.toString(),
      new Date().toISOString().split('T')[0]
    ])

    return ContentService.createTextOutput(JSON.stringify({
      status: 'success',
      message: 'データが正常に保存されました'
    })).setMimeType(ContentService.MimeType.JSON);

  } catch (error) {
    return ContentService.createTextOutput(JSON.stringify({
      status: 'error',
      code: 500,
      message: error.toString()
    }))
      .setMimeType(ContentService.MimeType.JSON)
      .setResponseCode(500);
  }
}
```

上の状態でformの送信ボタンを押した時に下のようなエラーが出てしまいました。
```zsh
admin:1 Access to XMLHttpRequest at 'https://script.google.com/macros'
from origin 'http://172.31.193.145:3000' has been blocked by CORS policy: 
Response to preflight request doesn't pass access control check: 
No 'Access-Control-Allow-Origin' header is present on the requested resource.
```

# エラーの原因
上のエラーはCORSによるエラーでした。

## そもそもCORSとは
エラー文にある`CORS(Cross Origin Resource Sharing)`とは、`オリジン間リソース共有`と呼ばれるブラウザのセキュリティ機能の一つです。

異なるオリジン間におけるリソースの共有を制限して信頼できるオリジンの対してのリクエストのみ応えるようにできます。

:::note
### オリジンとは
`http://example.com:3000`や`https://api.example.com`のような、`プロトコル`、`ドメイン名`、`ポート`などを組み合わせたものを指します。
:::

## GASでの仕様
公式な声明が見つからなかったですが、GASは一応CORSの許可オリジンはワイルドカードにしており、すべてのオリジンからのアクセスを許可しているそうです。

加えて`Content-Type:application/json`ではなく、`x-www-form-urlencoded`で送信することで解決するという情報が見つかりました。

`Content-Type:application/json`だとリクエストできない理由としては、`プリフライトリクエスト`が関係しています。

### プリフライトリクエストとは
プリフライトリクエストとは、CORSの仕様でサーバー側が認可しているメソッドかどうかの確認を特定のメソッドとヘッダーを利用して行います。

プリフライトリクエストの例を下に載せてみました。
HTTPメソッドは、`OPTION`
HTTPヘッダ-は、`Access-Control-Request-Method`、`Access-Control-Request-Headers`、`Origin`の３つが必要になります。
```
OPTIONS /resource/foo
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Origin, X-Requested-With
Origin: https://foo.bar.org
```

サーバー側がCORSの設定で許可をしているとレスポンスは下のようになります。
許可されていると許可内容が含められています。
```
HTTP/1.1 204 No Content
Connection: keep-alive
Access-Control-Allow-Origin: https://foo.bar.org
Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE
Access-Control-Allow-Headers: Origin, X-Requested-With
Access-Control-Max-Age: 86400
```


実際のプリフライトリクエストは下のようになっていました。

```
OPTIONS 
access-control-request-method: POST
access-control-request-headers: content-type
origin: http://172.31.193.145:3000
```

`Content-Type:application/json`でプリフライトリクエストが行われるのは、比較的新しい`Content-Type`であり、`application/x-www-form-urlencoded`や`text/plain`は古くからある`Content-Type`であるためCORSの対象に入っていないことが考えられます。

## しかしPostman等で送信すると上手くいく

Postman等のAPI送信ができるツールができるとステータスコード200が返ってきており、サーバーへのリクエストが上手くいっています。

これはCORSがブラウザとサーバー間の通信で必須となるセキュリティ機能の一つであるためです。
そのためブラウザ以外でのセキュリティ機能として存在しているわけではないため、リクエストがうまく届きます。

# 解決方法

今回はNext.jsでアプリケーションを作成していたため、サーバーコンポーネントとして実行することでブラウザではなく、サーバー間で通信させることで解決しました。

:::note
### サーバーコンポーネント
Reactの機能で、ブラウザで表示するコンポーネントとはまた別でサーバーでコンポーネントを生成、フロントでそのコンポーネントを呼び出すことができる。

https://ja.react.dev/reference/rsc/use-server

:::

コードは以下のようになりました。

```ts
"use server"

import {apiClient} from "@/lib/apiClient";

export interface NewRankingData {
    name?: string;
    avatar?: string;
    score?: string;
}

export const postRanking = async (reqData: NewRankingData) => {
    apiClient.post("", {
        name: reqData?.name,
        animal: reqData?.avatar,
        score: Number(reqData?.score),
    }).then((res)=>{
        console.log(res.data)
    }).catch((err)=>{
        console.error(err);
    })
}
```

このようにサーバー側から叩くことでGASのCORSエラーは回避することができます。

# Next.jsの関連記事
https://qiita.com/maooz4426/items/4786449caf78b58d6b5f

https://qiita.com/maooz4426/items/d5bdcfb83d2743756d81

https://qiita.com/maooz4426/items/79c6a9b129a6e7ec35b4

https://qiita.com/maooz4426/items/52511f6e425393cc36fd

https://qiita.com/maooz4426/items/21fbc1f02eab1d76061e

https://qiita.com/maooz4426/items/056e0fa08cd0edcee4bf

# GAS関連の記事
https://qiita.com/maooz4426/items/3efc2ccb9806572c70ee