Source: api-gateway.js

const assert = require('assert')
const _ = require('lodash')

const AWSComponent = require('./aws-component').AWSComponent

const Policy = require('./policy').Policy
const Lambda = require('./lambda').Lambda
const fs = require('fs')
const log = require('winston')

/**
 * This class represents an API Gateway component. 
 * By default CORS is always enabled.
 */
class ApiGateway extends AWSComponent {

  constructor(stackName, baseName) {
    super(stackName, baseName)

    this.lambdaConnections = {}
    this.swaggerJSON = {}
    this.deploymentStages = ['V1']
    this.policyStatements = [{
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams",
        "logs:PutLogEvents",
        "logs:GetLogEvents",
        "logs:FilterLogEvents"
      ],
      "Resource": "*"
    }]

    this.assumeRolePolicyDocument = {
      "Version": "2012-10-17",
      "Statement": [{
        "Effect": "Allow",
        "Principal": {
          "Service": "apigateway.amazonaws.com"
        },
        "Action": "sts:AssumeRole"
      }]
    }
  }

  /**
   * Available MIME types of the endpoint
   */
  get binaryMediaTypes() {
    return this.swaggerJSON["x-amazon-apigateway-binary-media-types"];
  }

  set binaryMediaTypes(mediaTypesArray) {
    if (_.isEmpty(mediaTypesArray)) {
      delete this.swaggerJSON["x-amazon-apigateway-binary-media-types"];
    }
    else {
      this.swaggerJSON["x-amazon-apigateway-binary-media-types"] = mediaTypesArray;
    }
  }

  /*
  API gate-way all items
  */


  /*
   * Item object is an object:
   * {
   *  methods: [get, post, etc], 
   *   path: String, path allowed, must start with '/' could be '/*'
   * }
   */
  policyStatementForAccessImpl(accessLevels, item) {

    assert.ok(accessLevels[0])
    assert.ok(item)

    const { methods, path } = item

    assert.ok(methods.length)
    assert.ok(path)

    const accessLevel = accessLevels[0]

    let statement = null;

    switch (accessLevel) {
      case ApiGateway.ACCESS_LEVEL_READ:
      case ApiGateway.ACCESS_LEVEL_WRITE:

        //Notice: currently allow access to all stages
        let resourceArray = _.map(methods, (m) => {
          return {
            "Fn::Join": ["", ["arn:aws:execute-api:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":", this.getValue('ID'), "/*/" + m.toUpperCase() + path]]
          }
        })

        statement = {
          "Effect": "Allow",
          "Action": [
            "execute-api:Invoke"
          ],
          "Resource": resourceArray
        }

        break;
      default:
        throw new Error(`${accessLevel} is not supported for APIGateway class`)
        break;
    }

    return statement
  }
  
  /**
   * Set the stages the enpoint is deployed to. I.e "alpha", "beta"
   * @param {array} stages the stages in the form of string array
   */
  setDeploymentStages(stages) {
    assert.equal(stages.constructor, Array)
    assert.ok(stages.length)

    this.deploymentStages = _.uniq(stages)
  }

  /**
   * Switch on IAM authentication for the given path and given methods. 
   * 
   * @param {string} path path of the function
   * @param {array} methods array of methods that needs to have IAM. For example: ["POST", "GET"]
   */
  setIAM(path, methods) {

    const lowercaseMethods = _.map(methods, (m) => {
      return m.toLowerCase()
    })

    /*
    Ref: 
    https://forums.aws.amazon.com/thread.jspa?threadID=236945
    http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-authorizer.html

    */
    assert.ok(this.swaggerJSON)

    this.swaggerJSON.securityDefinitions = {
      "sigv4": {
        "type": "apiKey",
        "name": "Authorization",
        "in": "header",
        "x-amazon-apigateway-authtype": "awsSigv4"
      }
    }

    //Attach integration object to the announced methods
    let methodsDict = this.swaggerJSON.paths[path]
    _.each(methodsDict, (body, existingMethod) => {
      //console.log(`Checking: ${existingMethod} against: ${lowercaseMethods}`)
      if (lowercaseMethods.indexOf(existingMethod.toLowerCase()) >= 0) {
        //console.log(`Attaching to ${path} for ${existingMethod}`)
        body.security = [{
          sigv4: []
        }]
      }
    })
  }


  setIntegrationRequestResponseMappingOptions(options) {

  }

  ///API gateway needs to following resources:

  //AWS::ApiGateway::RestApi
  //AWS::Lambda::Permission (invoke Lambda)
  //AWS::IAM::Role Role, must be allowed to visit Logs Policy
  //AWS::ApiGateway::Account
  //AWS::ApiGateway::Stage  //Declaration of stage
  //AWS::ApiGateway::Deployment //Deployment of state
  //Check SWAG definition for ApiGateway?

  //attach lambda function to this gateway

  //NOTICE: APIGateway always allow CORS

   /**
    * Attach a Lambda to this API Gateway.
    * @example 

    apiGateway.attachLambda(lambda, '/hello', ['GET'], {
        '.*Invalid parameters.*': {
        statusCode: 400
      }
    });

    * 
    * @param {Lambda} lambda the Lambda object
    * @param {string} path the path of which the Lambda should process. This path must already declared in the Swagger file. 
    * @param {array} methods an array of methods the Lambda should process. ie ['GET', 'POST']
    * @param {object} responseConfigOptions Exact format please check https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration-responses.html
    */
  attachLambda(lambda, path, methods, responseConfigOptions) {

    const defaultOption = {
      "statusCode": 200,
      "responseParameters": {
        "method.response.header.Access-Control-Allow-Origin": "'*'"
      }
    };

    if (!responseConfigOptions) {
      responseConfigOptions = {
        "default": defaultOption
      }
    }

    //Check responseConfigOptions
    const defaultResponseOptions_check_size = _.filter(responseConfigOptions, (op, key) => {
      return key == 'default';
    })

    assert.ok(defaultResponseOptions_check_size.length <= 1,
      `Expect responseConfigOptions to have one default response status code (usually 200, but could be else), found ${defaultResponseOptions_check_size.length}`);
      
    if (defaultResponseOptions_check_size.length == 0) {
      //Doesnt have a default. We add in
      responseConfigOptions['default'] = defaultOption;
    }

    _.each(responseConfigOptions, (respOption, key) => {
      assert.ok(parseInt(respOption.statusCode) > 0, 'invalid statusCode value in responseConfigOptions: ' + respOption.statusCode);
      if (!respOption.responseParameters) {
        respOption.responseParameters = {
          "method.response.header.Access-Control-Allow-Origin": "'*'"
        }
      }
    })

    const lowercaseMethods = _.map(methods, (m) => {
      return m.toLowerCase()
    })

    assert.ok(lambda instanceof Lambda)
    assert.equal(methods.constructor, Array)
    const key = lambda.fullName + path + methods.join('-');
    this.lambdaConnections[key] = { lambda, path, methods }

    assert.ok(!(_.isEmpty(this.swaggerJSON)), `Invalid SWAGGER JSON: ${JSON.stringify(this.swaggerJSON, null, '  ')}`)
    assert.ok(this.swaggerJSON.paths[path], `${path} does not exist for the specified SWAGGER JSON`)

    //Attach integration object to the announced methods
    let methodsDict = this.swaggerJSON.paths[path]
    _.each(methodsDict, (body, existingMethod) => {
      log.silly(`Checking: ${existingMethod} against: ${lowercaseMethods}`)
      if (lowercaseMethods.indexOf(existingMethod.toLowerCase()) >= 0) {
        //console.log(`Attaching to ${path} for ${existingMethod}`)

        //Modify method.responses table. Add headers spec.
        assert.ok(!_.isEmpty(body.responses), `Method ${existingMethod} for path ${path} has empty responses field, this is invalid.`);

        //For all codes, we add header spec for Access-Control-Allow-Origin
        _.each(body.responses, (responseSpec, code) => {
          responseSpec.headers = responseSpec.headers || {};
          responseSpec.headers['Access-Control-Allow-Origin'] = { type: 'string' };
        })

        const requestTransformTemplateString = fs.readFileSync(__dirname + '/request-transform-passthrough.template').toString();

        body['x-amazon-apigateway-integration'] = {

          ////// This request template will provide all info for AWS_IAM authentication went through Cognito Idendity. 
          ////// Info will be added to event.context.
          "requestTemplates": {
            ////// The value is copied from "Method Request Template" of AWS web console -> API Gateway -> Body Mapping Templates
            "application/json": requestTransformTemplateString
          },

          "passthroughBehavior": "when_no_match",
          "httpMethod": 'POST',
          "type": "aws",

          "uri": {
            "Fn::Join": [
              "", [
                "arn:aws:apigateway:",
                {
                  "Ref": "AWS::Region"
                },
                ":lambda:path/2015-03-31/functions/",
                lambda.getValue('ARN'),
                "/invocations"
              ]
            ]
          },
          
          "responses": responseConfigOptions
        }
      }
    })

    //Write CORS as Options 
    methodsDict.options = {
      "responses": {
        "200": {
          "description": "200 response",
          "headers": {
            "Access-Control-Allow-Origin": {
              "type": "string"
            },
            "Access-Control-Allow-Methods": {
              "type": "string"
            },
            "Access-Control-Allow-Headers": {
              "type": "string"
            }
          }
        }
      },

      "x-amazon-apigateway-integration": {
        "responses": {
          "default": {
            "statusCode": "200",
            "responseParameters": {
              "method.response.header.Access-Control-Allow-Methods": `'${methods.join(',')}'`,
              //Specifies accept, origin, content-type additionally for early version of iOS. Although with * they don't matter
              "method.response.header.Access-Control-Allow-Headers": "'*,accept, origin, content-type'",
              "method.response.header.Access-Control-Allow-Origin": "'*'"
            }
          }
        },
        "requestTemplates": {
          "application/json": "{\"statusCode\": 200}"
        },
        "passthroughBehavior": "when_no_match",
        "type": "mock"
      }
    }
  }

  /**
   * Set the swagger JSON specification for the API Gateway. 
   * swaggerJSON must be set before you can attach any Lambdas to this API Gateway.
   * @param {object} swaggerJSON 
   */
  setSwagger(swaggerJSON) {

    function removeUnsupportedKeys(swagger) {
      const unsupportedKeys = 'example';
      for (const prop in swagger) {
        if (unsupportedKeys.indexOf(prop) >= 0) {
          delete swagger[prop];
        }
        else if (typeof swagger[prop] === 'object') {
          removeUnsupportedKeys(swagger[prop]);
        }
      }
    }
    //Clean up: certain fields in SWAGGER are not supported by API gateway. Need to cull them out
    const copy = _.clone(swaggerJSON)
    removeUnsupportedKeys(copy);
    this.swaggerJSON = copy
    return copy;
    //console.log('Setting swagger JSON: ' + JSON.stringify(this.swaggerJSON, null, '  '))
  }

  get outputSpecs() {
    //Default: get ARN and ID
    let specs = {}
    const id_spec = `${this.APIName}ID`;
    specs[id_spec] = {
      "Description": `The ID for resource ${this.fullName} of stack ${this.stackName}`,
      "Value": { "Ref": this.APIName },
      "Export": {
        "Name": id_spec
      }
    }

    /*
     * MORE: also get stages
     */

    return specs
  }

  /**
   * The full name of this API Gateway.
   */
  get APIName() {
    return this.fullName;
  }

  get fullName() {
    return this.stackName + '' + super.fullName;
  }

  get template() {

    let template = {}

    const restApiDependsOn = _.map(this.lambdaConnections, (connection, key) => {
      return connection.lambda.fullName
    })

    template[this.APIName] = {
      "Type": "AWS::ApiGateway::RestApi",
      "DependsOn": restApiDependsOn, //Reference attached Lambda, if any
      "Properties": {
        "Name": `${this.APIName}`,
        "Description": "",
        "FailOnWarnings": true
      }
    }

    //Standard Swagger JSON declaration
    //No extension. 
    if (this.swaggerJSON) {
      template[this.APIName].Properties.Body = this.swaggerJSON
    }

    //Default role
    template = _.merge(template, this.defaultRole.template)

    //Lambda permissions(s). setup Lambda permission for each lambda attachmen

    _.each(this.lambdaConnections, (connection, key) => {
      const { lambda, path, methods } = connection
      const permissionName = `${lambda.fullName}Permission`
      const permissionTemplate = {
        "Type": "AWS::Lambda::Permission",
        "Properties": {
          "Action": "lambda:invokeFunction",
          "FunctionName": lambda.getValue('ARN'),
          "Principal": "apigateway.amazonaws.com",

          //Points to this API Gateway
          "SourceArn": {
            "Fn::Join": ["", ["arn:aws:execute-api:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":", { "Ref": this.APIName }, "/*/*" + path]]
          }
        }
      }

      template[permissionName] = permissionTemplate
    })

    //Account
    template[`${this.APIName}Account`] = {
      "Type": "AWS::ApiGateway::Account",
      "Properties": {
        //Referencing this role name
        "CloudWatchRoleArn": { "Fn::GetAtt": [this.roleName(), "Arn"] }
      }
    }

    //Deployment stage(s)
    _.each(this.deploymentStages, (stageName) => {
      template[`${this.fullName}${stageName}Stage`] = {
        "Type": "AWS::ApiGateway::Deployment",
        "DependsOn": [this.APIName], ////TODO: fix actual method declaration 
        "Properties": {
          "RestApiId": { "Ref": this.APIName },
          "StageName": stageName
        }
      }
    })

    return template
  }
}

exports.ApiGateway = ApiGateway