APIGatewayV2 mappings (#5711)

This commit is contained in:
Brendan Keane 2022-11-27 14:25:56 -08:00 committed by GitHub
parent 97b5e8b3ab
commit 22539585e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 614 additions and 18 deletions

View File

@ -175,13 +175,13 @@
## apigatewayv2
<details>
<summary>58% implemented</summary>
<summary>69% implemented</summary>
- [X] create_api
- [ ] create_api_mapping
- [X] create_api_mapping
- [X] create_authorizer
- [ ] create_deployment
- [ ] create_domain_name
- [X] create_domain_name
- [X] create_integration
- [X] create_integration_response
- [X] create_model
@ -191,11 +191,11 @@
- [X] create_vpc_link
- [ ] delete_access_log_settings
- [X] delete_api
- [ ] delete_api_mapping
- [X] delete_api_mapping
- [X] delete_authorizer
- [X] delete_cors_configuration
- [ ] delete_deployment
- [ ] delete_domain_name
- [X] delete_domain_name
- [X] delete_integration
- [X] delete_integration_response
- [X] delete_model
@ -207,15 +207,15 @@
- [X] delete_vpc_link
- [ ] export_api
- [X] get_api
- [ ] get_api_mapping
- [ ] get_api_mappings
- [X] get_api_mapping
- [X] get_api_mappings
- [X] get_apis
- [X] get_authorizer
- [ ] get_authorizers
- [ ] get_deployment
- [ ] get_deployments
- [ ] get_domain_name
- [ ] get_domain_names
- [X] get_domain_name
- [X] get_domain_names
- [X] get_integration
- [X] get_integration_response
- [X] get_integration_responses

View File

@ -33,10 +33,10 @@ apigatewayv2
CredentialsArn, RouteKey, Tags, Target
- [ ] create_api_mapping
- [X] create_api_mapping
- [X] create_authorizer
- [ ] create_deployment
- [ ] create_domain_name
- [X] create_domain_name
- [X] create_integration
- [X] create_integration_response
- [X] create_model
@ -50,11 +50,11 @@ apigatewayv2
- [X] create_vpc_link
- [ ] delete_access_log_settings
- [X] delete_api
- [ ] delete_api_mapping
- [X] delete_api_mapping
- [X] delete_authorizer
- [X] delete_cors_configuration
- [ ] delete_deployment
- [ ] delete_domain_name
- [X] delete_domain_name
- [X] delete_integration
- [X] delete_integration_response
- [X] delete_model
@ -66,8 +66,8 @@ apigatewayv2
- [X] delete_vpc_link
- [ ] export_api
- [X] get_api
- [ ] get_api_mapping
- [ ] get_api_mappings
- [X] get_api_mapping
- [X] get_api_mappings
- [X] get_apis
Pagination is not yet implemented
@ -77,8 +77,12 @@ apigatewayv2
- [ ] get_authorizers
- [ ] get_deployment
- [ ] get_deployments
- [ ] get_domain_name
- [ ] get_domain_names
- [X] get_domain_name
- [X] get_domain_names
Pagination is not yet implemented
- [X] get_integration
- [X] get_integration_response
- [X] get_integration_responses

View File

@ -93,3 +93,33 @@ class UnknownProtocol(APIGatewayV2Error):
"BadRequestException",
"Invalid protocol specified. Must be one of [HTTP, WEBSOCKET]",
)
class DomainNameNotFound(APIGatewayV2Error):
code = 404
def __init__(self) -> None:
super().__init__(
"NotFoundException",
"The domain name resource specified in the request was not found.",
)
class DomainNameAlreadyExists(APIGatewayV2Error):
code = 409
def __init__(self) -> None:
super().__init__(
"ConflictException",
"The domain name resource already exists.",
)
class ApiMappingNotFound(APIGatewayV2Error):
code = 404
def __init__(self) -> None:
super().__init__(
"NotFoundException",
"The api mapping resource specified in the request was not found.",
)

View File

@ -1,7 +1,8 @@
"""ApiGatewayV2Backend class with methods for supported APIs."""
import hashlib
import string
import yaml
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union
from moto.core import BaseBackend, BackendDict, BaseModel
from moto.core.utils import unix_time
@ -9,6 +10,7 @@ from moto.moto_api._internal import mock_random as random
from moto.utilities.tagging_service import TaggingService
from .exceptions import (
ApiMappingNotFound,
ApiNotFound,
AuthorizerNotFound,
BadRequestException,
@ -18,6 +20,8 @@ from .exceptions import (
IntegrationResponseNotFound,
RouteNotFound,
VpcLinkNotFound,
DomainNameNotFound,
DomainNameAlreadyExists,
)
@ -1011,6 +1015,55 @@ class VpcLink(BaseModel):
}
class DomainName(BaseModel):
def __init__(
self,
domain_name: str,
domain_name_configurations: List[Dict[str, str]],
mutual_tls_authentication: Dict[str, str],
tags: Dict[str, str],
):
self.api_mapping_selection_expression = "$request.basepath"
self.domain_name = domain_name
self.domain_name_configurations = domain_name_configurations
self.mutual_tls_authentication = mutual_tls_authentication
self.tags = tags
def to_json(self) -> Dict[str, Any]:
return {
"apiMappingSelectionExpression": self.api_mapping_selection_expression,
"domainName": self.domain_name,
"domainNameConfigurations": self.domain_name_configurations,
"mutualTlsAuthentication": self.mutual_tls_authentication,
"tags": self.tags,
}
class ApiMapping(BaseModel):
def __init__(
self,
api_id: str,
api_mapping_key: str,
api_mapping_id: str,
domain_name: str,
stage: str,
) -> None:
self.api_id = api_id
self.api_mapping_key = api_mapping_key
self.api_mapping_id = api_mapping_id
self.domain_name = domain_name
self.stage = stage
def to_json(self) -> Dict[str, Any]:
return {
"apiId": self.api_id,
"apiMappingId": self.api_mapping_id,
"apiMappingKey": self.api_mapping_key,
"domainName": self.domain_name,
"stage": self.stage,
}
class ApiGatewayV2Backend(BaseBackend):
"""Implementation of ApiGatewayV2 APIs."""
@ -1018,6 +1071,8 @@ class ApiGatewayV2Backend(BaseBackend):
super().__init__(region_name, account_id)
self.apis: Dict[str, Api] = dict()
self.vpc_links: Dict[str, VpcLink] = dict()
self.domain_names: Dict[str, DomainName] = dict()
self.api_mappings: Dict[str, ApiMapping] = dict()
self.tagger = TaggingService()
def create_api(
@ -1537,5 +1592,114 @@ class ApiGatewayV2Backend(BaseBackend):
vpc_link.update(name)
return vpc_link
def create_domain_name(
self,
domain_name: str,
domain_name_configurations: List[Dict[str, str]],
mutual_tls_authentication: Dict[str, str],
tags: Dict[str, str],
) -> DomainName:
if domain_name in self.domain_names.keys():
raise DomainNameAlreadyExists
domain = DomainName(
domain_name=domain_name,
domain_name_configurations=domain_name_configurations,
mutual_tls_authentication=mutual_tls_authentication,
tags=tags,
)
self.domain_names[domain.domain_name] = domain
return domain
def get_domain_name(self, domain_name: Union[str, None]) -> DomainName:
if domain_name is None or domain_name not in self.domain_names:
raise DomainNameNotFound
return self.domain_names[domain_name]
def get_domain_names(self) -> List[DomainName]:
"""
Pagination is not yet implemented
"""
return list(self.domain_names.values())
def delete_domain_name(self, domain_name: str) -> None:
if domain_name not in self.domain_names.keys():
raise DomainNameNotFound
for mapping_id, mapping in self.api_mappings.items():
if mapping.domain_name == domain_name:
del self.api_mappings[mapping_id]
del self.domain_names[domain_name]
def _generate_api_maping_id(
self, api_mapping_key: str, stage: str, domain_name: str
) -> str:
return str(
hashlib.sha256(
f"{stage} {domain_name}/{api_mapping_key}".encode("utf-8")
).hexdigest()
)[:5]
def create_api_mapping(
self, api_id: str, api_mapping_key: str, domain_name: str, stage: str
) -> ApiMapping:
if domain_name not in self.domain_names.keys():
raise DomainNameNotFound
if api_id not in self.apis.keys():
raise ApiNotFound("The resource specified in the request was not found.")
if api_mapping_key.startswith("/") or "//" in api_mapping_key:
raise BadRequestException(
"API mapping key should not start with a '/' or have consecutive '/'s."
)
if api_mapping_key.endswith("/"):
raise BadRequestException("API mapping key should not end with a '/'.")
api_mapping_id = self._generate_api_maping_id(
api_mapping_key=api_mapping_key, stage=stage, domain_name=domain_name
)
mapping = ApiMapping(
domain_name=domain_name,
api_id=api_id,
api_mapping_key=api_mapping_key,
api_mapping_id=api_mapping_id,
stage=stage,
)
self.api_mappings[api_mapping_id] = mapping
return mapping
def get_api_mapping(self, api_mapping_id: str, domain_name: str) -> ApiMapping:
if domain_name not in self.domain_names.keys():
raise DomainNameNotFound
if api_mapping_id not in self.api_mappings.keys():
raise ApiMappingNotFound
return self.api_mappings[api_mapping_id]
def get_api_mappings(self, domain_name: str) -> List[ApiMapping]:
domain_mappings = []
for mapping in self.api_mappings.values():
if mapping.domain_name == domain_name:
domain_mappings.append(mapping)
return domain_mappings
def delete_api_mapping(self, api_mapping_id: str, domain_name: str) -> None:
if api_mapping_id not in self.api_mappings.keys():
raise ApiMappingNotFound
if self.api_mappings[api_mapping_id].domain_name != domain_name:
raise BadRequestException(
f"given domain name {domain_name} does not match with mapping definition of mapping {api_mapping_id}"
)
del self.api_mappings[api_mapping_id]
apigatewayv2_backends = BackendDict(ApiGatewayV2Backend, "apigatewayv2")

View File

@ -180,6 +180,38 @@ class ApiGatewayV2Response(BaseResponse):
if request.method == "POST":
return self.create_vpc_link()
def domain_names(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE: # type: ignore[return]
self.setup_class(request, full_url, headers)
if request.method == "GET":
return self.get_domain_names()
if request.method == "POST":
return self.create_domain_name()
def domain_name(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE: # type: ignore[return]
self.setup_class(request, full_url, headers)
if request.method == "GET":
return self.get_domain_name()
if request.method == "DELETE":
return self.delete_domain_name()
def api_mappings(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE: # type: ignore[return]
self.setup_class(request, full_url, headers)
if request.method == "GET":
return self.get_api_mappings()
if request.method == "POST":
return self.create_api_mapping()
def api_mapping(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE: # type: ignore[return]
self.setup_class(request, full_url, headers)
if request.method == "GET":
return self.get_api_mapping()
if request.method == "DELETE":
return self.delete_api_mapping()
def create_api(self) -> TYPE_RESPONSE:
params = json.loads(self.body)
@ -751,3 +783,74 @@ class ApiGatewayV2Response(BaseResponse):
vpc_link = self.apigatewayv2_backend.update_vpc_link(vpc_link_id, name=name)
return 200, {}, json.dumps(vpc_link.to_json())
def create_domain_name(self) -> TYPE_RESPONSE:
params = json.loads(self.body)
domain_name = params.get("domainName")
domain_name_configurations = params.get("domainNameConfigurations", [{}])
mutual_tls_authentication = params.get("mutualTlsAuthentication", {})
tags = params.get("tags", {})
domain_name = self.apigatewayv2_backend.create_domain_name(
domain_name=domain_name,
domain_name_configurations=domain_name_configurations,
mutual_tls_authentication=mutual_tls_authentication,
tags=tags,
)
return 201, {}, json.dumps(domain_name.to_json())
def get_domain_name(self) -> TYPE_RESPONSE:
domain_name_param = self.path.split("/")[-1]
domain_name = self.apigatewayv2_backend.get_domain_name(
domain_name=domain_name_param
)
return 200, {}, json.dumps(domain_name.to_json())
def get_domain_names(self) -> TYPE_RESPONSE:
domain_names = self.apigatewayv2_backend.get_domain_names()
list_of_dict = [domain_name.to_json() for domain_name in domain_names]
return 200, {}, json.dumps(dict(items=list_of_dict))
def create_api_mapping(self) -> TYPE_RESPONSE:
domain_name = self.path.split("/")[-2]
params = json.loads(self.body)
api_id = params.get("apiId")
api_mapping_key = params.get("apiMappingKey", "")
stage = params.get("stage")
mapping = self.apigatewayv2_backend.create_api_mapping(
api_id=api_id,
api_mapping_key=api_mapping_key,
domain_name=domain_name,
stage=stage,
)
return 201, {}, json.dumps(mapping.to_json())
def get_api_mapping(self) -> TYPE_RESPONSE:
api_mapping_id = self.path.split("/")[-1]
domain_name = self.path.split("/")[-3]
mapping = self.apigatewayv2_backend.get_api_mapping(
api_mapping_id=api_mapping_id,
domain_name=domain_name,
)
return 200, {}, json.dumps(mapping.to_json())
def get_api_mappings(self) -> TYPE_RESPONSE:
domain_name = self.path.split("/")[-2]
mappings = self.apigatewayv2_backend.get_api_mappings(domain_name=domain_name)
list_of_dict = [mapping.to_json() for mapping in mappings]
return 200, {}, json.dumps(dict(items=list_of_dict))
def delete_domain_name(self) -> TYPE_RESPONSE:
domain_name = self.path.split("/")[-1]
self.apigatewayv2_backend.delete_domain_name(
domain_name=domain_name,
)
return 204, {}, ""
def delete_api_mapping(self) -> TYPE_RESPONSE:
api_mapping_id = self.path.split("/")[-1]
domain_name = self.path.split("/")[-3]
self.apigatewayv2_backend.delete_api_mapping(
api_mapping_id=api_mapping_id,
domain_name=domain_name,
)
return 204, {}, ""

View File

@ -31,4 +31,8 @@ url_paths = {
"{0}/v2/tags/(?P<resource_arn_pt1>[^/]+)/vpclinks/(?P<resource_arn_pt2>[^/]+)$": response_v2.tags,
"{0}/v2/vpclinks$": response_v2.vpc_links,
"{0}/v2/vpclinks/(?P<vpc_link_id>[^/]+)$": response_v2.vpc_link,
"{0}/v2/domainnames$": response_v2.domain_names,
"{0}/v2/domainnames/(?P<domain_name>[^/]+)$": response_v2.domain_name,
"{0}/v2/domainnames/(?P<domain_name>[^/]+)/apimappings$": response_v2.api_mappings,
"{0}/v2/domainnames/(?P<domain_name>[^/]+)/apimappings/(?P<api_mapping_id>[^/]+)$": response_v2.api_mapping,
}

View File

@ -0,0 +1,96 @@
import boto3
import sure # noqa # pylint: disable=unused-import
import pytest
import botocore.exceptions
from moto import mock_apigatewayv2
@mock_apigatewayv2
def test_create_domain_name():
client = boto3.client("apigatewayv2", region_name="us-east-1")
domain_name = "dev"
tags = {"tag": "it"}
expected_keys = [
"DomainName",
"ApiMappingSelectionExpression",
"DomainNameConfigurations",
"MutualTlsAuthentication",
"Tags",
]
post_resp = client.create_domain_name(DomainName=domain_name, Tags=tags)
get_resp = client.get_domain_name(DomainName=domain_name)
# check post response has all keys
for key in expected_keys:
post_resp.should.have.key(key)
# check get response has all keys
for key in expected_keys:
get_resp.should.have.key(key)
# check that values are equal for post and get of same resource
for key in expected_keys:
post_resp.get(key).should.equal(get_resp.get(key))
# ensure known values are set correct in post response
post_resp.get("DomainName").should.equal(domain_name)
post_resp.get("Tags").should.equal(tags)
@mock_apigatewayv2
def test_create_domain_name_already_exists():
client = boto3.client("apigatewayv2", region_name="us-east-1")
client.create_domain_name(DomainName="exists.io")
with pytest.raises(botocore.exceptions.ClientError) as exc:
client.create_domain_name(DomainName="exists.io")
err = exc.value.response["Error"]
err["Code"].should.equal("ConflictException")
err["Message"].should.equal("The domain name resource already exists.")
@mock_apigatewayv2
def test_get_domain_names():
client = boto3.client("apigatewayv2", region_name="us-east-1")
dev_domain = client.create_domain_name(DomainName="dev.service.io")
prod_domain = client.create_domain_name(DomainName="prod.service.io")
# sanity check responses
dev_domain.should.have.key("DomainName").equals("dev.service.io")
prod_domain.should.have.key("DomainName").equals("prod.service.io")
# make comparable
del dev_domain["ResponseMetadata"]
del prod_domain["ResponseMetadata"]
get_resp = client.get_domain_names()
get_resp.should.have.key("Items")
get_resp.get("Items").should.contain(dev_domain)
get_resp.get("Items").should.contain(prod_domain)
@mock_apigatewayv2
def test_delete_domain_name():
client = boto3.client("apigatewayv2", region_name="ap-southeast-1")
post_resp = client.create_domain_name(DomainName="dev.service.io")
client.delete_domain_name(DomainName="dev.service.io")
get_resp = client.get_domain_names()
del post_resp["ResponseMetadata"]
get_resp.should.have.key("Items")
get_resp.get("Items").should_not.contain(post_resp)
@mock_apigatewayv2
def test_delete_domain_name_dne():
client = boto3.client("apigatewayv2", region_name="ap-southeast-1")
with pytest.raises(botocore.exceptions.ClientError) as exc:
client.delete_domain_name(DomainName="dne.io")
err = exc.value.response["Error"]
err["Code"].should.equal("NotFoundException")
err["Message"].should.equal(
"The domain name resource specified in the request was not found."
)

View File

@ -0,0 +1,195 @@
import boto3
import sure # noqa # pylint: disable=unused-import
import pytest
import botocore.exceptions
from moto import mock_apigatewayv2
@mock_apigatewayv2
def test_create_api_mapping():
client = boto3.client("apigatewayv2", region_name="us-east-1")
api = client.create_api(Name="test-api", ProtocolType="HTTP")
dev_domain = client.create_domain_name(DomainName="dev.service.io")
expected_keys = ["ApiId", "ApiMappingId", "ApiMappingKey", "Stage"]
post_resp = client.create_api_mapping(
DomainName=dev_domain["DomainName"],
ApiMappingKey="v1/api",
Stage="$default",
ApiId=api["ApiId"],
)
get_resp = client.get_api_mapping(
DomainName="dev.service.io", ApiMappingId=post_resp["ApiMappingId"]
)
# check post response has all expected keys
for key in expected_keys:
post_resp.should.have.key(key)
# check get response has all expected keys
for key in expected_keys:
get_resp.should.have.key(key)
# check that values are equal for post and get of same resource
for key in expected_keys:
post_resp.get(key).should.equal(get_resp.get(key))
# ensure known values are set correct in post response
post_resp.get("ApiId").should_not.equal(None)
post_resp.get("ApiMappingId").should_not.equal(None)
post_resp.get("ApiMappingKey").should.equal("v1/api")
post_resp.get("Stage").should.equal("$default")
@mock_apigatewayv2
def test_create_api_mapping_missing_api():
client = boto3.client("apigatewayv2", region_name="us-east-1")
dev_domain = client.create_domain_name(DomainName="dev.service.io")
with pytest.raises(botocore.exceptions.ClientError) as exc:
client.create_api_mapping(
DomainName=dev_domain["DomainName"],
Stage="$default",
ApiId="123dne",
)
err = exc.value.response["Error"]
err["Code"].should.equal("NotFoundException")
err["Message"].should.equal(
"Invalid API identifier specified The resource specified in the request was not found."
)
@mock_apigatewayv2
def test_create_api_mapping_missing_domain():
client = boto3.client("apigatewayv2", region_name="us-east-1")
api = client.create_api(Name="test-api", ProtocolType="HTTP")
with pytest.raises(botocore.exceptions.ClientError) as exc:
client.create_api_mapping(
DomainName="domain.dne",
Stage="$default",
ApiId=api["ApiId"],
)
err = exc.value.response["Error"]
err["Code"].should.equal("NotFoundException")
err["Message"].should.equal(
"The domain name resource specified in the request was not found."
)
@mock_apigatewayv2
def test_create_api_mapping_bad_mapping_keys():
client = boto3.client("apigatewayv2", region_name="us-east-1")
api = client.create_api(Name="test-api", ProtocolType="HTTP")
dev_domain = client.create_domain_name(DomainName="dev.service.io")
bad_keys = {
"/api": "API mapping key should not start with a '/' or have consecutive '/'s.",
"v1//api": "API mapping key should not start with a '/' or have consecutive '/'s.",
"api/": "API mapping key should not end with a '/'.",
}
for bad_key, message in bad_keys.items():
with pytest.raises(botocore.exceptions.ClientError) as exc:
client.create_api_mapping(
DomainName=dev_domain["DomainName"],
ApiMappingKey=bad_key,
Stage="$default",
ApiId=api["ApiId"],
)
err = exc.value.response["Error"]
err["Code"].should.equal("BadRequestException")
err["Message"].should.equal(message)
@mock_apigatewayv2
def test_get_api_mappings():
client = boto3.client("apigatewayv2", region_name="eu-west-1")
api = client.create_api(Name="test-api", ProtocolType="HTTP")
included_domain = client.create_domain_name(DomainName="dev.service.io")
excluded_domain = client.create_domain_name(DomainName="hr.service.io")
v1_mapping = client.create_api_mapping(
DomainName=included_domain["DomainName"],
ApiMappingKey="v1/api",
Stage="$default",
ApiId=api["ApiId"],
)
v2_mapping = client.create_api_mapping(
DomainName=included_domain["DomainName"],
ApiMappingKey="v2/api",
Stage="$default",
ApiId=api["ApiId"],
)
hr_mapping = client.create_api_mapping(
DomainName=excluded_domain["DomainName"],
ApiMappingKey="hr/api",
Stage="$default",
ApiId=api["ApiId"],
)
# sanity check responses
v1_mapping.should.have.key("ApiMappingKey").equals("v1/api")
v2_mapping.should.have.key("ApiMappingKey").equals("v2/api")
hr_mapping.should.have.key("ApiMappingKey").equals("hr/api")
# make comparable
del v1_mapping["ResponseMetadata"]
del v2_mapping["ResponseMetadata"]
del hr_mapping["ResponseMetadata"]
get_resp = client.get_api_mappings(DomainName=included_domain["DomainName"])
get_resp.should.have.key("Items")
get_resp.get("Items").should.contain(v1_mapping)
get_resp.get("Items").should.contain(v2_mapping)
get_resp.get("Items").should_not.contain(hr_mapping)
@mock_apigatewayv2
def test_delete_api_mapping():
client = boto3.client("apigatewayv2", region_name="eu-west-1")
api = client.create_api(Name="test-api", ProtocolType="HTTP")
api_domain = client.create_domain_name(DomainName="dev.service.io")
v1_mapping = client.create_api_mapping(
DomainName=api_domain["DomainName"],
ApiMappingKey="v1/api",
Stage="$default",
ApiId=api["ApiId"],
)
del v1_mapping["ResponseMetadata"]
get_resp = client.get_api_mappings(DomainName=api_domain["DomainName"])
get_resp.should.have.key("Items")
get_resp.get("Items").should.contain(v1_mapping)
client.delete_api_mapping(
DomainName=api_domain["DomainName"], ApiMappingId=v1_mapping["ApiMappingId"]
)
get_resp = client.get_api_mappings(DomainName=api_domain["DomainName"])
get_resp.should.have.key("Items")
get_resp.get("Items").should_not.contain(v1_mapping)
@mock_apigatewayv2
def test_delete_api_mapping_dne():
client = boto3.client("apigatewayv2", region_name="eu-west-1")
client.create_api(Name="test-api", ProtocolType="HTTP")
api_domain = client.create_domain_name(DomainName="dev.service.io")
with pytest.raises(botocore.exceptions.ClientError) as exc:
client.delete_api_mapping(
DomainName=api_domain["DomainName"], ApiMappingId="123dne"
)
err = exc.value.response["Error"]
err["Code"].should.equal("NotFoundException")
err["Message"].should.equal(
"The api mapping resource specified in the request was not found."
)