diff --git a/samcli/lib/providers/api_collector.py b/samcli/lib/providers/api_collector.py index bf2896040c..33c117d23e 100644 --- a/samcli/lib/providers/api_collector.py +++ b/samcli/lib/providers/api_collector.py @@ -151,7 +151,7 @@ def dedupe_function_routes(routes: List[Route]) -> List[Route]: grouped_routes: Dict[str, Route] = {} for route in routes: - key = "{}-{}-{}".format(route.stack_path, route.function_name, route.path) + key = "{}-{}-{}-{}".format(route.stack_path, route.function_name, route.path, route.operation_name or "") config = grouped_routes.get(key, None) methods = route.methods if config: diff --git a/samcli/local/apigw/local_apigw_service.py b/samcli/local/apigw/local_apigw_service.py index 79ab5a1f30..e09649af18 100644 --- a/samcli/local/apigw/local_apigw_service.py +++ b/samcli/local/apigw/local_apigw_service.py @@ -324,13 +324,20 @@ def _request_handler(self, **kwargs): route_key, ) else: + + # The OperationName is only sent to the Lambda Function from API Gateway V1(Rest API). + # For Http Apis (v2), API Gateway never sends the OperationName. + if route.event_type == Route.API: + operation_name = route.operation_name + else: + operation_name = None event = self._construct_v_1_0_event( request, self.port, self.api.binary_media_types, self.api.stage_name, self.api.stage_variables, - route.operation_name, + operation_name, ) except UnicodeDecodeError: return ServiceErrorResponses.lambda_failure_response() diff --git a/tests/integration/local/start_api/test_start_api.py b/tests/integration/local/start_api/test_start_api.py index 0ddb8d5a31..575d837351 100644 --- a/tests/integration/local/start_api/test_start_api.py +++ b/tests/integration/local/start_api/test_start_api.py @@ -664,6 +664,30 @@ def test_patch_call_with_path_setup_with_any_swagger(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"hello": "world"}) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_http_api_payload_v1_should_not_have_operation_id(self): + response = requests.get(self.url + "/httpapi-operation-id-v1", timeout=300) + self.assertEqual(response.status_code, 200) + + response_data = response.json() + self.assertEqual(response_data.get("version", {}), "1.0") + # operationName or operationId shouldn't be processed by Httpapi swaggers + self.assertIsNone(response_data.get("requestContext", {}).get("operationName")) + self.assertIsNone(response_data.get("requestContext", {}).get("operationId")) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_http_api_payload_v2_should_not_have_operation_id(self): + response = requests.get(self.url + "/httpapi-operation-id-v2", timeout=300) + self.assertEqual(response.status_code, 200) + + response_data = response.json() + self.assertEqual(response_data.get("version", {}), "2.0") + # operationName or operationId shouldn't be processed by Httpapi swaggers + self.assertIsNone(response_data.get("requestContext", {}).get("operationName")) + self.assertIsNone(response_data.get("requestContext", {}).get("operationId")) + class TestStartApiWithSwaggerRestApis(StartApiIntegBaseClass): template_path = "/testdata/start_api/swagger-rest-api-template.yaml" @@ -792,6 +816,16 @@ def test_binary_response(self): self.assertEqual(response.headers.get("Content-Type"), "image/gif") self.assertEqual(response.content, expected) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_rest_api_operation_id(self): + """ + Binary data is returned correctly + """ + response = requests.get(self.url + "/printeventwithoperationidfunction", timeout=300) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("requestContext", {}).get("operationName"), "MyOperationName") + class TestServiceResponses(StartApiIntegBaseClass): """ @@ -1691,6 +1725,17 @@ def test_http_api_is_reachable(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"hello": "world"}) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_http_api_with_operation_name_is_reachable(self): + response = requests.get(self.url + "/http-api-with-operation-name", timeout=300) + + self.assertEqual(response.status_code, 200) + response_data = response.json() + # operationName or operationId shouldn't be processed by Httpapi + self.assertIsNone(response_data.get("requestContext", {}).get("operationName")) + self.assertIsNone(response_data.get("requestContext", {}).get("operationId")) + @pytest.mark.flaky(reruns=3) @pytest.mark.timeout(timeout=600, method="thread") def test_rest_api_is_reachable(self): @@ -1699,6 +1744,13 @@ def test_rest_api_is_reachable(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"hello": "world"}) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_rest_api_with_operation_name_is_reachable(self): + response = requests.get(self.url + "/rest-api-with-operation-name", timeout=300) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"operation_name": "MyOperationName"}) + class TestCFNTemplateHttpApiWithSwaggerBody(StartApiIntegBaseClass): template_path = "/testdata/start_api/cfn-http-api-with-swagger-body.yaml" @@ -1716,6 +1768,9 @@ def test_swagger_got_parsed_and_api_is_reachable_and_payload_version_is_2(self): self.assertEqual(response_data.get("version", {}), "2.0") self.assertIsNone(response_data.get("multiValueHeaders")) self.assertIsNotNone(response_data.get("cookies")) + # operationName or operationId shouldn't be processed by Httpapi swaggers + self.assertIsNone(response_data.get("requestContext", {}).get("operationName")) + self.assertIsNone(response_data.get("requestContext", {}).get("operationId")) class TestWarmContainersBaseClass(StartApiIntegBaseClass): diff --git a/tests/integration/testdata/start_api/cfn-http-api-and-rest-api-gateways.yaml b/tests/integration/testdata/start_api/cfn-http-api-and-rest-api-gateways.yaml index dffa1873f9..ce0f4a7f58 100644 --- a/tests/integration/testdata/start_api/cfn-http-api-and-rest-api-gateways.yaml +++ b/tests/integration/testdata/start_api/cfn-http-api-and-rest-api-gateways.yaml @@ -19,6 +19,20 @@ Resources: Value: SAM Timeout: 3 Type: AWS::Lambda::Function + HelloWorldFunctionWithOperationName: + Properties: + Handler: main.operation_name_handler + Code: '.' + Role: + Fn::GetAtt: + - HelloWorldFunctionRole + - Arn + Runtime: python3.6 + Tags: + - Key: lambda:createdBy + Value: SAM + Timeout: 3 + Type: AWS::Lambda::Function HelloWorldFunctionRole: Properties: AssumeRolePolicyDocument: @@ -81,6 +95,15 @@ Resources: IntegrationMethod: POST IntegrationUri: Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations + MyIntegrationWithOperationName: + Type: 'AWS::ApiGatewayV2::Integration' + Properties: + ApiId: !Ref HTTPAPIGateway + PayloadFormatVersion: "1.0" + IntegrationType: AWS_PROXY + IntegrationMethod: POST + IntegrationUri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunctionWithOperationName.Arn}/invocations MyRoute: Type: AWS::ApiGatewayV2::Route Properties: @@ -90,6 +113,16 @@ Resources: - / - - integrations - !Ref MyIntegration + MyRouteWithOperationName: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref HTTPAPIGateway + OperationName: 'MyOperationName' + RouteKey: 'GET /http-api-with-operation-name' + Target: !Join + - / + - - integrations + - !Ref MyIntegrationWithOperationName RestApiGateway: Type: AWS::ApiGateway::RestApi Properties: @@ -141,4 +174,39 @@ Resources: - Fn::GetAtt: - HelloWorldFunction - Arn + - /invocations + RestApiGatewayWithOperationNameResource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: + Fn::GetAtt: + - RestApiGateway + - RootResourceId + PathPart: "rest-api-with-operation-name" + RestApiId: + Ref: RestApiGateway + RestApiGatewayWithOperationNameMethod: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: GET + OperationName: 'MyOperationName' + ResourceId: + Ref: RestApiGatewayWithOperationNameResource + RestApiId: + Ref: RestApiGateway + AuthorizationType: NONE + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + Uri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - HelloWorldFunctionWithOperationName + - Arn - /invocations \ No newline at end of file diff --git a/tests/integration/testdata/start_api/cfn-http-api-with-swagger-body.yaml b/tests/integration/testdata/start_api/cfn-http-api-with-swagger-body.yaml index 606d89b863..c092b2db3d 100644 --- a/tests/integration/testdata/start_api/cfn-http-api-with-swagger-body.yaml +++ b/tests/integration/testdata/start_api/cfn-http-api-with-swagger-body.yaml @@ -61,6 +61,7 @@ Resources: /echoeventbody: get: responses: {} + operationId: 'postOperationIdShouldNotBeInHttpApi' x-amazon-apigateway-integration: httpMethod: POST payloadFormatVersion: '2.0' diff --git a/tests/integration/testdata/start_api/main.py b/tests/integration/testdata/start_api/main.py index 19dc6f51c2..a742f49a4c 100644 --- a/tests/integration/testdata/start_api/main.py +++ b/tests/integration/testdata/start_api/main.py @@ -8,6 +8,9 @@ def handler(event, context): return {"statusCode": 200, "body": json.dumps({"hello": "world"})} +def operation_name_handler(event, context): + return {"statusCode": 200, "body": json.dumps({"operation_name": event["requestContext"].get("operationName", "")})} + def echo_event_handler(event, context): return {"statusCode": 200, "body": json.dumps(event)} diff --git a/tests/integration/testdata/start_api/swagger-rest-api-template.yaml b/tests/integration/testdata/start_api/swagger-rest-api-template.yaml index e28aec0f7b..c98741b7cb 100644 --- a/tests/integration/testdata/start_api/swagger-rest-api-template.yaml +++ b/tests/integration/testdata/start_api/swagger-rest-api-template.yaml @@ -72,6 +72,14 @@ Resources: type: aws_proxy uri: Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MyNonServerlessLambdaFunction.Arn}/invocations + "/printeventwithoperationidfunction": + get: + operationId: 'MyOperationName' + x-amazon-apigateway-integration: + httpMethod: POST + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${EchoEventBodyFunction.Arn}/invocations swagger: '2.0' x-amazon-apigateway-binary-media-types: - image/gif diff --git a/tests/integration/testdata/start_api/swagger-template-http-api.yaml b/tests/integration/testdata/start_api/swagger-template-http-api.yaml index 6e5d0cb756..0f62b2aad1 100644 --- a/tests/integration/testdata/start_api/swagger-template-http-api.yaml +++ b/tests/integration/testdata/start_api/swagger-template-http-api.yaml @@ -69,6 +69,26 @@ Resources: type: aws_proxy uri: Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${EchoBase64EventBodyFunction.Arn}/invocations + "/httpapi-operation-id-v1": + get: + responses: {} + operationId: 'OperationNameShouldNotAppear' + x-amazon-apigateway-integration: + httpMethod: GET + type: aws_proxy + payloadFormatVersion: '1.0' + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${EchoEventHandlerHttpApiFunction.Arn}/invocations + "/httpapi-operation-id-v2": + get: + responses: {} + operationId: 'OperationNameShouldNotAppear' + x-amazon-apigateway-integration: + httpMethod: GET + type: aws_proxy + payloadFormatVersion: '2.0' + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${EchoEventHandlerHttpApiFunction.Arn}/invocations MyHttpApiLambdaFunction: Type: AWS::Serverless::Function diff --git a/tests/unit/local/apigw/test_local_apigw_service.py b/tests/unit/local/apigw/test_local_apigw_service.py index 5597d6d17a..b66b3855df 100644 --- a/tests/unit/local/apigw/test_local_apigw_service.py +++ b/tests/unit/local/apigw/test_local_apigw_service.py @@ -22,7 +22,12 @@ class TestApiGatewayService(TestCase): def setUp(self): self.function_name = Mock() - self.api_gateway_route = Route(methods=["GET"], function_name=self.function_name, path="/") + self.api_gateway_route = Route( + methods=["GET"], + function_name=self.function_name, + path="/", + operation_name="getRestApi", + ) self.http_gateway_route = Route( methods=["GET"], function_name=self.function_name, path="/", event_type=Route.HTTP ) @@ -77,6 +82,7 @@ def test_api_request_must_invoke_lambda(self, request_mock): self.api_service.service_response = make_response_mock self.api_service._get_current_route = MagicMock() + self.api_service._get_current_route.return_value = self.api_gateway_route self.api_service._get_current_route.methods = [] self.api_service._get_current_route.return_value.payload_format_version = "2.0" self.api_service._construct_v_1_0_event = Mock() @@ -95,7 +101,7 @@ def test_api_request_must_invoke_lambda(self, request_mock): self.assertEqual(result, make_response_mock) self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr) - self.api_service._construct_v_1_0_event.assert_called_with(ANY, ANY, ANY, ANY, ANY, ANY) + self.api_service._construct_v_1_0_event.assert_called_with(ANY, ANY, ANY, ANY, ANY, "getRestApi") @patch.object(LocalApigwService, "get_request_methods_endpoints") def test_http_request_must_invoke_lambda(self, request_mock): @@ -151,7 +157,7 @@ def test_http_v1_payload_request_must_invoke_lambda(self, request_mock): self.assertEqual(result, make_response_mock) self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr) - self.http_service._construct_v_1_0_event.assert_called_with(ANY, ANY, ANY, ANY, ANY, "getV1") + self.http_service._construct_v_1_0_event.assert_called_with(ANY, ANY, ANY, ANY, ANY, None) @patch.object(LocalApigwService, "get_request_methods_endpoints") def test_http_v2_payload_request_must_invoke_lambda(self, request_mock):