8000 enable generic actions and enhance parity for DynamoDB API Gateway in… · localstack/localstack@e64f63c · GitHub
[go: up one dir, main page]

Skip to content

Commit e64f63c

Browse files
authored
enable generic actions and enhance parity for DynamoDB API Gateway integration (#7755)
1 parent 942bfed commit e64f63c

File tree

7 files changed

+412
-355
lines changed

7 files changed

+412
-355
lines changed

localstack/services/apigateway/integration.py

Lines changed: 221 additions & 121 deletions
Large diffs are not rendered by default.

localstack/services/apigateway/invocations.py

Lines changed: 17 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import json
22
import logging
3-
import re
4-
from typing import Any, Dict, Union
5-
from urllib.parse import urljoin
3+
from typing import Dict
64

7-
import requests
85
from jsonschema import ValidationError, validate
96
from requests.models import Response
107

11-
from localstack import config
12-
from localstack.aws.accounts import get_aws_account_id
138
from localstack.constants import APPLICATION_JSON
149
from localstack.services.apigateway import helpers
1510
from localstack.services.apigateway.context import ApiInvocationContext
@@ -21,33 +16,20 @@
2116
)
2217
from localstack.services.apigateway.integration import (
2318
DynamoDBIntegration,
19+
HTTPIntegration,
2420
KinesisIntegration,
2521
LambdaIntegration,
2622
LambdaProxyIntegration,
2723
MockIntegration,
28-
SnsIntegration,
24+
S3Integration,
25+
SNSIntegration,
26+
SQSIntegration,
2927
StepFunctionIntegration,
3028
)
31-
from localstack.services.apigateway.templates import (
32-
RequestTemplates,
33-
ResponseTemplates,
34-
VtlTemplate,
35-
)
36-
from localstack.utils import common
3729
from localstack.utils.aws import aws_stack
38-
from localstack.utils.aws.aws_responses import request_response_stream
39-
from localstack.utils.http import add_query_params_to_url
4030

4131
LOG = logging.getLogger(__name__)
4232

43-
# target ARN patterns
44-
TARGET_REGEX_PATH_S3_URI = (
45-
r"^arn:aws:apigateway:[a-zA-Z0-9\-]+:s3:path/(?P<bucket>[^/]+)/(?P<object>.+)$"
46-
)
47-
TARGET_REGEX_ACTION_S3_URI = r"^arn:aws:apigateway:[a-zA-Z0-9\-]+:s3:action/(?:GetObject&Bucket\=(?P<bucket>[^&]+)&Key\=(?P<object>.+))$"
48-
49-
# TODO: refactor / split up this file into suitable submodules
50-
5133

5234
class AuthorizationError(Exception):
5335
pass
@@ -183,30 +165,6 @@ def update_content_length(response: Response):
183165
response.headers["Content-Length"] = str(len(response.content))
184166

185167

186-
# TODO: remove once we migrate all usages to `apply_request_parameters` on BackendIntegration
187-
def apply_request_parameters(
188-
uri: str, integration: Dict[str, Any], path_params: Dict[str, str], query_params: Dict[str, str]
189-
):
190-
request_parameters = integration.get("requestParameters")
191-
uri = uri or integration.get("uri") or integration.get("integrationUri") or ""
192-
if request_parameters:
193-
for key in path_params:
194-
# check if path_params is present in the integration request parameters
195-
request_param_key = f"integration.request.path.{key}"
196-
request_param_value = f"method.request.path.{key}"
197-
if request_parameters.get(request_param_key) == request_param_value:
198-
uri = uri.replace(f"{{{key}}}", path_params[key])
199-
200-
if integration.get("type") != "HTTP_PROXY" and request_parameters:
201-
for key in query_params.copy():
202-
request_query_key = f"integration.request.querystring.{key}"
203-
request_param_val = f"method.request.querystring.{key}"
204-
if request_parameters.get(request_query_key, None) != request_param_val:
205-
query_params.pop(key)
206-
207-
return add_query_params_to_url(uri, query_params)
208-
209-
210168
def apply_response_parameters(invocation_context: ApiInvocationContext):
211169
response = invocation_context.response
212170
integration = invocation_context.integration
@@ -300,31 +258,27 @@ def invoke_rest_api_integration(invocation_context: ApiInvocationContext):
300258
return make_error_response(msg, 400)
301259

302260

303-
# TODO: refactor this to have a class per integration type to make it easy to
304-
# test the encapsulated logic
305-
306-
# this call is patched downstream for backend integrations that are only available
307-
# in the PRO version
261+
# This function is patched downstream for backend integrations that are only available
262+
# in Pro (potentially to be replaced with a runtime hook in the future).
308263
def invoke_rest_api_integration_backend(invocation_context: ApiInvocationContext):
309264
# define local aliases from invocation context
310265
invocation_path = invocation_context.path_with_query_string
311266
method = invocation_context.method
312267
headers = invocation_context.headers
313268
resource_path = invocation_context.resource_path
314269
integration = invocation_context.integration
315-
integration_response = integration.get("integrationResponses", {})
316-
response_templates = integration_response.get("200", {}).get("responseTemplates", {})
317270
# extract integration type and path parameters
318271
relative_path, query_string_params = extract_query_string_params(path=invocation_path)
319272
integration_type_orig = integration.get("type") or integration.get("integrationType") or ""
320273
integration_type = integration_type_orig.upper()
321274
uri = integration.get("uri") or integration.get("integrationUri") or ""
322275

323276
try:
324-
path_params = extract_path_params(path=relative_path, extracted_path=resource_path)
325-
invocation_context.path_params = path_params
277+
invocation_context.path_params = extract_path_params(
278+
path=relative_path, extracted_path=resource_path
279+
)
326280
except Exception:
327-
path_params = {}
281+
invocation_context.path_params = {}
328282

329283
if (uri.startswith("arn:aws:apigateway:") and ":lambda:path" in uri) or uri.startswith(
330284
"arn:aws:lambda"
@@ -334,10 +288,6 @@ def invoke_rest_api_integration_backend(invocation_context: ApiInvocationContext
334288
elif integration_type == "AWS":
335289
return LambdaIntegration().invoke(invocation_context)
336290

337-
raise Exception(
338-
f'Unsupported API Gateway integration type "{integration_type}", action "{uri}", method "{method}"'
339-
)
340-
341291
elif integration_type == "AWS":
342292
if "kinesis:action/" in uri:
343293
return KinesisIntegration().invoke(invocation_context)
@@ -348,109 +298,20 @@ def invoke_rest_api_integration_backend(invocation_context: ApiInvocationContext
348298
if ":dynamodb:action" in uri:
349299
return DynamoDBIntegration().invoke(invocation_context)
350300

351-
# https://docs.aws.amazon.com/apigateway/api-reference/resource/integration/
352-
if ("s3:path/" in uri or "s3:action/" in uri) and method == "GET":
353-
s3 = aws_stack.connect_to_service("s3")
354-
uri = apply_request_parameters(
355-
uri,
356-
integration=integration,
357-
path_params=path_params,
358-
query_params=query_string_params,
359-
)
360-
uri_match = re.match(TARGET_REGEX_PATH_S3_URI, uri) or re.match(
361-
TARGET_REGEX_ACTION_S3_URI, uri
362-
)
363-
if uri_match:
364-
bucket, object_key = uri_match.group("bucket", "object")
365-
LOG.debug("Getting request for bucket %s object %s", bucket, object_key)
366-
try:
367-
object = s3.get_object(Bucket=bucket, Key=object_key)
368-
except s3.exceptions.NoSuchKey:
369-
msg = "Object %s not found" % object_key
370-
LOG.debug(msg)
371-
return make_error_response(msg, 404)
372-
373-
headers = aws_stack.mock_aws_request_headers(service="s3")
374-
375-
if object.get("ContentType"):
376-
headers["Content-Type"] = object["ContentType"]
377-
378-
# stream used so large files do not fill memory
379-
response = request_response_stream(stream=object["Body"], headers=headers)
380-
return response
381-
else:
382-
msg = "Request URI does not match s3 specifications"
383-
LOG.warning(msg)
384-
return make_error_response(msg, 400)
301+
if "s3:path/" in uri or "s3:action/" in uri:
302+
return S3Integration().invoke(invocation_context)
385303

386304
if method == "POST" and ":sqs:path" in uri:
387-
template = integration["requestTemplates"].get(APPLICATION_JSON)
388-
account_id, queue = uri.split("/")[-2:]
389-
region_name = uri.split(":")[3]
390-
if "GetQueueUrl" in template or "CreateQueue" in template:
391-
request_templates = RequestTemplates()
392-
payload = request_templates.render(invocation_context)
393-
new_request = f"{payload}&QueueName={queue}"
394-
else:
395-
request_templates = RequestTemplates()
396-
payload = request_templates.render(invocation_context)
397-
queue_url = f"{config.get_edge_url()}/{account_id}/{queue}"
398-
new_request = f"{payload}&QueueUrl={queue_url}"
399-
headers = aws_stack.mock_aws_request_headers(service="sqs", region_name=region_name)
400-
401-
url = urljoin(config.service_url("sqs"), f"{get_aws_account_id()}/{queue}")
402-
result = common.make_http_request(url, method="POST", headers=headers, data=new_request)
403-
return result
305+
return SQSIntegration().invoke(invocation_context)
404306

405307
if method == "POST" and ":sns:path" in uri:
406-
invocation_context.context = helpers.get_event_request_context(invocation_context)
407-
invocation_context.stage_variables = helpers.get_stage_variables(invocation_context)
408-
409-
integration_response = SnsIntegration().invoke(invocation_context)
410-
return apply_request_response_templates(
411-
integration_response, response_templates, content_type=APPLICATION_JSON
412-
)
413-
414-
raise Exception(
415-
f'API Gateway action uri "{uri}", integration type {integration_type} not yet implemented'
416-
)
308+
return SNSIntegration().invoke(invocation_context)
417309

418310
elif integration_type in ["HTTP_PROXY", "HTTP"]:
419-
420-
if ":servicediscovery:" in uri:
421-
# check if this is a servicediscovery integration URI
422-
client = aws_stack.connect_to_service("servicediscovery")
423-
service_id = uri.split("/")[-1]
424-
instances = client.list_instances(ServiceId=service_id)["Instances"]
425-
instance = (instances or [None])[0]
426-
if instance and instance.get("Id"):
427-
uri = "http://%s/%s" % (instance["Id"], invocation_path.lstrip("/"))
428-
429-
# apply custom request template
430-
invocation_context.context = helpers.get_event_request_context(invocation_context)
431-
invocation_context.stage_variables = helpers.get_stage_variables(invocation_context)
432-
request_templates = RequestTemplates()
433-
payload = request_templates.render(invocation_context)
434-
435-
if isinstance(payload, dict):
436-
payload = json.dumps(payload)
437-
438-
uri = apply_request_parameters(
439-
uri,
440-
integration=integration,
441-
path_params=path_params,
442-
query_params=query_string_params,
443-
)
444-
result = requests.request(method=method, url=uri, data=payload, headers=headers)
445-
# apply custom response template
446-
invocation_context.response = result
447-
response_templates = ResponseTemplates()
448-
response_templates.render(invocation_context)
449-
return invocation_context.response
311+
return HTTPIntegration().invoke(invocation_context)
450312

451313
elif integration_type == "MOCK":
452-
mock_integration = MockIntegration()
453-
return mock_integration.invoke(invocation_context)
314+
return MockIntegration().invoke(invocation_context)
454315

455316
if method == "OPTIONS":
456317
# fall back to returning CORS headers if this is an OPTIONS request
@@ -459,26 +320,3 @@ def invoke_rest_api_integration_backend(invocation_context: ApiInvocationContext
459320
raise Exception(
460321
f'API Gateway integration type "{integration_type}", method "{method}", URI "{uri}" not yet implemented'
461322
)
462-
463-
464-
def apply_request_response_templates(
465-
data: Union[Response, bytes],
466-
templates: Dict[str, str],
467-
content_type: str = None,
468-
as_json: bool = False,
469-
):
470-
"""Apply the matching request/response template (if it exists) to the payload data and return the result"""
471-
472-
content_type = content_type or APPLICATION_JSON
473-
is_response = isinstance(data, Response)
474-
templates = templates or {}
475-
template = templates.get(content_type)
476-
if not template:
477-
return data
478-
content = (data.content if is_response else data) or ""
479-
result = VtlTemplate().render_vtl(template, content, as_json=as_json)
480-
if is_response:
481-
data._content = result
482-
update_content_length(data)
483-
return data
484-
return result

localstack/utils/collections.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,8 @@ def _remove(o, **kwargs):
297297
return o
298298

299299
return recurse_object(obj, _remove)
300-
attributes = attributes if is_list_or_tuple(attributes) else [attributes]
300+
301+
attributes = ensure_list(attributes)
301302
for attr in attributes:
302303
obj.pop(attr, None)
303304
return obj

0 commit comments

Comments
 (0)
0