Search code examples
amazon-web-servicescorsaws-api-gateway

How do I add custom headers to API Gateway RestAPI Integration responses via CDK (it works through the web console)


I have a CDK project to create a DynamoDB Table and an API Gateway Rest API that exposes the results of a query to that table. I need to make a GET request to that API from a different domain than the API's domain, so I have to add a CORS header. API Gateway provides an easy way to specify the CORS behaviour for the OPTIONS request, but from what I can tell it's my responsibility to add it for the other verbs. According to these docs:

When you enable CORS by using the AWS Management Console, API Gateway creates an OPTIONS method and attempts to add the Access-Control-Allow-Origin header to your existing method integration responses. This doesn’t always work, and sometimes you need to manually modify the integration response to properly enable CORS. Usually this just means manually modifying the integration response to return the Access-Control-Allow-Origin header.

Note that these docs describe what happens when you use the AWS Console, whereas I'm doing it with CDK. Although "API Gateway attempts to add the header" for console, I expect to have to do that myself. It seems that my task is to modify the integration response to return the Access-Control-Allow-Origin header (through CDK).

I have authored a CDK package that includes the CORS header for OPTIONS and no CORS header for the other verbs (see below). When I manually (through the web console) change the integration response to return the Access-Control-Allow-Origin header, I see the header in the response to the GET request as expected. However, when I try to reproduce that through CDK I cannot find a combination of integration response options that successfully builds and deploys. At the bottom of the question I include the CDK attempt I made, but it didn't deploy successfully (and neither did several other CDK options guesses that I made and discarded).

I'd like to know how I can replace my manual integration response changes with a CDK declaration that does the same thing.

Details

Working deployment without CORS header for GET

CDK:

    const table = new dynamodb.Table(this, 'NiftyTable', { partitionKey: ... });
    const api = new apigateway.RestApi(this, 'NiftyApi', {
      defaultCorsPreflightOptions: {
        allowOrigins: [
          'https://production.nifty.com',
          'http://local.nifty.com:8080',
        ]
      }
    });
    const specificResource = api.root.addResource('SomethingSpecific')
    const dynamoQueryIntegration = new apigateway.AwsIntegration({ service: 'dynamodb', action: 'Query', options: {
      passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_TEMPLATES,
      credentialsRole: ...,
      requestParameters: { ... },
      requestTemplates: { 'application/json': JSON.stringify({ TableName: table.tableName, ... }) },
      integrationResponses: [{
        statusCode: '200',
        // I've tried adding responseParameters and responseTemplates here without success
      }],
    }});

    api.addMethod('GET', dynamoQueryIntegration, {
      methodResponses: [{ statusCode: '200' }],
      requestParameters: { ... },
    });

When I deploy this stack and make a request to the API from Firefox, I see the following in the Network tab:

OPTIONS returns a valid CORS header ✅

OPTIONS https://apidomain.com/prod/SomethingSpecific
access-control-allow-origin
    https://production.nifty.com

GET has no CORS header 👎🏻

GET https://apidomain.com/prod/SomethingSpecific
Response body is not available to scripts (Reason: CORS Missing Allow Origin)

Manually configured CORS header for GET response

Through the AWS Api Gateway console I add a CORS header to the resource:

  • API Gateway > APIs > NiftyApi > Resources > /SomethingSpecific > GET
  • Method Response > 200
  • Add Header Access-Control-Allow-Origin
  • back to Method Execution
  • Integration Response > 200 > Header Mappings
  • change Access-Control-Allow-Origin Mapping value to '*'
  • deploy API

Now, when I make the same request from the browser to the API, the OPTIONS request still returns the expected CORS header, and now the GET request includes the expected header access-control-allow-origin * which I just added through Console 👍🏻. If I was comfortable with manual config steps after deploying, this would solve all my problems. However, I'd like this deployment to be fully handled by CDK with no manual steps, so I'm not satisfied having to manually make the change.

Efforts made to set CORS header for GET response through CDK

I read the docs for ApiIntegration which show that it accepts ApiIntegrationProps which contain a field options of type IntegrationOptions. The IntegrationOptions object has integrationResponses which is a list of IntegrationResponse objects, each which contain fields responseParameters and responseTemplates. Since I'm trying to change the integrationResponse, I think this object is where I need to make a change.

Guesswork and reverse engineering

I couldn't find any guidance in official docs or stack overflow for how to use responseParameters and responseTemplates. I'm looking for an example that I can copy, but haven't found anything.

I wanted to know what happens in the web console when I make the change, so I repeated the manual steps with the network tab opened and saw that when I changed the Method Response it called updateMethodResponse with content string {"patchOperations":[{"op":"add","path":"/responseParameters/method.response.header.Access-Control-Allow-Origin"}]}. Then when I changed the Integration Response it called updateIntegrationResponse with content string {"patchOperations":[{"op":"add","path":"/responseParameters/method.response.header.Access-Control-Allow-Origin","value":"'*'"}]}.

Console Step What I entered Corresponding CDK option (guessed, probably incorrect)
Method Response Access-Control-Allow-Origin responseParameters key
Integration Response select Access-Control-Allow-Origin, enter '*' responseParameters value

Based on that, I changed my CDK to the following:

    const dynamoQueryIntegration = new apigateway.AwsIntegration({ service: 'dynamodb', action: 'Query', options: {
      passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_TEMPLATES,
      credentialsRole: apiGatewayIntegrationRole,
      requestParameters: { ... },
      requestTemplates: { 'application/json': JSON.stringify({ TableName: table.tableName, ... }) },
      integrationResponses: [{
        statusCode: '200',
        responseParameters: {
          'integration.response.header.Access-Control-Allow-Origin': "'*'"
        },
      }],
    }});

when I attempt to deploy this, I get the error

Invalid mapping expression specified: Validation Result: warnings : [], errors : [Invalid mapping expression specified: integration.response.header.Access-Control-Allow-Origin] (Service: AmazonApiGateway; Status Code: 400; Error Code: BadRequestException; Request ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx; Proxy: null)

Is it possible to do what I'm trying to do? Maybe there is a hack using the SDK from a lambda-backed custom resource? I would accept any solution that runs as part of my CDK deploy, even if it's a bit of a hack.


Solution

  • Only AwsIntegration's options.integrationResponses response parameter is not enough for that.

    You need allow header on addMethod's methodResponses as well

    export class ApiDynamoStack extends Stack {
      constructor(scope: Construct, id: string, props?: StackProps) {
        super(scope, id, props);
    
        // DynamoDB Table
        const ddbTable = new Table(this, 'ApiDynamoTable', {
          partitionKey: {name:'pk', type: AttributeType.STRING},
          billingMode: BillingMode.PAY_PER_REQUEST,
          removalPolicy: RemovalPolicy.DESTROY,
        });
    
        // RestApi
        const restApi = new RestApi(this, 'ApiDynamoRestApi', {
          defaultCorsPreflightOptions: {
            allowOrigins: [
              'https://production.nifty.com',
              'http://local.nifty.com:8080',
            ]
          }
        });
        const resource = restApi.root.addResource('{id}')
    
        // Allow the RestApi to access DynamoDb by assigning this role to the integration
        const integrationRole = new Role(this, 'IntegrationRole', {
          assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
        })
        ddbTable.grantReadWriteData(integrationRole)
    
        // GET Integration with DynamoDb
        const dynamoQueryIntegration = new AwsIntegration({
          service: 'dynamodb',
          action: 'Query',
          options: {
            passthroughBehavior: PassthroughBehavior.WHEN_NO_TEMPLATES,
            credentialsRole: integrationRole,
            requestParameters: {
              'integration.request.path.id': 'method.request.path.id'
            },
            requestTemplates: {
              'application/json': JSON.stringify({
                  'TableName': ddbTable.tableName,
                  'KeyConditionExpression': 'pk = :v1',
                  'ExpressionAttributeValues': {
                      ':v1': {'S': "$input.params('id')"}
                  }
              }),
            },
            integrationResponses: [
              { 
                statusCode: '200',
                responseParameters: {
                  'method.response.header.Access-Control-Allow-Origin': "'*'",
                },
              }
            ],
          }
        })
        resource.addMethod('GET', dynamoQueryIntegration, {
          methodResponses: [
            { 
              statusCode: '200',
              responseParameters: {
                'method.response.header.Access-Control-Allow-Origin': true
              }
            }
          ],
          requestParameters: {
            'method.request.path.id': true
          }
        })
      }
    }
    

    This stack respond with expected header on response. enter image description here