SAMでCORS設定する

この記事は、[Ateam Group U\-30のカレンダー \| Advent Calendar 2022 \- Qiita](https://qiita.com/advent-calendar/2022/ateam-group-u30)の22日目の記事になります。

本日は、@tommy1038が担当いたします。

## はじめに

みなさん、[AWS Serverless Application Model (AWS SAM)](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/what-is-sam.html)を使ったことがありますか？
AWS Serverless Application Model (AWS SAM)とは、公式サイトによりますと、

>AWS Serverless Application Model (AWS SAM) は、AWS でサーバーレスアプリケーションを構築するために使用できるオープンソースのフレームワークです。

とのことです。名称が長いため、以下`SAM`と記載します。
今回は、実際に業務で使用している際に対応した、CORS設定の部分を紹介したいと思います。

## おことわり

実際に公式のチュートリアルを見ながらデプロイまで進めて、そのあたりも記事に含もうと思っていましたが、そういった記事で参考になるものが多かったことや、業務で詰まった箇所にフォーカスしたかったので、`sam init`から`sam deploy --guided`の部分は軽く紹介して、その後をメインに本記事を書きたいと思います。

### 小さく始めていく際に、参考になりそうなサイト・記事

- [チュートリアル: Hello World アプリケーションのデプロイ \- AWS Serverless Application Model](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-getting-started-hello-world.html)
- [\[AWS SAM\] 概要、Hello World \- Qiita](https://qiita.com/takmot/items/27f7145f953fc62bdf6b)
- [\[AWS\]SAMでTypeScriptがサポートされたので5分でAPIを作成する \- Qiita](https://qiita.com/hf7777hi/items/6d2b093d6ed7271cf81b#%E7%A2%BA%E8%AA%8D)
- [AWS SAMを使ってみる（nodejs TypeScript版）](https://zenn.dev/ttani/articles/aws-sam-setup)

## 事前準備

実行環境としては、以下のバージョンで実施しました。`aws cli`は参考までに記載しています。

```
❯ brew --version
Homebrew 3.6.15
Homebrew/homebrew-core (git revision 32dbb76ab72; last commit 2022-12-17)
Homebrew/homebrew-cask (git revision 54c47be607; last commit 2022-12-17)

❯ sam --version
SAM CLI, version 1.66.0

❯ aws --version
aws-cli/2.9.6 Python/3.11.0 Darwin/21.6.0 source/x86_64 prompt/off
```

## プロジェクトの作成

`sam init`からプロジェクトを作成していきます。`sam deploy --guided`までしますが、本題は [Reactアプリから、APIを呼んでみる](#reactアプリからapiを呼んでみる) からなので、デプロイ済みの方は飛ばしてもらって大丈夫です。

```
❯ sam init

You can preselect a particular runtime or package type when using the `sam init` experience.
Call `sam init --help` to learn more.

Which template source would you like to use?
	1 - AWS Quick Start Templates
	2 - Custom Template Location
Choice: 1

Choose an AWS Quick Start application template
	1 - Hello World Example
	2 - Multi-step workflow
	3 - Serverless API
	4 - Scheduled task
	5 - Standalone function
	6 - Data processing
	7 - Infrastructure event management
	8 - Serverless Connector Hello World Example
	9 - Multi-step workflow with Connectors
	10 - Lambda EFS example
	11 - Machine Learning
Template: 1

Use the most popular runtime and package type? (Python and zip) [y/N]: N

Which runtime would you like to use?
	1 - aot.dotnet7 (provided.al2)
	2 - dotnet6
	3 - dotnet5.0
	4 - dotnetcore3.1
	5 - go1.x
	6 - go (provided.al2)
	7 - graalvm.java11 (provided.al2)
	8 - graalvm.java17 (provided.al2)
	9 - java11
	10 - java8.al2
	11 - java8
	12 - nodejs18.x
	13 - nodejs16.x
	14 - nodejs14.x
	15 - nodejs12.x
	16 - python3.9
	17 - python3.8
	18 - python3.7
	19 - ruby2.7
	20 - rust (provided.al2)
Runtime: 12

What package type would you like to use?
	1 - Zip
	2 - Image
Package type: 1

Based on your selections, the only dependency manager available is npm.
We will proceed copying the template using npm.

Select your starter template
	1 - Hello World Example
	2 - Hello World Example TypeScript
Template: 2

Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]: N

Project name [sam-app]: sam-app-typescript

Cloning from https://github.com/aws/aws-sam-cli-app-templates (process may take a moment)

    -----------------------
    Generating application:
    -----------------------
    Name: sam-app-typescript
    Runtime: nodejs18.x
    Architectures: x86_64
    Dependency Manager: npm
    Application Template: hello-world-typescript
    Output Directory: .

    Next steps can be found in the README file at ./sam-app-typescript/README.md


    Commands you can use next
    =========================
    [*] Create pipeline: cd sam-app-typescript && sam pipeline init --bootstrap
    [*] Validate SAM template: cd sam-app-typescript && sam validate
    [*] Test Function in the Cloud: cd sam-app-typescript && sam sync --stack-name {stack-name} --watch

❯ sam build

(中略)

❯ sam deploy --guided

Configuring SAM deploy
======================

	Looking for config file [samconfig.toml] :  Not found

	Setting default arguments for 'sam deploy'
	=========================================
	Stack Name [sam-app]: sam-app-typescript
	AWS Region [ap-northeast-1]:
	#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
	Confirm changes before deploy [y/N]: N
	#SAM needs permission to be able to create roles to connect to the resources in your template
	Allow SAM CLI IAM role creation [Y/n]: Y
	#Preserves the state of previously provisioned resources when an operation fails
	Disable rollback [y/N]: y
	HelloWorldFunction may not have authorization defined, Is this okay? [y/N]: y
	Save arguments to configuration file [Y/n]: Y
	SAM configuration file [samconfig.toml]:
	SAM configuration environment [default]:

	Looking for resources needed for deployment:
	 Managed S3 bucket: aws-sam-cli-managed-default-samclisourcebucket-1eb8qhml3zh2a
	 A different default S3 bucket can be set in samconfig.toml

	Saved arguments to config file
	Running 'sam deploy' for future deployments will use the parameters saved above.
	The above parameters can be changed by modifying samconfig.toml
	Learn more about samconfig.toml syntax at
	https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html

(中略)

Outputs
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 HelloWorldFunctionIamRole
Description         Implicit IAM Role created for Hello World function
Value               arn:aws:iam::************:role/sam-app-typescript-HelloWorldFunctionRole-************

Key                 HelloWorldApi
Description         API Gateway endpoint URL for Prod stage for Hello World function
Value               https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/

Key                 HelloWorldFunction
Description         Hello World Lambda Function ARN
Value               arn:aws:lambda:ap-northeast-1:************:function:sam-app-typescript-HelloWorldFunction-************
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Successfully created/updated stack - sam-app-typescript in ap-northeast-1
```

### 挙動確認

```
❯ curl https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
{"message":"hello world"}

❯ curl -i https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
HTTP/2 200
content-type: application/json
content-length: 25
date: Sun, 18 Dec 2022 15:45:55 GMT
x-amzn-requestid:
x-amz-apigw-id:
x-amzn-trace-id:
x-cache: Miss from cloudfront
via:
x-amz-cf-pop:
x-amz-cf-id:

{"message":"hello world"}⏎
```

## Reactアプリから、APIを呼んでみる

さて、ここからが本題です。テンプレートを使用してAPIを作成することができました。

ここで作成したAPIを、自分のWebサイト側から呼び出してみたいと思います。今回は仮で、ReactでWebサイトを作ります。
https://create-react-app.dev/docs/getting-started/ を参考に、Quick Startしてみます。

```
npx create-react-app my-app
cd my-app
npm start
```

上記により、自動でReactのアプリが立ち上がり、`http://localhost:3000/`で以下のようにアクセスできます。

![スクリーンショット 2022-12-22 0.21.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/cc1aa1b1-3263-c44e-31a4-5f736eb12ac6.png)

では、ReactのアプリでAPIを呼び出してみたいと思います。
どんなライブラリでも構いませんが、今回は[axios/axios: Promise based HTTP client for the browser and node\.js](https://github.com/axios/axios)を用いてAPIを呼んでみます。

以下、呼び出しのコードを抜粋してみます。

```ts
import axios, { AxiosError, AxiosRequestConfig } from "axios";

const url = "https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/"

const options: AxiosRequestConfig = {
  url: `${url}/hello`,
  method: "GET",
};

axios(options)
  .then((res) => {
    const { data } = res;
    console.log(data);
  })
  .catch((e: AxiosError) => {
    console.log(e.message);
  });
```

`npm start`起動して、開発者コンソールを開いてログを確認してみましょう。想定しているのは、`{"message":"hello world"}`が確認できることですが、下記のようなエラーが出ていました。

![スクリーンショット 2022-12-22 0.28.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/5e045867-816d-0410-b0f3-e931dee957c3.png)


よくある？CORSのエラーですね、、、CORSとはなんでしょうか？
CORS(Cross-Origin Resource Sharing)とは、日本語訳をすると**オリジン間リソース共有**です。

>つまりCORSとは、あるオリジンで動いている Web アプリケーションに対して、別のオリジンのサーバーへのアクセスをオリジン間 HTTP リクエストによって許可できる仕組み (https://qiita.com/att55/items/2154a8aad8bf1409db2b より引用)

今回の場合、Reactのアプリ側からAPIを呼び出そうとした際に、オリジン間のリソース共有を許可していなかったため、エラーになっていました。そのため、CORSを許可してあげることが必要になります。

### 参考記事

- [なんとなく CORS がわかる\.\.\.はもう終わりにする。 \- Qiita](https://qiita.com/att55/items/2154a8aad8bf1409db2b)

対応方法として、以下の公式サイトを参考にします。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/how-to-cors.html#:~:text=Lambda%20%E3%81%BE%E3%81%9F%E3%81%AF%20HTTP%20%E3%83%97%E3%83%AD%E3%82%AD%E3%82%B7%E7%B5%B1%E5%90%88%E3%81%B8%E3%81%AE%20CORS%20%E3%81%AE%E3%82%B5%E3%83%9D%E3%83%BC%E3%83%88%E3%82%92%E6%9C%89%E5%8A%B9%E3%81%AB%E3%81%99%E3%82%8B

リンクを参考に、Lambda側の修正を行います。returnの中だけ抜粋して記述します。

```hello-world/app.ts
return {
    statusCode: 200,
    body: JSON.stringify({
        message: 'hello world',
    }),
    // headersを追加
    headers: {
        'Access-Control-Allow-Origin': '*', // 全てを許可してしまうので、本来はよろしくない
    },
};
```

`headers`の要素に、`Access-Control-Allow-Origin`にて、許可するオリジンの文字列を`*`で設定しています。開発環境で試しているので、一旦`*`ですが、本番でオリジンを指定しましょう。

挙動を確認してみます。

```
❯ curl -i https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
{"message":"hello world"}

❯ curl -i https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
HTTP/2 200
content-type: application/json
content-length: 25
date: Wed, 21 Dec 2022 15:37:59 GMT
x-amzn-requestid:
access-control-allow-origin: * // ここが追加されている
x-amz-apigw-id:
x-amzn-trace-id:
x-cache:
via:
x-amz-cf-pop:
x-amz-cf-id:

{"message":"hello world"}⏎
```

では、もう一度、`npm start`してReact側での挙動も見てみましょう。

![スクリーンショット 2022-12-22 0.36.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/e4cbd712-d133-1270-ba59-25e6f8db4e4d.png)

ちゃんと、"hello world"の文字列が受け取れていそうです。これで、Reactアプリから呼び出すことができました。

## Lambda プロキシ統合 / 非プロキシ(カスタム)統合

今までのCORS設定の対応で、自分のReactアプリなどからAPIを呼び出すことができますが、CORS設定の対応としてもう一つあるようです。（お恥ずかしながら、記事を書きながら知りました。。。）
それが、`Lambda プロキシ統合の使用`の有無です。

普通にCLIベースで作成した場合、`プロキシ統合`でAPIが作成されていました。

そのため、返却値として以下のように組むと

```ts
{
  statusCode: 200,
  body: JSON.stringify({
      message: 'hello world',
  }),
  headers: {
      'Access-Control-Allow-Origin': '*',
  },
}
```

```
❯ curl https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
{"message":"hello world"}⏎
```

と、`statusCode`などは得られずに、`body`部分だけ得られるという形でした。

業務で使用しているAPIの中には、`Lambda 非プロキシ(カスタム)統合`のものもありましたので、そちらの対応もしていこうと思います。
AWS::Serverless::Api リソースの`DefinitionBody`に、OpenAPIの定義を用意していきます。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/enable-cors-for-resource-using-swagger-importer-tool.html

を参考にして、以下のように修正してみます。

`template.yaml`に `AWS::Serverless::Api` 定義を用意します。

```template.yml
MyAPI:
  Type: AWS::Serverless::Api
  Properties:
    StageName: Prod
    DefinitionBody: #後述
```

Stage名はプロパティとして必須ですが、`https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/`の`Prod`に対応しています。`DefinitionBody`にOpenAPIの定義を用意します。

```template.yml
    DefinitionBody:
      swagger: 2.0
      info:
        title:
          "sam-app-test"
      schemes:
      - https
      paths:
        /hello:
          get:
            consumes:
            - application/json
            produces:
            - application/json
            responses:
              '200':
                description: '200 response'
                headers:
                  Access-Control-Allow-Origin:
                      type: string
            x-amazon-apigateway-integration:
              responses:
                default:
                  statusCode: 200
                  responseParameters:
                    method.response.header.Access-Control-Allow-Origin : "'*'"
              uri:
                Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations
              passthroughBehavior: when_no_match
              httpMethod: POST
              type: aws
```

/helloのgetメソッドを定義して、headersを用意します。consumes/producesは、MIME Typeの設定です。レスポンスの設定で`Access-Control-Allow-Origin`を、ヘッダーの要素として定義します。`x-amazon-apigateway-integrations`でのレスポンスで、ヘッダーの要素に対して値をセットアップします。

また、Lambda 関数の呼び出しでは、値は `POST` である必要があるため、`httpMethod`は `POST` でOKです。その他の要素は下記リンクを参照ください。

参考: [x\-amazon\-apigateway\-integration オブジェクト \- Amazon API Gateway](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration.html)

最終的に、下記のようになりました。
さきほどの説明の中で紹介していない点をコメントとして残していますが、`AWS::Serverless::Api`の定義として、`MyAPI`を作成したので、それを参照するように修正しています。

見やすさのために、`git diff`して追記箇所を表してみます。

```diff_yaml
❯ git diff
diff --git a/template.yaml b/template.yaml
index 25470b9..2253036 100644
--- a/template.yaml
+++ b/template.yaml
@@ -11,6 +11,42 @@ Globals:
     Timeout: 3

 Resources:
+  MyAPI:
+    Type: AWS::Serverless::Api
+    Properties:
+      StageName: Prod
+      DefinitionBody:
+        swagger: 2.0
+        info:
+          title:
+            "sam-app-test"
+        schemes:
+        - https
+        paths:
+          /hello:
+            get:
+              consumes:
+              - application/json
+              produces:
+              - application/json
+              responses:
+                '200':
+                  description: '200 response'
+                  headers:
+                    Access-Control-Allow-Origin:
+                        type: string
+              x-amazon-apigateway-integration:
+                responses:
+                  default:
+                    statusCode: 200
+                    responseParameters:
+                      method.response.header.Access-Control-Allow-Origin : "'*'"
+                uri:
+                  Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations
+                passthroughBehavior: when_no_match
+                httpMethod: POST
+                type: aws
+
   HelloWorldFunction:
     Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
     Properties:
@@ -25,6 +61,7 @@ Resources:
           Properties:
             Path: /hello
             Method: get
+            RestApiId: !Ref MyAPI // 自分で定義したAPIを参照するように
     Metadata: # Manage esbuild properties
       BuildMethod: esbuild
       BuildProperties:
@@ -40,7 +77,7 @@ Outputs:
   # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
   HelloWorldApi:
     Description: "API Gateway endpoint URL for Prod stage for Hello World function"
-    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
+    Value: !Sub "https://${MyAPI}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/" // 自分で定義したAPIを参照するように
   HelloWorldFunction:
     Description: "Hello World Lambda Function ARN"
     Value: !GetAtt HelloWorldFunction.Arn
```

挙動を確認してみます。

```
❯ curl -i https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
{"statusCode":200,"body":"{\"message\":\"hello world\"}"}

❯ curl -i https://{restapi_id}.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
HTTP/2 200
content-type: application/json
content-length: 57
date: Wed, 21 Dec 2022 16:03:16 GMT
x-amzn-requestid:
access-control-allow-origin: * // ここが追加されている
x-amz-apigw-id:
x-amzn-trace-id:
x-cache:
via:
x-amz-cf-pop
x-amz-cf-id:

{"statusCode":200,"body":"{\"message\":\"hello world\"}"}
```

同じLambdaですが、`プロキシ統合`と返却値が異なっていますね。こちらの方が、API GatewayとLambdaの役割がそれぞれわかりやすいというメリットがありそうですが、さくっとLambdaだけ作って検証したい、というようなときはまとまっていたりすると便利でしょうし、メリット/デメリットを考えて使えればよいのかなと思いました。（参考になりそうな記事を最後にまとめています。）

これで、Reactアプリ側から呼び出してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/2535af43-8213-b6ba-9e1c-bb4f966498b5.png)

問題なく呼び出せていますね。

## まとめ

CORS設定はあるあるネタかなと思ったんですが、SAMでよしなにやってくれていたりする部分を紐解いていくと、API Gateway側なのか、Lambda側なのか、そもそもCloudformationのテンプレートの書き方がダメなのか、とハマってしまいました。同じようなことを試される方の参考になれば幸いです。

## 参考にさせてもらったサイト・記事

- [API Gateway で Lambda 統合を設定する \- Amazon API Gateway](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-integrations.html)
- [API Gateway \+ Lambda プロキシ結合の使用有無による違い \- Qiita](https://qiita.com/yuuwatanabe/items/a3bd65e709f20574b6db)
- [【AWS API Gateway, Lambda】プロキシ統合を使うとき/使わないときの値のやりとり \- BFT名古屋 TECH BLOG](https://bftnagoya.hateblo.jp/entry/2022/11/08/134800)