Skip to content

Commit

Permalink
feat: Added OpenApi body integration testing and updated property bui…
Browse files Browse the repository at this point in the history
…lder (aws#5291)

* Added OpenApi body integration testing and updated property builder

* Added more test cases

* Changed tearDown to tearDownClass

* Updated JSON body parser to handle parsing errors and added unit tests

* Removed V1 references
  • Loading branch information
lucashuy authored and moelasmar committed Jun 8, 2023
1 parent 4a8d798 commit 7a129df
Show file tree
Hide file tree
Showing 7 changed files with 248 additions and 3 deletions.
36 changes: 35 additions & 1 deletion samcli/hook_packages/terraform/hooks/prepare/property_builder.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""
Terraform prepare property builder
"""
import logging
from json import loads
from json.decoder import JSONDecodeError
from typing import Any, Dict, Optional

from samcli.hook_packages.terraform.hooks.prepare.resource_linking import _resolve_resource_attribute
Expand All @@ -24,6 +27,8 @@
from samcli.lib.utils.resources import AWS_LAMBDA_FUNCTION as CFN_AWS_LAMBDA_FUNCTION
from samcli.lib.utils.resources import AWS_LAMBDA_LAYERVERSION as CFN_AWS_LAMBDA_LAYER_VERSION

LOG = logging.getLogger(__name__)

REMOTE_DUMMY_VALUE = "<<REMOTE DUMMY VALUE - RAISE ERROR IF IT IS STILL THERE>>"
TF_AWS_LAMBDA_FUNCTION = "aws_lambda_function"
TF_AWS_LAMBDA_LAYER_VERSION = "aws_lambda_layer_version"
Expand Down Expand Up @@ -211,6 +216,35 @@ def _check_image_config_value(image_config: Any) -> bool:
return True


def _get_json_body(tf_properties: dict, resource: TFResource) -> Any:
"""
Gets the JSON formatted body value from the API Gateway if there is one
Parameters
----------
tf_properties: dict
Properties of the terraform AWS Lambda function resource
resource: TFResource
Configuration terraform resource
Returns
-------
Any
Returns a dictonary if there is a valid body to parse, otherwise return original value
"""
body = tf_properties.get("body")

if isinstance(body, str):
try:
return loads(body)
except JSONDecodeError:
pass

LOG.debug(f"Failed to load JSON body for API Gateway body, returning original value: '{body}'")

return body


AWS_LAMBDA_FUNCTION_PROPERTY_BUILDER_MAPPING: PropertyBuilderMapping = {
"FunctionName": _get_property_extractor("function_name"),
"Architectures": _get_property_extractor("architectures"),
Expand All @@ -234,7 +268,7 @@ def _check_image_config_value(image_config: Any) -> bool:

AWS_API_GATEWAY_REST_API_PROPERTY_BUILDER_MAPPING: PropertyBuilderMapping = {
"Name": _get_property_extractor("name"),
"Body": _get_property_extractor("body"),
"Body": _get_json_body,
"Parameters": _get_property_extractor("parameters"),
"BinaryMediaTypes": _get_property_extractor("binary_media_types"),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _unsupported_reference_field(field: str, resource: Dict, config_resource: TF
False otherwise
"""
return bool(
not resource.get(field)
not (resource.get(field) or resource.get("values", {}).get(field))
and config_resource.attributes.get(field)
and isinstance(config_resource.attributes.get(field), References)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import shutil
import os
from pathlib import Path
from subprocess import CalledProcessError, CompletedProcess, run
from typing import Optional
from unittest import skipIf
from http.client import HTTPConnection
from parameterized import parameterized

import pytest
import requests
Expand Down Expand Up @@ -38,6 +39,48 @@ def tearDown(self) -> None:
pass


class TerraformStartApiIntegrationApplyBase(TerraformStartApiIntegrationBase):
terraform_application: str
run_command_timeout = 300

@classmethod
def setUpClass(cls):
# init terraform project to populate deploy-only values
cls._run_command(["terraform", "init", "-input=false"])
cls._run_command(["terraform", "apply", "-auto-approve", "-input=false"])

super(TerraformStartApiIntegrationApplyBase, cls).setUpClass()

@staticmethod
def get_integ_dir():
return Path(__file__).resolve().parents[2]

@classmethod
def tearDownClass(cls) -> None:
try:
cls._run_command(["terraform", "apply", "-destroy", "-auto-approve", "-input=false"])
except CalledProcessError:
# skip, command can fail here if there isn't an applied project to destroy
# (eg. failed to apply in setup)
pass

try:
os.remove(str(Path(cls.project_directory / "terraform.tfstate"))) # type: ignore
os.remove(str(Path(cls.project_directory / "terraform.tfstate.backup"))) # type: ignore
except (FileNotFoundError, PermissionError):
pass

super(TerraformStartApiIntegrationApplyBase, cls).tearDownClass()

@classmethod
def _run_command(cls, command) -> CompletedProcess:
test_data_folder = (
Path(cls.get_integ_dir()) / "testdata" / "start_api" / "terraform" / cls.terraform_application
)

return run(command, cwd=test_data_folder, check=True, capture_output=True, timeout=cls.run_command_timeout)


@skipIf(
not CI_OVERRIDE,
"Skip Terraform test cases unless running in CI",
Expand All @@ -54,3 +97,38 @@ def test_successful_request(self):

self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"message": "hello world"})


@skipIf(
not CI_OVERRIDE,
"Skip Terraform test cases unless running in CI",
)
@pytest.mark.flaky(reruns=3)
class TestStartApiTerraformApplicationOpenApiAuthorizer(TerraformStartApiIntegrationApplyBase):
terraform_application = "lambda-auth-openapi"

def setUp(self):
self.url = "http://127.0.0.1:{}".format(self.port)

@parameterized.expand(
[
("/hello", {"headers": {"myheader": "123"}}),
("/hello-request", {"headers": {"myheader": "123"}, "params": {"mystring": "456"}}),
]
)
def test_successful_request(self, endpoint, params):
response = requests.get(self.url + endpoint, timeout=300, **params)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"message": "from authorizer"})

@parameterized.expand(
[
("/hello", {"headers": {"missin": "123"}}),
("/hello-request", {"headers": {"notcorrect": "123"}, "params": {"abcde": "456"}}),
]
)
def test_missing_identity_sources(self, endpoint, params):
response = requests.get(self.url + endpoint, timeout=300, **params)

self.assertEqual(response.status_code, 401)
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
provider "aws" {}

data "aws_region" "current" {}

resource "aws_api_gateway_authorizer" "header_authorizer" {
name = "header-authorizer-open-api"
rest_api_id = aws_api_gateway_rest_api.api.id
authorizer_uri = aws_lambda_function.authorizer.invoke_arn
authorizer_credentials = aws_iam_role.invocation_role.arn
identity_source = "method.request.header.myheader"
identity_validation_expression = "^123$"
}

resource "aws_lambda_function" "authorizer" {
filename = "lambda-functions.zip"
function_name = "authorizer-open-api"
role = aws_iam_role.invocation_role.arn
handler = "handlers.auth_handler"
runtime = "python3.8"
source_code_hash = filebase64sha256("lambda-functions.zip")
}

resource "aws_lambda_function" "hello_endpoint" {
filename = "lambda-functions.zip"
function_name = "hello-lambda-open-api"
role = aws_iam_role.invocation_role.arn
handler = "handlers.hello_handler"
runtime = "python3.8"
source_code_hash = filebase64sha256("lambda-functions.zip")
}

resource "aws_api_gateway_rest_api" "api" {
name = "api-open-api"
body = jsonencode({
swagger = "2.0"
info = {
title = "api-body"
version = "1.0"
}
securityDefinitions = {
TokenAuthorizer = {
type = "apiKey"
in = "header"
name = "myheader"
x-amazon-apigateway-authtype = "custom"
x-amazon-apigateway-authorizer = {
type = "TOKEN"
authorizerUri = "arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.authorizer.arn}/invocations"
}
}
RequestAuthorizer = {
type = "apiKey"
in = "unused"
name = "unused"
x-amazon-apigateway-authtype = "custom"
x-amazon-apigateway-authorizer = {
type = "REQUEST"
identitySource = "method.request.header.myheader, method.request.querystring.mystring"
authorizerUri = "arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.authorizer.arn}/invocations"
}
}
}
paths = {
"/hello" = {
get = {
security = [
{TokenAuthorizer = []}
]
x-amazon-apigateway-integration = {
httpMethod = "GET"
payloadFormatVersion = "1.0"
type = "AWS_PROXY"
uri = "arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.hello_endpoint.arn}/invocations"
}
}
}
"/hello-request" = {
get = {
security = [
{RequestAuthorizer = []}
]
x-amazon-apigateway-integration = {
httpMethod = "GET"
payloadFormatVersion = "1.0"
type = "AWS_PROXY"
uri = "arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.hello_endpoint.arn}/invocations"
}
}
}
}
})
}

resource "aws_iam_role" "invocation_role" {
name = "iam-lambda-open-api"
path = "/"
assume_role_policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Effect": "Allow",
"Sid": ""
}
]
}
EOF
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def test_validate_invalid(self, mock_unsupported_reference_field):
@parameterized.expand(
[
({"field": "a"}, TFResource("address", "", Mock(), {}), False),
({"values": {"field": "a"}}, TFResource("address", "", Mock(), {}), False),
({}, TFResource("address", "", Mock(), {"field": ConstantValue("a")}), False),
({}, TFResource("address", "", Mock(), {"field": References(["a"])}), True),
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Test Terraform property builder"""
import json
from unittest.mock import patch, Mock, call
from parameterized import parameterized

from samcli.hook_packages.terraform.hooks.prepare.property_builder import (
_build_code_property,
REMOTE_DUMMY_VALUE,
_get_json_body,
_get_property_extractor,
_build_lambda_function_environment_property,
_build_lambda_function_image_config_property,
Expand Down Expand Up @@ -194,3 +196,21 @@ def test_check_image_config_value_invalid_length(self):
f"aws_lambda_function resource in the terraform plan output, but there are {len(image_config)} items"
with self.assertRaises(PrepareHookException, msg=expected_message):
_check_image_config_value(image_config)

def test_get_json_body(self):
body_object = {"foo": "bar"}

result = _get_json_body({"body": json.dumps(body_object)}, Mock())

self.assertEqual(result, body_object)

@parameterized.expand(
[
(Mock(),), # wrong type
("not valid json",), # not valid json
]
)
def test_get_json_body_invalid(self, invalid_value):
result = _get_json_body({"body": invalid_value}, Mock())

self.assertEqual(result, invalid_value)

0 comments on commit 7a129df

Please sign in to comment.