API GatewayバックのLambda関数にアプリケーションからカスタムヘッダーを渡す際にハマったこと

2021.10.13

こんにちは、CX事業本部 IoT事業部の若槻です。

今回は、API GatewayバックのLambda関数にアプリケーションからカスタムヘッダーを渡す際にハマったので対処した話です。

実装

CDKコード

Lambda+API Gateway(Lambdaプロキシ統合)のバックエンド構成をAWS CDKで実装しました。

lib/aws-cdk-app-stack.ts

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as lambdaNodejs from '@aws-cdk/aws-lambda-nodejs';
import * as apigateway from '@aws-cdk/aws-apigateway';

export class AwsCdkAppStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const getDataFunction = new lambdaNodejs.NodejsFunction(this, 'getData', {
      entry: 'src/lambda/handlers/get-data.ts',
      runtime: lambda.Runtime.NODEJS_14_X,
    });

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: 'restApi',
    });

    const methodOptionsWithCors: apigateway.MethodOptions = {
      methodResponses: [
        {
          statusCode: '200',
          responseModels: {
            'application/json': apigateway.Model.EMPTY_MODEL,
          },
          responseParameters: {
            'method.response.header.Access-Control-Allow-Methods': false,
            'method.response.header.Access-Control-Allow-Headers': false,
            'method.response.header.Access-Control-Allow-Origin': false,
          },
        },
      ],
    };

    const corsIntegration = new apigateway.MockIntegration({
      integrationResponses: [
        {
          statusCode: '200',
          responseParameters: {
            'method.response.header.Access-Control-Allow-Headers':
              "'Content-Type'",
            'method.response.header.Access-Control-Allow-Methods':
              "'OPTIONS,POST,PUT,GET,DELETE,PATCH'",
            'method.response.header.Access-Control-Allow-Origin': "'*'",
          },
          responseTemplates: {
            'application/json': '{}',
          },
        },
      ],
      passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
      requestTemplates: {
        'application/json': '{"statusCode": 200}',
      },
    });

    const dataResource = restApi.root.addResource('data');
    dataResource.addMethod(
      'GET',
      new apigateway.LambdaIntegration(getDataFunction)
    );
    dataResource.addMethod('OPTIONS', corsIntegration, methodOptionsWithCors);
  }
}

Lambdaコード

LambdaではカスタムヘッダーdeviceIdが指定されていればステータスコード200、未指定ならステータスコード400を返します。

src/lambda/handlers/get-data.ts

import { APIGatewayEvent } from 'aws-lambda';

const headers = {
  'Content-Type': 'application/json; charset=utf-8',
  'Access-Control-Allow-Headers': 'Authorization, Content-Type',
  'Access-Control-Allow-Methods': 'POST',
  'Access-Control-Allow-Origin': '*',
};

export const handler = async (event: APIGatewayEvent) => {
  console.log(event.headers);

  //deviceIdヘッダーが未指定なら400エラーを返す
  if (event.headers['deviceId'] == undefined) {
    console.log('deviceIdが指定されていません');
    return {
      statusCode: 400,
      headers: headers,
      body: JSON.stringify({
        timestamp: new Date(),
      }),
    };
  }

  return {
    statusCode: 200,
    headers: headers,
    body: JSON.stringify({
      timestamp: new Date(),
      method: event.httpMethod,
    }),
  };
};

API Gatewayコンソールでのテスト

cdk deployでRest APIとLambda関数をデプロイしたら、API Gatewayコンソールで動作確認をしてみます。

deviceIdヘッダーを指定した場合は200が返ります。

deviceIdヘッダーを指定しない場合は400が返ります。

コンソールからはちゃんと動作していることを確認できました。

Webアプリ(React)からアクセスしてみる

前述のRest APIにリクエストをする下記のReactアプリを実装しました。

src/App.tsx

import React, { useEffect } from 'react';
import Axios from 'axios';

const apiEndpoint = process.env.REACT_APP_API_ENDPOINT;
const deviceId = 'device01';

let headers = {
  'Content-Type': 'application/json',
  deviceId: deviceId,
};

const App: React.FC = () => {
  useEffect(() => {
    Axios.get(`${apiEndpoint}/data`, {
      headers: headers,
    })
      .then((res) => {
        console.log(res);
      })
      .catch((err) => {
        console.log(err);
      });
  }, []);

  return <div>Hello World.</div>;
};

export default App;

ハマり1:Access-Control-Allow-Headersへの未追加

アプリにブラウザでアクセスすると下記のエラーとなりました。

Request header field deviceid is not allowed by Access-Control-Allow-Headers in preflight response.

APIへのプリフライトリクエストに対するAccess-Control-Allow-Headersレスポンスヘッダーにdeviceidが追加されている必要があるようです。

プリフライトリクエストとは、ブラウザからのCORSリクエストがCORSプロトコルに則っているかどうかをAPIサーバーがチェックするためのリクエストです。

そこでCDKコードを下記のように修正します。

lib/aws-cdk-app-stack.ts

    const corsIntegration = new apigateway.MockIntegration({
      integrationResponses: [
        {
          statusCode: '200',
          responseParameters: {
            'method.response.header.Access-Control-Allow-Headers':
+              "'Content-Type,deviceId'",
-              "'Content-Type'",

ハマり2:ブラウザからのアクセスではヘッダーが小文字となる

前述の修正済みのRest APIに対して再度アプリからアクセスすると、次は400エラーとなりました。

Lambdaのログを確認するとデバイスIDヘッダーがdeviceIdではなくdeviceid(小文字のみ)となっています。そのためLambdaが400エラーをレスポンスしていたようです。

ブラウザのネットワークログを確認すると、ブラウザからのリクエスト時点でdeviceidとなっています。

ドキュメントによるとHTTPヘッダーは大文字と小文字を区別しない(case-insensitive)とのこと。そのためリクエスト時にヘッダーが小文字のみのdeviceidに変換され、Lambda側で大文字含むdeviceIdをパースできなかったようです。

HTTP ヘッダーは、大文字小文字を区別しないヘッダー名とそれに続くコロン (:)、 値で構成されます。

そこでLambdaコードを下記のようにヘッダーを小文字で扱うように修正します。

src/App.tsx

export const handler = async (event: APIGatewayEvent) => {
  console.log(event.headers);

+  if (event.headers['deviceid'] == undefined) {
-  if (event.headers['deviceId'] == undefined) {
    console.log('deviceIdが指定されていません');

するとエラー無くリクエストを実行することができました。

バックエンドのE2Eテストでは問題を見つけられなかった

今回ハマった事象ですが、バックエンドを実装した際に作成したE2Eテストでは問題を見つけられませんでした。理由としては、そのE2EテストではAxiosをスクリプトで実行してRest APIにリクエストをするのですが、今回ハマった事象はブラウザでアクセスすることが条件であったためです。よってアプリケーションを実装する際に初めて問題が見つかった、という経緯がありました。

おわりに

API GatewayバックのLambda関数にアプリケーションからカスタムヘッダーを渡す際にハマったので対処した話でした。

Webの基本的なところの理解がまだまだだということを思い知らされました。またブラウザを使うことを前提としたより本番利用に近い形でのテストを実装する必要性も感じました。

参考

以上