├── LICENSE ├── README.md ├── awsapigatewayupsert.py ├── definition-template ├── lambda-awsproxy-any-route-cors.json ├── lambda-awsproxy-classic-any-cors.json └── lambda-awsproxy-get-root-catchall.json └── requirements.txt /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Peter Mescalchin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS API Gateway upsert 2 | 3 | Command line utility for upserting (create if not exist, otherwise update) [AWS API Gateway](https://aws.amazon.com/api-gateway/) instances from [Swagger 2.0](https://swagger.io/specification/) JSON definitions. 4 | 5 | - [Features](#features) 6 | - [Requires](#requires) 7 | - [Usage](#usage) 8 | - [Lambda function policy creation](#lambda-function-policy-creation) 9 | - [Lambda function generic format ARNs](#lambda-function-generic-format-arns) 10 | - [Examples](#examples) 11 | - [Definition templates](#definition-templates) 12 | 13 | ## Features 14 | 15 | - Ability to create, update or export API Gateway instances from/to JSON Swagger definition files. 16 | - With API updates, automatically compares current to proposed definition - only deploying when differences are detected. Useful for continuous delivery pipelines and avoiding creation of duplicated API Gateway deployments. 17 | - Lambda functions referenced as integration targets can optionally have permissions [updated during the upsert process](#lambda-function-policy-creation), enabling invoke access from the API Gateway instance. This includes support for [September 2016 feature additions](https://aws.amazon.com/blogs/aws/api-gateway-update-new-features-simplify-api-development/) of: 18 | - Catch-all path variables. 19 | - HTTP pseudo `ANY` method. 20 | - Definition referenced Lambda function integration ARNs can be written in a generic format, without specifying AWS region and account ID - improving portability. 21 | - Upsert operations can be executed in a dry run mode, for verifying operations that would be applied to the target AWS account. 22 | 23 | ## Requires 24 | 25 | - Python 2.7.x. 26 | - [Boto 3](https://boto3.readthedocs.io/en/latest/). 27 | 28 | ```sh 29 | $ pip install -r requirements.txt 30 | ``` 31 | 32 | ## Usage 33 | 34 | ``` 35 | usage: awsapigatewayupsert.py [-h] --region REGION --api-name NAME --api-stage 36 | NAME [--export-file-json FILE] 37 | [--generic-lambda-integration-uri] 38 | [--upsert-file-json FILE] 39 | [--apply-lambda-permissions {exclusive,inclusive}] 40 | [--dry-run] [--quiet] 41 | 42 | Creates or updates an AWS API Gateway instance by API name from a JSON Swagger 43 | 2.0 definition. Can also export an active API Gateway instance back to 44 | definition file. 45 | 46 | optional arguments: 47 | -h, --help show this help message and exit 48 | --region REGION AWS target region 49 | --api-name NAME API Gateway name for upsert or export 50 | --api-stage NAME API stage name for upsert or export 51 | --export-file-json FILE 52 | Export API Gateway definition to the given file 53 | --generic-lambda-integration-uri 54 | Exported definition will have Lambda function 55 | integration URIs converted to a generic format 56 | --upsert-file-json FILE 57 | Definition file to create/update an API Gateway 58 | instance from 59 | --apply-lambda-permissions {exclusive,inclusive} 60 | Update Lambda function policies referenced by API 61 | definition to enable invoke actions. The 'exclusive' 62 | mode removes policies unrelated to gateway instance, 63 | 'inclusive' will preserve such policies. 64 | --dry-run Display what would happen during API definition 65 | upsert, without committing changes 66 | --quiet Suppress output during export/upsert progress 67 | ``` 68 | 69 | ### Lambda function policy creation 70 | 71 | For an API Gateway instance to successfully [invoke a Lambda function](https://docs.aws.amazon.com/lambda/latest/dg/with-on-demand-https.html), permissions allowing the gateway are required against the function itself. 72 | 73 | During the upsert of a definition, integration Lambda targets within the current account/region can have policies managed via `--apply-lambda-permissions` to complement gateway requirements: 74 | 75 | - The `exclusive` mode will **remove all** API Gateway related permissions from Lambda functions that are not associated to the upserted API. Use this mode when referenced Lambda functions are used by a *single* API Gateway instance only. 76 | - Alternatively `inclusive` mode will **retain** all permissions unrelated to the current API Gateway instance - only removing what is not directly required by the current upsert API. Use this mode when Lambda functions have dependency on *multiple* API Gateway endpoints. 77 | 78 | Some format examples of generated Lambda function permissions: 79 | 80 | | HTTP method | URI path | Generated permission | 81 | |:------------|:---------------------|:----------------------------------------------------------------| 82 | | `GET` | `/` | `arn:aws:execute-api:REGION:ACCOUNT_ID:API_ID/*/GET/` | 83 | | `POST` | `/api/path` | `arn:aws:execute-api:REGION:ACCOUNT_ID:API_ID/*/POST/api/path` | 84 | | `ANY` * | `/api/path` | `arn:aws:execute-api:REGION:ACCOUNT_ID:API_ID/*/*/api/path` | 85 | | `GET` | `/api/path/{proxy+}` | `arn:aws:execute-api:REGION:ACCOUNT_ID:API_ID/*/GET/api/path/*` | 86 | | `ANY` * | `/api/path/{proxy+}` | `arn:aws:execute-api:REGION:ACCOUNT_ID:API_ID/*/*/api/path/*` | 87 | 88 | **Note:** 89 | 90 | - The `ANY` pseudo method is represented as `x-amazon-apigateway-any-method` within Swagger definitions. 91 | - Policies assigned to a Lambda function can be view via the AWS CLI [`lambda get-policy`](https://docs.aws.amazon.com/cli/latest/reference/lambda/get-policy.html) command. 92 | 93 | ### Lambda function generic format ARNs 94 | 95 | Lambda function ARN integration points referenced in definitions can be written in a generic format, where the AWS region and account ID are not specified. 96 | 97 | For example: 98 | 99 | ``` 100 | arn:aws:apigateway:ap-southeast-2:lambda:path/2015-03-31/functions/arn:aws:lambda:ap-southeast-2:123456789012:function:lambda-function/invocations 101 | ``` 102 | 103 | becomes: 104 | 105 | ``` 106 | arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:lambda-function/invocations 107 | ``` 108 | 109 | - During upsert operations, generic format ARNs will be detected and expanded on the fly before further API processing continues. 110 | - The export definition mode also supports writing back generic format Lambda function ARNs through use of the `--generic-lambda-integration-uri` argument. 111 | 112 | ## Examples 113 | 114 | Upsert definition JSON file `/path/to/definition.json` to an API named `my-first-api` within AWS region `ap-southeast-2` deployed to stage named `production`: 115 | 116 | ```sh 117 | $ ./awsapigatewayupsert.py \ 118 | --region ap-southeast-2 \ 119 | --api-name my-first-api \ 120 | --api-stage production \ 121 | --upsert-file-json /path/to/definition.json 122 | ``` 123 | 124 | - Upsert definition `/path/to/definition.json` to an API `my-second-api` deployed at stage `development`. 125 | - Lambda functions referenced within definition will have policy permissions added/removed to complement that of the definition. 126 | - In addition, any Lambda permissions not associated to `my-second-api` will be removed: 127 | 128 | ```sh 129 | $ ./awsapigatewayupsert.py \ 130 | --region ap-southeast-2 \ 131 | --api-name my-second-api \ 132 | --api-stage development \ 133 | --upsert-file-json /path/to/definition.json \ 134 | --apply-lambda-permissions exclusive 135 | ``` 136 | 137 | - Export API named `my-first-api` deployed at stage `production` to a definition Swagger 2.0 JSON file `/path/to/definition.json`. 138 | - In addition, any Lambda function referenced integration URIs will be converted to generic form ARNs: 139 | 140 | ```sh 141 | $ ./awsapigatewayupsert.py \ 142 | --region ap-southeast-2 \ 143 | --api-name my-first-api \ 144 | --api-stage production \ 145 | --export-file-json /path/to/definition.json \ 146 | --generic-lambda-integration-uri 147 | ``` 148 | 149 | ## Definition templates 150 | 151 | Example JSON Swagger 2.0 API Gateway templates for common implementation patterns: 152 | 153 | - [`lambda-awsproxy-any-route-cors.json`](definition-template/lambda-awsproxy-any-route-cors.json) implements: 154 | - Request paths of both `/method` and `/method/*` ([catch-all](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html#api-gateway-proxy-resource) path) with upstream Lambda function targets via the `aws_proxy` integration type. 155 | - Accepting any HTTP method verb via the pseduo `ANY` / `x-amazon-apigateway-any-method` method. 156 | - [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) to match, allowing any requesting domain (via `*` wildcard). 157 | - [`lambda-awsproxy-classic-any-cors.json`](definition-template/lambda-awsproxy-classic-any-cors.json) implements: 158 | - Request paths of `/awsproxy` and `/classic` with Lambda function upstream targets via `aws_proxy` and `aws` (classic) integration systems respectively. 159 | - Accepting any HTTP method verb via the pseduo `ANY` / `x-amazon-apigateway-any-method` method. 160 | - [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) to match, allowing any requesting domain (via `*` wildcard). 161 | - [`lambda-awsproxy-get-root-catchall.json`](definition-template/lambda-awsproxy-get-root-catchall.json) implements: 162 | - Request paths of `/` and `/*` ([catch-all](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html#api-gateway-proxy-resource) path) with upstream target of a single Lambda functions via the `aws_proxy` integration type. 163 | - Any URI under the root for `GET` HTTP method requests and routed to the target Lambda function. 164 | -------------------------------------------------------------------------------- /awsapigatewayupsert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import copy 5 | import json 6 | import os.path 7 | import random 8 | import re 9 | import string 10 | import sys 11 | import boto3 12 | import botocore 13 | 14 | REST_API_GET_FETCH_COUNT_LIMIT = 500 15 | DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S %z' 16 | JSON_INDENT_SIZE = 4 17 | JSON_INDENT_REGEXP = re.compile(r'^( +)(.*)') 18 | 19 | API_GATEWAY_PRINCIPAL_SERVICE = 'apigateway.amazonaws.com' 20 | API_GATEWAY_ID_IDENTIFIER = (10,string.digits + string.ascii_lowercase) # [0-9a-z] 21 | API_GATEWAY_EXPORT_TYPE = 'swagger' 22 | API_GATEWAY_EXPORT_PROPERTY_COLLECTION = { 'extensions': 'integrations' } 23 | API_GATEWAY_EXPORT_CONTENT_TYPE = 'application/json' 24 | 25 | # note: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html#api-gateway-proxy-resource 26 | API_GATEWAY_DEFINITION_RESOURCE_PATH_REGEXP = re.compile( 27 | r'^(?:' 28 | # root URI followed by optional catch all (greedy path variable) 29 | r'(?P/)(?P\{[a-zA-Z0-9._-]+\+\})?|' 30 | # ...or, one or more resource path parts [/PART_NAME] followed by optional catch all 31 | r'(?P(?:/[a-zA-Z0-9._-]+)+)(?:/(?P\{[a-zA-Z0-9._-]+\+\}))?' 32 | r')$' 33 | ) 34 | 35 | API_GATEWAY_DEFINITION_HTTP_ANY_METHOD = 'x-amazon-apigateway-any-method' 36 | API_GATEWAY_DEFINITION_VALID_HTTP_METHOD_SET = { 37 | 'delete','get','head','options','patch','post','put', 38 | API_GATEWAY_DEFINITION_HTTP_ANY_METHOD 39 | } 40 | 41 | API_GATEWAY_DEFINITION_INTEGRATION_PROPERTY = 'x-amazon-apigateway-integration' 42 | API_GATEWAY_DEFINITION_INTEGRATION_TYPE_SET = { 'aws','aws_proxy' } 43 | 44 | API_GATEWAY_DEFINITION_PUT_MODE = 'overwrite' 45 | API_GATEWAY_DEFINITION_STRUCT_PATH_IGNORE_LIST = [ 46 | ['basePath'], 47 | ['host'], 48 | ['info','version'] 49 | ] 50 | 51 | API_GATEWAY_LAMBDA_URI_ARN_REGEXP = re.compile( 52 | r'^arn:aws:apigateway:[a-z]{2}-[a-z]{4,}-[0-9]:lambda:' 53 | r'path/(?P[0-9]{4}-[0-9]{2}-[0-9]{2})/' 54 | r'functions/(?Parn:aws:lambda:[a-z]{2}-[a-z]{4,}-[0-9]:[0-9]+:' 55 | r'function:(?P[^ /]+))/invocations$' 56 | ) 57 | 58 | API_GATEWAY_LAMBDA_URI_ARN_GENERIC_REGEXP = re.compile( 59 | r'^arn:aws:apigateway::lambda:' 60 | r'path/(?P[0-9]{4}-[0-9]{2}-[0-9]{2})/' 61 | r'functions/arn:aws:lambda:::' 62 | r'function:(?P[^ /]+)/invocations$' 63 | ) 64 | 65 | LAMBDA_ARN_PROPERTY_REGEXP = re.compile( 66 | r'^arn:aws:lambda:' 67 | r'(?P[a-z]{2}-[a-z]{4,}-[0-9]):' 68 | r'(?P[0-9]+):function:[^ /]+$' 69 | ) 70 | 71 | LAMBDA_POLICY_ACTION_INVOKE = 'lambda:InvokeFunction' 72 | LAMBDA_POLICY_PRINCIPAL_SERVICE = 'apigateway.amazonaws.com' 73 | # note: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-control-access-using-iam-policies-to-invoke-api.html 74 | LAMBDA_POLICY_API_GATEWAY_SOURCE_ARN_REGEXP = re.compile( 75 | r'^arn:aws:execute-api:' 76 | r'(?P[a-z]{2}-[a-z]{4,}-[0-9]):' 77 | r'(?P[0-9]+):' 78 | r'(?P[a-z0-9]{10})/\*/' 79 | r'(?P[^ /]+)' 80 | # resource URI character class of [a-zA-Z0-9._-] validated via API Gateway admin console 81 | # matching either root [/], or one or more resource path parts [/PART_NAME] followed by optional catch all [*] 82 | r'(?P/\*?|(?:/[a-zA-Z0-9._-]+)+(?:/\*)?)$' 83 | ) 84 | 85 | LAMBDA_POLICY_APPLY_PERMISSIONS_EXCLUSIVE = 'exclusive' 86 | LAMBDA_POLICY_APPLY_PERMISSIONS_INCLUSIVE = 'inclusive' 87 | LAMBDA_POLICY_STATEMENT_ID_IDENTIFIER = (32,string.hexdigits[:16]) # [0-9a-f] 88 | 89 | 90 | class Console: 91 | verbose = False 92 | 93 | def exit_error(self,message): 94 | sys.stderr.write('Error: {0}\n'.format(message)) 95 | sys.exit(1) 96 | 97 | def write_info(self,message = ''): 98 | if (Console.verbose): 99 | print(message) 100 | 101 | def write_notice(self,message): 102 | self.write_info('Notice: {0}'.format(message)) 103 | 104 | def json_struct_compare(struct_a,struct_b,path_ignore_list = []): 105 | def get_type(item): 106 | # treat unicode type as string 107 | item_type = type(item) 108 | return str if (item_type is unicode) else item_type 109 | 110 | def compare(data_a,data_b,struct_path = [],struct_path_part = None): 111 | def get_updated_struct_path(): 112 | if (struct_path_part is not None): 113 | # copy and update struct path 114 | return list(struct_path) + [struct_path_part] 115 | 116 | # no update to path 117 | return struct_path 118 | 119 | # type: list 120 | if (type(data_a) is list): 121 | # [data_b] a list and of same length as [data_a]? 122 | if ( 123 | (type(data_b) is not list) or 124 | (len(data_a) != len(data_b)) 125 | ): 126 | return False 127 | 128 | # iterate over list items 129 | struct_path = get_updated_struct_path() 130 | for list_index,list_item in enumerate(data_a): 131 | # compare [data_a] against [data_b] at list index 132 | if (not compare(list_item,data_b[list_index],struct_path,list_index)): 133 | return False 134 | 135 | # list identical 136 | return True 137 | 138 | # type: dictionary 139 | if (type(data_a) is dict): 140 | # is [data_b] a dictionary? 141 | if (type(data_b) is not dict): 142 | return False 143 | 144 | # iterate over dictionary keys 145 | struct_path = get_updated_struct_path() 146 | for dict_key,dict_value in data_a.items(): 147 | dict_key = str(dict_key) # normalize unicode 148 | 149 | # skip check of dictionary key if struct path is in ignore list 150 | if ((struct_path + [dict_key]) in path_ignore_list): 151 | continue 152 | 153 | # key exists in [data_b] dictionary, and same value? 154 | if ( 155 | (dict_key not in data_b) or 156 | (not compare(dict_value,data_b[dict_key],struct_path,dict_key)) 157 | ): 158 | return False 159 | 160 | # dictionary identical 161 | return True 162 | 163 | # simple value - compare both value and type for equality 164 | return ( 165 | (data_a == data_b) and 166 | (get_type(data_a) is get_type(data_b)) 167 | ) 168 | 169 | # compare a to b, then b to a 170 | return ( 171 | compare(struct_a,struct_b) and 172 | compare(struct_b,struct_a) 173 | ) 174 | 175 | def read_arguments(): 176 | console = Console() 177 | 178 | # create parser 179 | parser = argparse.ArgumentParser( 180 | description = 181 | 'Creates or updates an AWS API Gateway instance by API name from a JSON Swagger 2.0 definition. ' 182 | 'Can also export an active API Gateway instance back to definition file.' 183 | ) 184 | 185 | parser.add_argument( 186 | '--region', 187 | help = 'AWS target region', 188 | required = True 189 | ) 190 | 191 | parser.add_argument( 192 | '--api-name', 193 | help = 'API Gateway name for upsert or export', 194 | metavar = 'NAME', 195 | required = True 196 | ) 197 | 198 | parser.add_argument( 199 | '--api-stage', 200 | help = 'API stage name for upsert or export', 201 | metavar = 'NAME', 202 | required = True 203 | ) 204 | 205 | parser.add_argument( 206 | '--export-file-json', 207 | help = 'export API Gateway definition to the given file', 208 | metavar = 'FILE' 209 | ) 210 | 211 | parser.add_argument( 212 | '--generic-lambda-integration-uri', 213 | action = 'store_true', 214 | help = 'exported definition will have Lambda function integration URIs converted to a generic format' 215 | ) 216 | 217 | parser.add_argument( 218 | '--upsert-file-json', 219 | help = 'definition file to create/update an API Gateway instance from', 220 | metavar = 'FILE' 221 | ) 222 | 223 | parser.add_argument( 224 | '--apply-lambda-permissions', 225 | choices = { 226 | LAMBDA_POLICY_APPLY_PERMISSIONS_EXCLUSIVE, 227 | LAMBDA_POLICY_APPLY_PERMISSIONS_INCLUSIVE 228 | }, 229 | help = 230 | 'update Lambda function policies referenced by API definition to enable invoke actions, ' 231 | 'where \'exclusive\' mode removes policies unrelated to gateway instance, \'inclusive\' will preserve' 232 | ) 233 | 234 | parser.add_argument( 235 | '--dry-run', 236 | action = 'store_true', 237 | help = 'display what would happen during API definition upsert, without committing changes' 238 | ) 239 | 240 | parser.add_argument( 241 | '--quiet', 242 | action = 'store_true', 243 | help = 'suppress output during export/upsert progress' 244 | ) 245 | 246 | arg_list = parser.parse_args() 247 | 248 | # validate AWS region 249 | if (not re.search(r'^[a-z]{2}-[a-z]{4,}-[0-9]$',arg_list.region)): 250 | console.exit_error('Invalid AWS target region format [{0}]'.format(arg_list.region)) 251 | 252 | # validate naming format of API name and stage name 253 | if (not re.search(r'^[A-Za-z0-9][A-Za-z0-9-_]+[A-Za-z0-9]$',arg_list.api_name)): 254 | console.exit_error('Invalid API Gateway name [{0}]'.format(arg_list.api_name)) 255 | 256 | if (not re.search(r'^[A-Za-z0-9][A-Za-z0-9_]+[A-Za-z0-9]$',arg_list.api_stage)): 257 | console.exit_error('Invalid API Gateway stage name [{0}]'.format(arg_list.api_stage)) 258 | 259 | # confirm one of [--export-file-json] or [--upsert-file-json] is given 260 | if ((arg_list.export_file_json is None) and (arg_list.upsert_file_json is None)): 261 | console.exit_error('Specify one of --export-file-json or --upsert-file-json') 262 | 263 | upsert_mode = False 264 | file_path_json = arg_list.export_file_json 265 | if (arg_list.upsert_file_json is not None): 266 | # ensure file exists 267 | if (not os.path.isfile(arg_list.upsert_file_json)): 268 | console.exit_error('Unable to open [{0}] for API Gateway upsert'.format(arg_list.upsert_file_json)) 269 | 270 | # file exists - get canonical path 271 | file_path_json = os.path.realpath(arg_list.upsert_file_json) 272 | upsert_mode = True 273 | 274 | # confirm if [--export-generic-lambda-arn-uri] enabled that we are in export definition mode 275 | if (arg_list.generic_lambda_integration_uri and upsert_mode): 276 | console.exit_error('Generic Lambda function integration URI mode only valid during API definition export') 277 | 278 | # confirm if [--apply-lambda-permissions] specified that we are in upsert definition mode 279 | if ((arg_list.apply_lambda_permissions is not None) and (not upsert_mode)): 280 | console.exit_error('Apply Lambda permissions option only valid with upsert mode') 281 | 282 | # confirm if [--dry-run] mode that we are in upsert definition mode 283 | if (arg_list.dry_run and (not upsert_mode)): 284 | console.exit_error('Dry run option only valid for upsert mode') 285 | 286 | # return arguments 287 | return ( 288 | arg_list.region, 289 | arg_list.api_name,arg_list.api_stage, 290 | upsert_mode,file_path_json, 291 | arg_list.generic_lambda_integration_uri, 292 | arg_list.apply_lambda_permissions, 293 | arg_list.dry_run, 294 | not arg_list.quiet 295 | ) 296 | 297 | class LambdaAccess: 298 | aws_target_region = None 299 | 300 | _client = None 301 | _function_collection = None 302 | 303 | def get_client(self): 304 | # return existing Lambda API client instance, or create new 305 | if (not LambdaAccess._client): 306 | LambdaAccess._client = boto3.client( 307 | 'lambda', 308 | region_name = LambdaAccess.aws_target_region 309 | ) 310 | 311 | return LambdaAccess._client 312 | 313 | def get_function_collection(self): 314 | # if no function list - build now 315 | if (LambdaAccess._function_collection is None): 316 | Console().write_info('Fetching Lambda function list\n') 317 | LambdaAccess._function_collection = {} 318 | 319 | # fetch all Lambda functions for account/region - handling pagination if required over multiple API calls 320 | fetch_marker = None 321 | while (True): 322 | request_args = {} 323 | if (fetch_marker): 324 | request_args['Marker'] = fetch_marker 325 | 326 | list_response = self.get_client().list_functions(**request_args) 327 | 328 | if ('Functions' in list_response): 329 | # add each function to collection - keyed by ARN 330 | for function_item in list_response['Functions']: 331 | # extract Lambda properties from ARN 332 | function_arn = str(function_item['FunctionArn']) 333 | arn_match = LAMBDA_ARN_PROPERTY_REGEXP.search(function_arn) 334 | 335 | if (arn_match): 336 | # add function and extracted ARN details to collection 337 | LambdaAccess._function_collection[function_arn] = { 338 | 'name': str(function_item['FunctionName']), 339 | 'region': arn_match.group('region'), 340 | 'account_id': arn_match.group('account_id') 341 | } 342 | 343 | if ('NextMarker' not in list_response): 344 | break # no more functions 345 | 346 | fetch_marker = list_response['NextMarker'] 347 | 348 | return LambdaAccess._function_collection 349 | 350 | def get_api_export_from_api_id_stage_name(api_gateway_client,api_id,api_stage_name): 351 | response_get_export = api_gateway_client.get_export( 352 | restApiId = api_id, 353 | stageName = api_stage_name, 354 | exportType = API_GATEWAY_EXPORT_TYPE, 355 | parameters = API_GATEWAY_EXPORT_PROPERTY_COLLECTION, 356 | accepts = API_GATEWAY_EXPORT_CONTENT_TYPE 357 | ) 358 | 359 | # return as Python structure 360 | return json.loads( 361 | response_get_export['body'].read() 362 | ) 363 | 364 | def get_api_data_from_name_stage(api_gateway_client,api_name,api_stage_name): 365 | console = Console() 366 | 367 | api_id = None 368 | api_definition = None 369 | 370 | console.write_info('Retrieving API Gateway [{0}] at stage [{1}]\n'.format(api_name,api_stage_name)) 371 | 372 | # fetch API Gateway REST API collection 373 | rest_api_list = api_gateway_client.get_rest_apis( 374 | limit = REST_API_GET_FETCH_COUNT_LIMIT 375 | ) 376 | 377 | # find our requested API ID by name 378 | # note: ensure name is found only once - otherwise can't guarantee working with correct API 379 | for rest_api_item in rest_api_list['items']: 380 | if (rest_api_item['name'] != api_name): 381 | # skip item 382 | continue 383 | 384 | if (api_id): 385 | # have already found a REST API with this exact name - can't proceed 386 | console.exit_error('Unable to safely reference [{0}], as more than one API shares this name'.format(api_name)) 387 | 388 | # save API ID 389 | api_id = str(rest_api_item['id']) 390 | 391 | if (not api_id): 392 | # no API found for requested name 393 | console.write_info('Unable to locate active API named [{0}]\n'.format(api_name)) 394 | 395 | else: 396 | # API by name found, continue on 397 | console.write_info('API with ID [{0}] located'.format(api_id)) 398 | 399 | # fetch list of stages defined for API 400 | stage_list = api_gateway_client.get_stages( 401 | restApiId = api_id 402 | ) 403 | 404 | # attempt to locate requested stage from list returned 405 | for stage_item in stage_list['item']: 406 | if (stage_item['stageName'] != api_stage_name): 407 | # skip stage 408 | continue 409 | 410 | # found stage name match for API 411 | console.write_info('Stage [{0}] found, last updated at [{1}]\n'.format( 412 | api_stage_name, 413 | stage_item['lastUpdatedDate'].strftime(DATETIME_FORMAT) 414 | )) 415 | 416 | # fetch definition export from API stage and exit loop 417 | api_definition = get_api_export_from_api_id_stage_name( 418 | api_gateway_client, 419 | api_id,api_stage_name 420 | ) 421 | 422 | break 423 | 424 | if (not api_definition): 425 | # found the API we seek, but not the stage name 426 | console.write_info('Unable to locate stage of [{0}]\n'.format(api_stage_name)) 427 | 428 | # return API ID and definition data 429 | return api_id,api_definition 430 | 431 | def traverse_api_definition_integration_uri(api_definition,handler): 432 | def get_resource_path_uri_parts(path_uri): 433 | path_match = API_GATEWAY_DEFINITION_RESOURCE_PATH_REGEXP.search(str(path_uri)) 434 | 435 | if (path_match): 436 | if (path_match.group('root_path_uri')): 437 | # root path 438 | return path_match.group('root_path_uri','root_catch_all') 439 | 440 | # sub-level resource path 441 | return path_match.group('path_uri','catch_all') 442 | 443 | # unable to parse resource path (this is bad) 444 | return None 445 | 446 | # definition must have top level [paths] property containing a dictionary - else exit 447 | if ( 448 | ('paths' not in api_definition) or 449 | (type(api_definition['paths']) is not dict) 450 | ): 451 | return 452 | 453 | # traverse paths 454 | for path_uri,path_definition in api_definition['paths'].items(): 455 | # path definitions must be of type dictionary - otherwise skip 456 | if (type(path_definition) is not dict): 457 | continue 458 | 459 | # parse resource path URI 460 | resource_path_uri_parts = get_resource_path_uri_parts(path_uri) 461 | if (not resource_path_uri_parts): 462 | # invalid path URI - skip 463 | continue 464 | 465 | for http_method,method_definition in path_definition.items(): 466 | # lower-case and verify [http_method] is valid 467 | http_method = str(http_method.strip().lower()) 468 | if (http_method not in API_GATEWAY_DEFINITION_VALID_HTTP_METHOD_SET): 469 | continue 470 | 471 | # only interested in integration type 'aws'/'aws_proxy' with a 'uri' target 472 | if ( 473 | (API_GATEWAY_DEFINITION_INTEGRATION_PROPERTY not in method_definition) or 474 | (type(method_definition[API_GATEWAY_DEFINITION_INTEGRATION_PROPERTY]) is not dict) or 475 | ('uri' not in method_definition[API_GATEWAY_DEFINITION_INTEGRATION_PROPERTY]) or ( 476 | method_definition[API_GATEWAY_DEFINITION_INTEGRATION_PROPERTY].get('type') not in 477 | API_GATEWAY_DEFINITION_INTEGRATION_TYPE_SET 478 | ) 479 | ): 480 | continue 481 | 482 | # pass integration data to [handler] 483 | uri_transform = handler( 484 | http_method = http_method, 485 | path_uri = resource_path_uri_parts[0], 486 | path_uri_catch_all = resource_path_uri_parts[1], 487 | integration_uri = str(method_definition[API_GATEWAY_DEFINITION_INTEGRATION_PROPERTY]['uri'].strip()) 488 | ) 489 | 490 | if (uri_transform): 491 | # handler returned URI - transform definition 492 | method_definition[API_GATEWAY_DEFINITION_INTEGRATION_PROPERTY]['uri'] = uri_transform 493 | 494 | def build_identifier(identifier_spec): 495 | target_length,charset = identifier_spec 496 | charset_length = len(charset) - 1 497 | 498 | char_list = [] 499 | while (len(char_list) < target_length): 500 | # pick the next random character, add to list 501 | char_list.append(charset[random.randint(0,charset_length)]) 502 | 503 | # join characters and return new identifier 504 | return ''.join(char_list) 505 | 506 | def upsert_api_definition( 507 | api_gateway_client,api_data, 508 | api_name,api_stage_name, 509 | file_source_json_path, 510 | dry_run_mode 511 | ): 512 | console = Console() 513 | 514 | # unpack API data tuple 515 | api_id,api_definition = api_data 516 | file_source_json_name = os.path.basename(file_source_json_path) 517 | 518 | # load JSON definition source from disk to struct 519 | fp = open(file_source_json_path,'r') 520 | upsert_definition = json.load(fp) 521 | fp.close() 522 | 523 | # ensure upsert definition [info -> title] matches that of the desired API name 524 | if ( 525 | ('info' not in upsert_definition) or 526 | (type(upsert_definition['info']) is not dict) 527 | ): 528 | # can't find required property within definition 529 | console.exit_error('Unable to locate [info] property for API definition [{0}]'.format(file_source_json_name)) 530 | 531 | # set definition title to desired API name and fully qualify generic formatted Lambda ARN integration targets 532 | upsert_definition['info']['title'] = api_name 533 | upsert_api_definition_qualify_generic_lambda_uri(upsert_definition) 534 | 535 | def put_definition_and_deploy(action_type): 536 | if (not dry_run_mode): 537 | # update API with definition data import 538 | api_gateway_client.put_rest_api( 539 | restApiId = api_id, 540 | mode = API_GATEWAY_DEFINITION_PUT_MODE, 541 | failOnWarnings = False, 542 | body = json.dumps( 543 | upsert_definition, 544 | separators = (',',':') 545 | ) 546 | ) 547 | 548 | console.write_info('Imported definition [{0}]'.format(file_source_json_name)) 549 | 550 | if (not dry_run_mode): 551 | # create new/updated stage from imported definition 552 | api_gateway_client.create_deployment( 553 | restApiId = api_id, 554 | stageName = api_stage_name, 555 | cacheClusterEnabled = False 556 | ) 557 | 558 | # emit message, return deployed definition 559 | console.write_info('{0} deployment stage [{1}]\n'.format(action_type,api_stage_name)) 560 | 561 | if (dry_run_mode): 562 | # if dry run mode, just return definition source given - is sufficient for dry run checks 563 | return upsert_definition 564 | 565 | else: 566 | # return current definition - post deployment 567 | return get_api_export_from_api_id_stage_name( 568 | api_gateway_client, 569 | api_id,api_stage_name 570 | ) 571 | 572 | # existing API Gateway stage definition found? 573 | if (api_definition): 574 | # yes - compare current to proposed definition 575 | if (json_struct_compare( 576 | api_definition,upsert_definition, 577 | API_GATEWAY_DEFINITION_STRUCT_PATH_IGNORE_LIST 578 | )): 579 | # existing and proposed definitions are functionally equivalent 580 | console.write_info('API [{0}] at stage [{1}] matches given definition [{2}]\nNo deployment required\n'.format( 581 | api_name,api_stage_name, 582 | file_source_json_name 583 | )) 584 | 585 | else: 586 | # differences between current and proposed definitions 587 | console.write_info('Differences between API [{0}] at stage [{1}] and definition [{2}]'.format( 588 | api_name,api_stage_name, 589 | file_source_json_name 590 | )) 591 | 592 | # import definition and update stage 593 | api_definition = put_definition_and_deploy('Updated') 594 | 595 | else: 596 | # no stage defined - does API exist? 597 | if (not api_id): 598 | if (dry_run_mode): 599 | # generate faux API Gateway ID for dry run 600 | api_id = build_identifier(API_GATEWAY_ID_IDENTIFIER) 601 | console.write_notice('Mock API ID of [{0}] generated for dry run mode\n'.format(api_id)) 602 | 603 | else: 604 | # create new API Gateway instance 605 | response_create_api = api_gateway_client.create_rest_api(name = api_name) 606 | api_id = str(response_create_api['id']) 607 | 608 | console.write_info('Created API Gateway [{0}] with ID [{1}]'.format(api_name,api_id)) 609 | 610 | # import definition and create stage 611 | api_definition = put_definition_and_deploy('Created') 612 | 613 | # return updated API data tuple 614 | return api_id,api_definition 615 | 616 | def upsert_api_definition_qualify_generic_lambda_uri(api_definition): 617 | console = Console() 618 | 619 | class this: 620 | function_name_collection = None 621 | processed = False 622 | 623 | def get_function_name_collection(): 624 | # if no function collection defined - build now 625 | if (this.function_name_collection is None): 626 | # transform function list into function name/data tuple pairs 627 | this.function_name_collection = { 628 | function_item['name']: (function_arn,function_item['region']) 629 | for function_arn,function_item in LambdaAccess().get_function_collection().items() 630 | } 631 | 632 | return this.function_name_collection 633 | 634 | def traverse_handler(http_method,path_uri,path_uri_catch_all,integration_uri): 635 | # attempt match of generic Lambda ARN function name from integration URI 636 | lambda_match = API_GATEWAY_LAMBDA_URI_ARN_GENERIC_REGEXP.search(integration_uri) 637 | if (not lambda_match): 638 | return 639 | 640 | # fetch collection of Lambda functions - does generic function name exist? 641 | function_name = lambda_match.group('function_name') 642 | if (function_name not in get_function_name_collection()): 643 | # function not found in account/region - no transform possible 644 | return 645 | 646 | # flag a transform has occurred and fetch function data 647 | this.processed = True 648 | function_arn,function_region = get_function_name_collection()[function_name] 649 | console.write_info('Qualified upsert Lambda integration URI [{0}]'.format(function_arn)) 650 | 651 | # return qualified Lambda integration URI, updating API definition 652 | return 'arn:aws:apigateway:{0}:lambda:path/{1}/functions/{2}/invocations'.format( 653 | function_region, 654 | lambda_match.group('path_version'), 655 | function_arn 656 | ) 657 | 658 | # traverse and where possible, qualify generic Lambda URI paths 659 | traverse_api_definition_integration_uri(api_definition,traverse_handler) 660 | 661 | # if processing done, emit a blank info line 662 | if (this.processed): 663 | console.write_info() 664 | 665 | def upsert_api_definition_lambda_apply_permissions( 666 | api_data,api_exclusive_mode, 667 | dry_run_mode 668 | ): 669 | console = Console() 670 | 671 | # unpack API data tuple, create Lambda access instance 672 | api_id,api_definition = api_data 673 | lambdaAccess = LambdaAccess() 674 | 675 | # extract HTTP method/URI path and associated Lambda ARN from API definition integration URIs 676 | api_definition_method_path_lambda_set = set() 677 | 678 | def traverse_handler(http_method,path_uri,path_uri_catch_all,integration_uri): 679 | # attempt extract of Lambda ARN from integration URI 680 | lambda_match = API_GATEWAY_LAMBDA_URI_ARN_REGEXP.search(integration_uri) 681 | 682 | if (lambda_match): 683 | if (path_uri_catch_all): 684 | # resource path contains catch-all (greedy), append URI wildcard [/*] as used by Lambda function policy 685 | path_uri = '{0}{1}*'.format(path_uri,'' if (path_uri == '/') else '/') 686 | 687 | api_definition_method_path_lambda_set.add(( 688 | http_method, 689 | path_uri, 690 | lambda_match.group('arn') 691 | )) 692 | 693 | traverse_api_definition_integration_uri(api_definition,traverse_handler) 694 | 695 | # if no Lambda integrations found - then no work to do 696 | if (not api_definition_method_path_lambda_set): 697 | return 698 | 699 | # iterate Lambda functions, removing policies related to API definition not required 700 | # create set of Lambda ARN's referenced in definition 701 | api_referenced_function_arn_set = { 702 | item_function_arn 703 | for _,_,item_function_arn in api_definition_method_path_lambda_set 704 | } 705 | 706 | def get_api_gateway_function_invoke_policy_set(function_arn,region,account_id): 707 | # fetch current policy for Lambda function 708 | # note: boto will (annoyingly) throw an exception if no policy currently exists - so must catch this 709 | try: 710 | policy = lambdaAccess.get_client().get_policy(FunctionName = function_arn) 711 | 712 | except botocore.exceptions.ClientError as e: 713 | # no policies defined for Lambda 714 | return set() 715 | 716 | # found a policy - parse JSON and confirm we have a statement structure to work with 717 | policy = json.loads(policy['Policy']) 718 | if ( 719 | ('Statement' not in policy) or 720 | (type(policy['Statement']) is not list) 721 | ): 722 | return set() 723 | 724 | invoke_policy_set = set() 725 | for statement_item in policy['Statement']: 726 | # skip policy statements we aren't interested in 727 | if ( 728 | (statement_item['Action'] != LAMBDA_POLICY_ACTION_INVOKE) or 729 | ('Condition' not in statement_item) or 730 | ('ArnLike' not in statement_item['Condition']) or 731 | ('AWS:SourceArn' not in statement_item['Condition']['ArnLike']) or 732 | (statement_item['Effect'] != 'Allow') or 733 | ('Service' not in statement_item['Principal']) or 734 | (statement_item['Principal']['Service'] != LAMBDA_POLICY_PRINCIPAL_SERVICE) 735 | ): 736 | continue 737 | 738 | # parse source ARN condition - does is match required context? 739 | arn_match = LAMBDA_POLICY_API_GATEWAY_SOURCE_ARN_REGEXP.search( 740 | str(statement_item['Condition']['ArnLike']['AWS:SourceArn']) 741 | ) 742 | 743 | if (not arn_match): 744 | continue 745 | 746 | # transform HTTP method to API Gateway 'any method' if wildcard 747 | # will be easier for compare against items contained in [api_definition_method_path_lambda_set] 748 | http_method = arn_match.group('http_method').lower() 749 | if (http_method == '*'): 750 | http_method = API_GATEWAY_DEFINITION_HTTP_ANY_METHOD 751 | 752 | if ( 753 | (arn_match.group('region') == region) and 754 | (arn_match.group('account_id') == account_id) 755 | ): 756 | invoke_policy_set.add(( 757 | str(statement_item['Sid']), 758 | arn_match.group('api_id'), 759 | http_method, 760 | arn_match.group('path_uri'), 761 | )) 762 | 763 | return invoke_policy_set 764 | 765 | def retain_existing_function_policy(policy_api_id,policy_check): 766 | if (api_exclusive_mode): 767 | # retaining policies only related to API definition 768 | if (policy_api_id != api_id): 769 | return False 770 | 771 | # is related policy required by API definition? 772 | if (policy_check not in api_definition_method_path_lambda_set): 773 | return False 774 | 775 | else: 776 | # retaining policies related to other API definitions 777 | # is related policy required by API definition? 778 | if ( 779 | (policy_api_id == api_id) and 780 | (policy_check not in api_definition_method_path_lambda_set) 781 | ): 782 | return False 783 | 784 | # retain policy 785 | return True 786 | 787 | def get_policy_http_method(definition_http_method): 788 | if (definition_http_method == API_GATEWAY_DEFINITION_HTTP_ANY_METHOD): 789 | return '*' 790 | 791 | return definition_http_method.upper() 792 | 793 | function_policy_updated = False 794 | for function_arn,function_item in lambdaAccess.get_function_collection().items(): 795 | # if function not referenced in API definition - skip it 796 | if (function_arn not in api_referenced_function_arn_set): 797 | continue 798 | 799 | # fetch function invoke policies related to region, account ID and API ID 800 | invoke_policy_set = get_api_gateway_function_invoke_policy_set( 801 | function_arn, 802 | function_item['region'], 803 | function_item['account_id'] 804 | ) 805 | 806 | # check each policy to determine if still required by API definition 807 | for policy_sid,policy_api_id,policy_http_method,policy_path_uri in invoke_policy_set: 808 | policy_check = (policy_http_method,policy_path_uri,function_arn) 809 | 810 | if (retain_existing_function_policy(policy_api_id,policy_check)): 811 | # policy to remain against function 812 | if ( 813 | (policy_api_id == api_id) and 814 | (policy_check in api_definition_method_path_lambda_set) 815 | ): 816 | # remove policy from API definition referenced Lambda function set 817 | # thus won't be processed by the 'add policy' routines below 818 | api_definition_method_path_lambda_set.remove(policy_check) 819 | 820 | else: 821 | # remove policy from function 822 | if (not dry_run_mode): 823 | lambdaAccess.get_client().remove_permission( 824 | FunctionName = function_arn, 825 | StatementId = policy_sid 826 | ) 827 | 828 | console.write_info('Removed Lambda function [{0}] policy [{1}:{2}:{3}]'.format( 829 | function_item['name'], 830 | policy_api_id, 831 | get_policy_http_method(policy_http_method), 832 | policy_path_uri 833 | )) 834 | 835 | function_policy_updated = True 836 | 837 | # if removals made - emit blank line 838 | if (function_policy_updated): 839 | console.write_info() 840 | 841 | # for remaining [api_definition_method_path_lambda_set] items - add function policies 842 | function_policy_updated = False 843 | for function_http_method,function_path_uri,function_arn in api_definition_method_path_lambda_set: 844 | # if function not defined for account/region, skip it 845 | if (function_arn not in lambdaAccess.get_function_collection()): 846 | continue 847 | 848 | # add policy allowing invoke from API Gateway instance for specific HTTP method/URI path defined by [SourceArn] 849 | function_item = lambdaAccess.get_function_collection()[function_arn] 850 | policy_http_method = get_policy_http_method(function_http_method) 851 | 852 | if (not dry_run_mode): 853 | lambdaAccess.get_client().add_permission( 854 | FunctionName = function_arn, 855 | StatementId = build_identifier(LAMBDA_POLICY_STATEMENT_ID_IDENTIFIER), 856 | Action = LAMBDA_POLICY_ACTION_INVOKE, 857 | Principal = LAMBDA_POLICY_PRINCIPAL_SERVICE, 858 | SourceArn = 'arn:aws:execute-api:{0}:{1}:{2}/*/{3}{4}'.format( 859 | function_item['region'], 860 | function_item['account_id'], 861 | api_id, 862 | policy_http_method, 863 | function_path_uri 864 | ) 865 | ) 866 | 867 | console.write_info('Added Lambda function [{0}] policy [{1}:{2}:{3}]'.format( 868 | function_item['name'], 869 | api_id, 870 | policy_http_method, 871 | function_path_uri 872 | )) 873 | 874 | function_policy_updated = True 875 | 876 | # if policy additions updates made, emit blank line 877 | if (function_policy_updated): 878 | console.write_info() 879 | 880 | def export_api_definition(api_data,file_target_json_path,generic_lambda_integration_uri): 881 | console = Console() 882 | 883 | # unpack API data tuple 884 | api_id,api_definition = api_data 885 | 886 | # do we have an API definition to export/save? 887 | if (not api_definition): 888 | console.exit_error('No definition available for export') 889 | 890 | # copy definition structure, if enabled apply generic Lambda function integration URIs 891 | api_definition_export = copy.deepcopy(api_definition) 892 | 893 | if (generic_lambda_integration_uri): 894 | def traverse_handler(http_method,path_uri,path_uri_catch_all,integration_uri): 895 | # if URI matches that of a Lambda function integration, convert to generic form 896 | lambda_match = API_GATEWAY_LAMBDA_URI_ARN_REGEXP.search(integration_uri) 897 | 898 | if (lambda_match): 899 | return ( 900 | 'arn:aws:apigateway::lambda:path/{0}/functions/' 901 | 'arn:aws:lambda:::function:{1}/invocations'.format( 902 | lambda_match.group('path_version'), 903 | lambda_match.group('function_name') 904 | ) 905 | ) 906 | 907 | traverse_api_definition_integration_uri(api_definition_export,traverse_handler) 908 | 909 | # convert API definition struct data back to JSON 910 | rest_api_json = json.dumps( 911 | api_definition_export, 912 | indent = JSON_INDENT_SIZE, 913 | separators = (',',': '), 914 | sort_keys = True 915 | ) 916 | 917 | # write definition back to file with tab indents 918 | def tab_indent_json(): 919 | json_line_list = [] 920 | for json_line in rest_api_json.split('\n'): 921 | # match JSON line spaced indent 922 | indent_match = JSON_INDENT_REGEXP.search(json_line) 923 | if (indent_match): 924 | # rewrite line with tab indents in place of spaces 925 | json_line = ( 926 | ('\t' * int(len(indent_match.group(1)) / JSON_INDENT_SIZE)) + 927 | indent_match.group(2) 928 | ) 929 | 930 | json_line_list.append(json_line) 931 | 932 | # join tabbed lines back together 933 | return '\n'.join(json_line_list) 934 | 935 | fp = open(file_target_json_path,'w') 936 | fp.write(tab_indent_json() + '\n') 937 | fp.close() 938 | 939 | # finish up 940 | console.write_info('Successfully exported API definition to [{0}]\n'.format(file_target_json_path)) 941 | 942 | def display_api_invoke_uri(api_data): 943 | # unpack API data tuple 944 | api_id,api_definition = api_data 945 | 946 | # ensure we have a 'host' property 947 | if ('host' in api_definition): 948 | # API Gateway definitions with custom domain names assigned won't have 'basePath' defined 949 | base_path = '/' 950 | if ('basePath' in api_definition): 951 | base_path = api_definition['basePath'] 952 | 953 | Console().write_info('API invoke URI: https://{0}{1}'.format( 954 | api_definition['host'], 955 | base_path 956 | )) 957 | 958 | def main(): 959 | # fetch arguments 960 | ( 961 | aws_target_region, 962 | api_name,api_stage_name, 963 | upsert_mode,file_path_json, 964 | export_generic_lambda_integration_uri, 965 | apply_lambda_permission_mode, 966 | dry_run_mode, 967 | verbose_mode 968 | ) = read_arguments() 969 | 970 | # set Console verbose mode and target AWS region for Lambda access class 971 | Console.verbose = verbose_mode 972 | LambdaAccess.aws_target_region = aws_target_region 973 | 974 | console = Console() 975 | 976 | if (dry_run_mode): 977 | console.write_notice('Dry run mode enabled, additions/modifications reported but not applied to account/region\n') 978 | 979 | # create API Gateway client 980 | api_gateway_client = boto3.client( 981 | 'apigateway', 982 | region_name = aws_target_region 983 | ) 984 | 985 | # fetch requested API data from name/stage combination 986 | api_data = get_api_data_from_name_stage( 987 | api_gateway_client, 988 | api_name,api_stage_name 989 | ) 990 | 991 | if (upsert_mode): 992 | # upsert API definition from file 993 | api_data = upsert_api_definition( 994 | api_gateway_client,api_data, 995 | api_name,api_stage_name, 996 | file_path_json, 997 | dry_run_mode 998 | ) 999 | 1000 | # if apply Lambda permission mode enabled: 1001 | # - parse API definition for Lambda references 1002 | # - ensure API Gateway instance can invoke Lambda function(s) found (and update where required) 1003 | if (apply_lambda_permission_mode is not None): 1004 | upsert_api_definition_lambda_apply_permissions( 1005 | api_data, 1006 | apply_lambda_permission_mode == LAMBDA_POLICY_APPLY_PERMISSIONS_EXCLUSIVE, 1007 | dry_run_mode 1008 | ) 1009 | 1010 | else: 1011 | # export API definition to file and display public endpoint 1012 | export_api_definition( 1013 | api_data,file_path_json, 1014 | export_generic_lambda_integration_uri 1015 | ) 1016 | 1017 | # display the API invoke URI and end successfully 1018 | display_api_invoke_uri(api_data) 1019 | if (dry_run_mode): 1020 | console.write_notice('Dry run completed') 1021 | 1022 | sys.exit(0) 1023 | 1024 | 1025 | if (__name__ == '__main__'): 1026 | main() 1027 | -------------------------------------------------------------------------------- /definition-template/lambda-awsproxy-any-route-cors.json: -------------------------------------------------------------------------------- 1 | { 2 | "basePath": "/stage_name", 3 | "definitions": { 4 | "Empty": { 5 | "type": "object" 6 | } 7 | }, 8 | "host": "api_id.execute-api.region.amazonaws.com", 9 | "info": { 10 | "title": "lambda-awsproxy-any-route-cors", 11 | "version": "2016-01-01T00:00:00Z" 12 | }, 13 | "paths": { 14 | "/method": { 15 | "options": { 16 | "consumes": [ 17 | "application/json" 18 | ], 19 | "produces": [ 20 | "application/json" 21 | ], 22 | "responses": { 23 | "200": { 24 | "description": "200 response", 25 | "headers": { 26 | "Access-Control-Allow-Headers": { 27 | "type": "string" 28 | }, 29 | "Access-Control-Allow-Methods": { 30 | "type": "string" 31 | }, 32 | "Access-Control-Allow-Origin": { 33 | "type": "string" 34 | } 35 | }, 36 | "schema": { 37 | "$ref": "#/definitions/Empty" 38 | } 39 | } 40 | }, 41 | "x-amazon-apigateway-integration": { 42 | "passthroughBehavior": "when_no_match", 43 | "requestTemplates": { 44 | "application/json": "{\"statusCode\": 200}" 45 | }, 46 | "responses": { 47 | "default": { 48 | "responseParameters": { 49 | "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", 50 | "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", 51 | "method.response.header.Access-Control-Allow-Origin": "'*'" 52 | }, 53 | "statusCode": "200" 54 | } 55 | }, 56 | "type": "mock" 57 | } 58 | }, 59 | "x-amazon-apigateway-any-method": { 60 | "responses": {}, 61 | "x-amazon-apigateway-integration": { 62 | "httpMethod": "POST", 63 | "passthroughBehavior": "when_no_match", 64 | "responses": { 65 | "default": { 66 | "statusCode": "200" 67 | } 68 | }, 69 | "type": "aws_proxy", 70 | "uri": "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:example-awsproxy/invocations" 71 | } 72 | } 73 | }, 74 | "/method/{proxy+}": { 75 | "options": { 76 | "consumes": [ 77 | "application/json" 78 | ], 79 | "produces": [ 80 | "application/json" 81 | ], 82 | "responses": { 83 | "200": { 84 | "description": "200 response", 85 | "headers": { 86 | "Access-Control-Allow-Headers": { 87 | "type": "string" 88 | }, 89 | "Access-Control-Allow-Methods": { 90 | "type": "string" 91 | }, 92 | "Access-Control-Allow-Origin": { 93 | "type": "string" 94 | } 95 | }, 96 | "schema": { 97 | "$ref": "#/definitions/Empty" 98 | } 99 | } 100 | }, 101 | "x-amazon-apigateway-integration": { 102 | "passthroughBehavior": "when_no_match", 103 | "requestTemplates": { 104 | "application/json": "{\"statusCode\": 200}" 105 | }, 106 | "responses": { 107 | "default": { 108 | "responseParameters": { 109 | "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", 110 | "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", 111 | "method.response.header.Access-Control-Allow-Origin": "'*'" 112 | }, 113 | "statusCode": "200" 114 | } 115 | }, 116 | "type": "mock" 117 | } 118 | }, 119 | "x-amazon-apigateway-any-method": { 120 | "responses": {}, 121 | "x-amazon-apigateway-integration": { 122 | "httpMethod": "POST", 123 | "passthroughBehavior": "when_no_match", 124 | "responses": { 125 | "default": { 126 | "statusCode": "200" 127 | } 128 | }, 129 | "type": "aws_proxy", 130 | "uri": "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:example-awsproxy/invocations" 131 | } 132 | } 133 | } 134 | }, 135 | "schemes": [ 136 | "https" 137 | ], 138 | "swagger": "2.0" 139 | } 140 | -------------------------------------------------------------------------------- /definition-template/lambda-awsproxy-classic-any-cors.json: -------------------------------------------------------------------------------- 1 | { 2 | "basePath": "/stage_name", 3 | "definitions": { 4 | "Empty": { 5 | "type": "object" 6 | } 7 | }, 8 | "host": "api_id.execute-api.region.amazonaws.com", 9 | "info": { 10 | "title": "lambda-awsproxy-classic-any-cors", 11 | "version": "2016-01-01T00:00:00Z" 12 | }, 13 | "paths": { 14 | "/awsproxy": { 15 | "options": { 16 | "consumes": [ 17 | "application/json" 18 | ], 19 | "produces": [ 20 | "application/json" 21 | ], 22 | "responses": { 23 | "200": { 24 | "description": "200 response", 25 | "headers": { 26 | "Access-Control-Allow-Headers": { 27 | "type": "string" 28 | }, 29 | "Access-Control-Allow-Methods": { 30 | "type": "string" 31 | }, 32 | "Access-Control-Allow-Origin": { 33 | "type": "string" 34 | } 35 | }, 36 | "schema": { 37 | "$ref": "#/definitions/Empty" 38 | } 39 | } 40 | }, 41 | "x-amazon-apigateway-integration": { 42 | "passthroughBehavior": "when_no_match", 43 | "requestTemplates": { 44 | "application/json": "{\"statusCode\": 200}" 45 | }, 46 | "responses": { 47 | "default": { 48 | "responseParameters": { 49 | "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", 50 | "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", 51 | "method.response.header.Access-Control-Allow-Origin": "'*'" 52 | }, 53 | "statusCode": "200" 54 | } 55 | }, 56 | "type": "mock" 57 | } 58 | }, 59 | "x-amazon-apigateway-any-method": { 60 | "responses": {}, 61 | "x-amazon-apigateway-integration": { 62 | "httpMethod": "POST", 63 | "passthroughBehavior": "when_no_match", 64 | "responses": { 65 | "default": { 66 | "statusCode": "200" 67 | } 68 | }, 69 | "type": "aws_proxy", 70 | "uri": "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:example-awsproxy/invocations" 71 | } 72 | } 73 | }, 74 | "/classic": { 75 | "options": { 76 | "consumes": [ 77 | "application/json" 78 | ], 79 | "produces": [ 80 | "application/json" 81 | ], 82 | "responses": { 83 | "200": { 84 | "description": "200 response", 85 | "headers": { 86 | "Access-Control-Allow-Headers": { 87 | "type": "string" 88 | }, 89 | "Access-Control-Allow-Methods": { 90 | "type": "string" 91 | }, 92 | "Access-Control-Allow-Origin": { 93 | "type": "string" 94 | } 95 | }, 96 | "schema": { 97 | "$ref": "#/definitions/Empty" 98 | } 99 | } 100 | }, 101 | "x-amazon-apigateway-integration": { 102 | "passthroughBehavior": "when_no_match", 103 | "requestTemplates": { 104 | "application/json": "{\"statusCode\": 200}" 105 | }, 106 | "responses": { 107 | "default": { 108 | "responseParameters": { 109 | "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", 110 | "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", 111 | "method.response.header.Access-Control-Allow-Origin": "'*'" 112 | }, 113 | "statusCode": "200" 114 | } 115 | }, 116 | "type": "mock" 117 | } 118 | }, 119 | "x-amazon-apigateway-any-method": { 120 | "produces": [ 121 | "application/json" 122 | ], 123 | "responses": { 124 | "200": { 125 | "description": "200 response", 126 | "schema": { 127 | "$ref": "#/definitions/Empty" 128 | } 129 | } 130 | }, 131 | "x-amazon-apigateway-integration": { 132 | "httpMethod": "POST", 133 | "passthroughBehavior": "when_no_match", 134 | "responses": { 135 | "default": { 136 | "statusCode": "200" 137 | } 138 | }, 139 | "type": "aws", 140 | "uri": "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:example-classic/invocations" 141 | } 142 | } 143 | } 144 | }, 145 | "schemes": [ 146 | "https" 147 | ], 148 | "swagger": "2.0" 149 | } 150 | -------------------------------------------------------------------------------- /definition-template/lambda-awsproxy-get-root-catchall.json: -------------------------------------------------------------------------------- 1 | { 2 | "basePath": "/stage_name", 3 | "host": "api_id.execute-api.region.amazonaws.com", 4 | "info": { 5 | "title": "lambda-awsproxy-get-root-catchall", 6 | "version": "2016-01-01T00:00:00Z" 7 | }, 8 | "paths": { 9 | "/": { 10 | "get": { 11 | "responses": {}, 12 | "x-amazon-apigateway-integration": { 13 | "httpMethod": "POST", 14 | "passthroughBehavior": "when_no_match", 15 | "responses": { 16 | "default": { 17 | "statusCode": "200" 18 | } 19 | }, 20 | "type": "aws_proxy", 21 | "uri": "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:example-awsproxy/invocations" 22 | } 23 | } 24 | }, 25 | "/{proxy+}": { 26 | "get": { 27 | "responses": {}, 28 | "x-amazon-apigateway-integration": { 29 | "httpMethod": "POST", 30 | "passthroughBehavior": "when_no_match", 31 | "responses": { 32 | "default": { 33 | "statusCode": "200" 34 | } 35 | }, 36 | "type": "aws_proxy", 37 | "uri": "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:example-awsproxy/invocations" 38 | } 39 | } 40 | } 41 | }, 42 | "schemes": [ 43 | "https" 44 | ], 45 | "swagger": "2.0" 46 | } 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3>=1.4.1 2 | botocore>=1.4.70 3 | --------------------------------------------------------------------------------