From f1c72814d8bd0069c3367d2e7676d200564a179f Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Thu, 29 Jun 2023 09:09:15 +0000 Subject: [PATCH] APIGatewayV2: Stages (#6456) --- IMPLEMENTATION_COVERAGE.md | 10 +- docs/docs/services/apigatewayv2.rst | 8 +- moto/apigatewayv2/exceptions.py | 10 ++ moto/apigatewayv2/models.py | 80 +++++++++++++++- moto/apigatewayv2/responses.py | 39 ++++++++ moto/apigatewayv2/urls.py | 2 + .../terraform-tests.success.txt | 3 + .../test_apigatewayv2_stages.py | 95 +++++++++++++++++++ 8 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 tests/test_apigatewayv2/test_apigatewayv2_stages.py diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 3a8ac6584..d2def447b 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -204,7 +204,7 @@ ## apigatewayv2
-69% implemented +75% implemented - [X] create_api - [X] create_api_mapping @@ -216,7 +216,7 @@ - [X] create_model - [X] create_route - [X] create_route_response -- [ ] create_stage +- [X] create_stage - [X] create_vpc_link - [ ] delete_access_log_settings - [X] delete_api @@ -232,7 +232,7 @@ - [X] delete_route_request_parameter - [X] delete_route_response - [ ] delete_route_settings -- [ ] delete_stage +- [X] delete_stage - [X] delete_vpc_link - [ ] export_api - [X] get_api @@ -256,8 +256,8 @@ - [X] get_route_response - [ ] get_route_responses - [X] get_routes -- [ ] get_stage -- [ ] get_stages +- [X] get_stage +- [X] get_stages - [X] get_tags - [X] get_vpc_link - [X] get_vpc_links diff --git a/docs/docs/services/apigatewayv2.rst b/docs/docs/services/apigatewayv2.rst index 452254ce5..7be34f0ff 100644 --- a/docs/docs/services/apigatewayv2.rst +++ b/docs/docs/services/apigatewayv2.rst @@ -46,7 +46,7 @@ apigatewayv2 The following parameters are not yet implemented: ResponseModels, ResponseParameters -- [ ] create_stage +- [X] create_stage - [X] create_vpc_link - [ ] delete_access_log_settings - [X] delete_api @@ -62,7 +62,7 @@ apigatewayv2 - [X] delete_route_request_parameter - [X] delete_route_response - [ ] delete_route_settings -- [ ] delete_stage +- [X] delete_stage - [X] delete_vpc_link - [ ] export_api - [X] get_api @@ -102,8 +102,8 @@ apigatewayv2 Pagination is not yet implemented -- [ ] get_stage -- [ ] get_stages +- [X] get_stage +- [X] get_stages - [X] get_tags - [X] get_vpc_link - [X] get_vpc_links diff --git a/moto/apigatewayv2/exceptions.py b/moto/apigatewayv2/exceptions.py index 1a63b65a9..44ef021d6 100644 --- a/moto/apigatewayv2/exceptions.py +++ b/moto/apigatewayv2/exceptions.py @@ -123,3 +123,13 @@ class ApiMappingNotFound(APIGatewayV2Error): "NotFoundException", "The api mapping resource specified in the request was not found.", ) + + +class StageNotFound(APIGatewayV2Error): + code = 404 + + def __init__(self) -> None: + super().__init__( + "NotFoundException", + "Invalid stage identifier specified", + ) diff --git a/moto/apigatewayv2/models.py b/moto/apigatewayv2/models.py index 7842e5a2b..5ec2b3992 100644 --- a/moto/apigatewayv2/models.py +++ b/moto/apigatewayv2/models.py @@ -22,9 +22,57 @@ from .exceptions import ( VpcLinkNotFound, DomainNameNotFound, DomainNameAlreadyExists, + StageNotFound, ) +class Stage(BaseModel): + def __init__(self, api: "Api", config: Dict[str, Any]): + self.config = config + self.name = config["stageName"] + if api.protocol_type == "HTTP": + self.default_route_settings = config.get( + "defaultRouteSettings", {"detailedMetricsEnabled": False} + ) + elif api.protocol_type == "WEBSOCKET": + self.default_route_settings = config.get( + "defaultRouteSettings", + { + "dataTraceEnabled": False, + "detailedMetricsEnabled": False, + "loggingLevel": "OFF", + }, + ) + self.access_log_settings = config.get("accessLogSettings") + self.auto_deploy = config.get("autoDeploy") + self.client_certificate_id = config.get("clientCertificateId") + self.description = config.get("description") + self.route_settings = config.get("routeSettings", {}) + self.stage_variables = config.get("stageVariables", {}) + self.tags = config.get("tags", {}) + self.created = self.updated = unix_time() + + def to_json(self) -> Dict[str, Any]: + dct = { + "stageName": self.name, + "defaultRouteSettings": self.default_route_settings, + "createdDate": self.created, + "lastUpdatedDate": self.updated, + "routeSettings": self.route_settings, + "stageVariables": self.stage_variables, + "tags": self.tags, + } + if self.access_log_settings: + dct["accessLogSettings"] = self.access_log_settings + if self.auto_deploy is not None: + dct["autoDeploy"] = self.auto_deploy + if self.client_certificate_id: + dct["clientCertificateId"] = self.client_certificate_id + if self.description: + dct["description"] = self.description + return dct + + class Authorizer(BaseModel): def __init__( self, @@ -541,6 +589,7 @@ class Api(BaseModel): self.integrations: Dict[str, Integration] = dict() self.models: Dict[str, Model] = dict() self.routes: Dict[str, Route] = dict() + self.stages: Dict[str, Stage] = dict() self.arn = f"arn:aws:apigateway:{region}::/apis/{self.api_id}" self.backend.tag_resource(self.arn, tags) @@ -550,6 +599,7 @@ class Api(BaseModel): self.integrations.clear() self.models.clear() self.routes.clear() + self.stages.clear() def delete_cors_configuration(self) -> None: self.cors_configuration = None @@ -962,6 +1012,19 @@ class Api(BaseModel): route = self.get_route(route_id) return route.get_route_response(route_response_id) + def create_stage(self, config: Dict[str, Any]) -> Stage: + stage = Stage(api=self, config=config) + self.stages[stage.name] = stage + return stage + + def get_stage(self, stage_name: str) -> Stage: + if stage_name not in self.stages: + raise StageNotFound + return self.stages[stage_name] + + def delete_stage(self, stage_name: str) -> None: + self.stages.pop(stage_name, None) + def to_json(self) -> Dict[str, Any]: return { "apiId": self.api_id, @@ -1599,7 +1662,6 @@ class ApiGatewayV2Backend(BaseBackend): mutual_tls_authentication: Dict[str, str], tags: Dict[str, str], ) -> DomainName: - if domain_name in self.domain_names.keys(): raise DomainNameAlreadyExists @@ -1701,5 +1763,21 @@ class ApiGatewayV2Backend(BaseBackend): del self.api_mappings[api_mapping_id] + def create_stage(self, api_id: str, config: Dict[str, Any]) -> Stage: + api = self.get_api(api_id) + return api.create_stage(config) + + def get_stage(self, api_id: str, stage_name: str) -> Stage: + api = self.get_api(api_id) + return api.get_stage(stage_name) + + def delete_stage(self, api_id: str, stage_name: str) -> None: + api = self.get_api(api_id) + api.delete_stage(stage_name) + + def get_stages(self, api_id: str) -> List[Stage]: + api = self.get_api(api_id) + return list(api.stages.values()) + apigatewayv2_backends = BackendDict(ApiGatewayV2Backend, "apigatewayv2") diff --git a/moto/apigatewayv2/responses.py b/moto/apigatewayv2/responses.py index 91ac11669..82f1c143c 100644 --- a/moto/apigatewayv2/responses.py +++ b/moto/apigatewayv2/responses.py @@ -212,6 +212,22 @@ class ApiGatewayV2Response(BaseResponse): if request.method == "DELETE": return self.delete_api_mapping() + def stages(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE: # type: ignore[return] + self.setup_class(request, full_url, headers) + + if self.method == "POST": + return self.create_stage() + if self.method == "GET": + return self.get_stages() + + def stage(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE: # type: ignore[return] + self.setup_class(request, full_url, headers) + + if self.method == "GET": + return self.get_stage() + if self.method == "DELETE": + return self.delete_stage() + def create_api(self) -> TYPE_RESPONSE: params = json.loads(self.body) @@ -854,3 +870,26 @@ class ApiGatewayV2Response(BaseResponse): domain_name=domain_name, ) return 204, {}, "" + + def create_stage(self) -> TYPE_RESPONSE: + api_id = self.path.split("/")[-2] + config = json.loads(self.body) + stage = self.apigatewayv2_backend.create_stage(api_id, config) + return 200, {}, json.dumps(stage.to_json()) + + def get_stage(self) -> TYPE_RESPONSE: + api_id = self.path.split("/")[-3] + stage_name = self.path.split("/")[-1] + stage = self.apigatewayv2_backend.get_stage(api_id, stage_name) + return 200, {}, json.dumps(stage.to_json()) + + def delete_stage(self) -> TYPE_RESPONSE: + api_id = self.path.split("/")[-3] + stage_name = self.path.split("/")[-1] + self.apigatewayv2_backend.delete_stage(api_id, stage_name) + return 200, {}, "{}" + + def get_stages(self) -> TYPE_RESPONSE: + api_id = self.path.split("/")[-2] + stages = self.apigatewayv2_backend.get_stages(api_id) + return 200, {}, json.dumps({"items": [st.to_json() for st in stages]}) diff --git a/moto/apigatewayv2/urls.py b/moto/apigatewayv2/urls.py index b737112e8..7826904a6 100644 --- a/moto/apigatewayv2/urls.py +++ b/moto/apigatewayv2/urls.py @@ -26,6 +26,8 @@ url_paths = { "{0}/v2/apis/(?P[^/]+)/routes/(?P[^/]+)/routeresponses$": response_v2.route_responses, "{0}/v2/apis/(?P[^/]+)/routes/(?P[^/]+)/routeresponses/(?P[^/]+)$": response_v2.route_response, "{0}/v2/apis/(?P[^/]+)/routes/(?P[^/]+)/requestparameters/(?P[^/]+)$": response_v2.route_request_parameter, + "{0}/v2/apis/(?P[^/]+)/stages$": response_v2.stages, + "{0}/v2/apis/(?P[^/]+)/stages/(?P[^/]+)$": response_v2.stage, "{0}/v2/tags/(?P[^/]+)$": response_v2.tags, "{0}/v2/tags/(?P[^/]+)/apis/(?P[^/]+)$": response_v2.tags, "{0}/v2/tags/(?P[^/]+)/vpclinks/(?P[^/]+)$": response_v2.tags, diff --git a/tests/terraformtests/terraform-tests.success.txt b/tests/terraformtests/terraform-tests.success.txt index bd41fd494..4a5430eb5 100644 --- a/tests/terraformtests/terraform-tests.success.txt +++ b/tests/terraformtests/terraform-tests.success.txt @@ -30,6 +30,9 @@ apigatewayv2: - TestAccAPIGatewayV2IntegrationResponse - TestAccAPIGatewayV2Model - TestAccAPIGatewayV2Route + - TestAccAPIGatewayV2Stage_basicHTTP + - TestAccAPIGatewayV2Stage_basicWebSocket + - TestAccAPIGatewayV2Stage_disappears - TestAccAPIGatewayV2VPCLink appconfig: - TestAccAppConfigConfigurationProfileDataSource_basic diff --git a/tests/test_apigatewayv2/test_apigatewayv2_stages.py b/tests/test_apigatewayv2/test_apigatewayv2_stages.py new file mode 100644 index 000000000..3dfe47332 --- /dev/null +++ b/tests/test_apigatewayv2/test_apigatewayv2_stages.py @@ -0,0 +1,95 @@ +import boto3 +import pytest +from botocore.exceptions import ClientError +from moto import mock_apigatewayv2 + + +@mock_apigatewayv2 +def test_create_stage__defaults(): + client = boto3.client("apigatewayv2", "us-east-2") + + api_id = client.create_api(Name="test-api", ProtocolType="HTTP")["ApiId"] + + stages = client.get_stages(ApiId=api_id) + assert stages["Items"] == [] + + stage = client.create_stage(ApiId=api_id, StageName="my_stage") + assert stage["StageName"] == "my_stage" + + resp = client.get_stage(ApiId=api_id, StageName="my_stage") + assert resp["StageName"] == "my_stage" + + stages = client.get_stages(ApiId=api_id) + assert len(stages["Items"]) == 1 + + assert stages["Items"][0]["StageName"] == "my_stage" + assert stages["Items"][0]["DefaultRouteSettings"] == { + "DetailedMetricsEnabled": False + } + assert stages["Items"][0]["CreatedDate"] + assert stages["Items"][0]["LastUpdatedDate"] + assert stages["Items"][0]["RouteSettings"] == {} + assert stages["Items"][0]["StageVariables"] == {} + assert stages["Items"][0]["Tags"] == {} + + client.delete_stage(ApiId=api_id, StageName="my_stage") + + with pytest.raises(ClientError) as exc: + client.get_stage(ApiId=api_id, StageName="my_stage") + err = exc.value.response["Error"] + assert err["Code"] == "NotFoundException" + assert err["Message"] == "Invalid stage identifier specified" + + +@mock_apigatewayv2 +def test_create_stage__defaults_for_websocket_api(): + client = boto3.client("apigatewayv2", "us-east-2") + + api_id = client.create_api(Name="test-api", ProtocolType="WEBSOCKET")["ApiId"] + + client.create_stage(ApiId=api_id, StageName="my_stage") + + stage = client.get_stage(ApiId=api_id, StageName="my_stage") + assert stage["StageName"] == "my_stage" + assert stage["DefaultRouteSettings"] == { + "DataTraceEnabled": False, + "DetailedMetricsEnabled": False, + "LoggingLevel": "OFF", + } + + +@mock_apigatewayv2 +def test_create_stage(): + client = boto3.client("apigatewayv2", "us-east-2") + + api_id = client.create_api(Name="test-api", ProtocolType="HTTP")["ApiId"] + + stage = client.create_stage( + ApiId=api_id, + StageName="my_stage", + AccessLogSettings={"Format": "JSON"}, + AutoDeploy=True, + ClientCertificateId="ccid", + DefaultRouteSettings={"LoggingLevel": "INFO", "ThrottlingBurstLimit": 9000}, + Description="my shiny stage", + RouteSettings={ + "route1": {"DataTraceEnabled": True}, + }, + StageVariables={"sv1": "val1"}, + Tags={"k1": "v1"}, + ) + + assert stage["StageName"] == "my_stage" + assert stage["AccessLogSettings"] == {"Format": "JSON"} + assert stage["AutoDeploy"] is True + assert stage["ClientCertificateId"] == "ccid" + assert stage["DefaultRouteSettings"] == { + "LoggingLevel": "INFO", + "ThrottlingBurstLimit": 9000, + } + assert stage["Description"] == "my shiny stage" + assert stage["CreatedDate"] + assert stage["LastUpdatedDate"] + assert stage["RouteSettings"] == {"route1": {"DataTraceEnabled": True}} + assert stage["StageVariables"] == {"sv1": "val1"} + assert stage["Tags"] == {"k1": "v1"}