Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -605,14 +605,23 @@ def update_integration_response(

# for path "/responseTemplates/application~1json"
if "/responseTemplates" in path:
integration_response.response_templates = (
integration_response.response_templates or {}
)
value = patch_operation.get("value")
if not isinstance(value, str):
raise BadRequestException(
f"Invalid patch value '{value}' specified for op '{op}'. Must be a string"
)
param = path.removeprefix("/responseTemplates/")
param = param.replace("~1", "/")
integration_response.response_templates.pop(param)
if op == "remove":
integration_response.response_templates.pop(param)
elif op == "add":
integration_response.response_templates[param] = value

elif "/contentHandling" in path and op == "replace":
integration_response.content_handling = patch_operation.get("value")

def update_resource(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
LOG = logging.getLogger(__name__)


# TODO: this will need to use ApiGatewayIntegration class, using Plugin for discoverability and a PluginManager,
# in order to automatically have access to defined Integrations that we can extend
class IntegrationHandler(RestApiGatewayHandler):
def __call__(
self,
Expand All @@ -24,7 +22,7 @@ def __call__(
integration = REST_API_INTEGRATIONS.get(integration_type)

if not integration:
# TODO: raise proper exception?
# this should not happen, as we validated the type in the provider
raise NotImplementedError(
f"This integration type is not yet supported: {integration_type}"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import base64
import logging
from http import HTTPMethod

from werkzeug.datastructures import Headers

from localstack.aws.api.apigateway import Integration, IntegrationType
from localstack.aws.api.apigateway import ContentHandlingStrategy, Integration, IntegrationType
from localstack.constants import APPLICATION_JSON
from localstack.http import Request, Response
from localstack.utils.collections import merge_recursive
Expand All @@ -13,7 +14,7 @@
from ..context import IntegrationRequest, InvocationRequest, RestApiInvocationContext
from ..gateway_response import InternalServerError, UnsupportedMediaTypeError
from ..header_utils import drop_headers, set_default_headers
from ..helpers import render_integration_uri
from ..helpers import mime_type_matches_binary_media_types, render_integration_uri
from ..parameters_mapping import ParametersMapper, RequestDataMapping
from ..template_mapping import (
ApiGatewayVtlTemplate,
Expand Down Expand Up @@ -116,8 +117,10 @@ def __call__(
integration=integration, request=context.invocation_request
)

converted_body = self.convert_body(context)

body, request_override = self.render_request_template_mapping(
context=context, template=request_template
context=context, body=converted_body, template=request_template
)
# mutate the ContextVariables with the requestOverride result, as we copy the context when rendering the
# template to avoid mutation on other fields
Expand Down Expand Up @@ -175,21 +178,26 @@ def get_integration_request_data(
def render_request_template_mapping(
self,
context: RestApiInvocationContext,
body: str | bytes,
template: str,
) -> tuple[bytes, ContextVarsRequestOverride]:
request: InvocationRequest = context.invocation_request
body = request["body"]

if not template:
return body, {}
return to_bytes(body), {}

try:
body_utf8 = to_str(body)
except UnicodeError:
raise InternalServerError("Internal server error")

body, request_override = self._vtl_template.render_request(
template=template,
variables=MappingTemplateVariables(
context=context.context_variables,
stageVariables=context.stage_variables or {},
input=MappingTemplateInput(
body=to_str(body),
body=body_utf8,
params=MappingTemplateParams(
path=request.get("path_parameters"),
querystring=request.get("query_string_parameters", {}),
Expand Down Expand Up @@ -235,6 +243,39 @@ def get_request_template(integration: Integration, request: InvocationRequest) -

return request_template

@staticmethod
def convert_body(context: RestApiInvocationContext) -> bytes | str:
"""
https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings.html
https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html
:param context:
:return: the body, either as is, or converted depending on the table in the second link
"""
request: InvocationRequest = context.invocation_request
body = request["body"]

is_binary_request = mime_type_matches_binary_media_types(
mime_type=request["headers"].get("Content-Type"),
binary_media_types=context.deployment.rest_api.rest_api.get("binaryMediaTypes", []),
)
content_handling = context.integration.get("contentHandling")
if is_binary_request:
if content_handling and content_handling == ContentHandlingStrategy.CONVERT_TO_TEXT:
body = base64.b64encode(body)
# if the content handling is not defined, or CONVERT_TO_BINARY, we do not touch the body and leave it as
# proper binary
else:
if not content_handling or content_handling == ContentHandlingStrategy.CONVERT_TO_TEXT:
body = body.decode(encoding="UTF-8", errors="replace")
else:
# it means we have CONVERT_TO_BINARY, so we need to try to decode the base64 string
try:
body = base64.b64decode(body)
except ValueError:
raise InternalServerError("Internal server error")

return body
Comment on lines +262 to +277
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice digging! There are a few hoops to jump through here as well 🚀


@staticmethod
def _merge_http_proxy_query_string(
query_string_parameters: dict[str, list[str]],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import base64
import json
import logging
import re

from werkzeug.datastructures import Headers

from localstack.aws.api.apigateway import Integration, IntegrationResponse, IntegrationType
from localstack.aws.api.apigateway import (
ContentHandlingStrategy,
Integration,
IntegrationResponse,
IntegrationType,
)
from localstack.constants import APPLICATION_JSON
from localstack.http import Response
from localstack.utils.strings import to_bytes, to_str
from localstack.utils.strings import to_bytes

from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain
from ..context import (
Expand All @@ -17,6 +23,7 @@
RestApiInvocationContext,
)
from ..gateway_response import ApiConfigurationError, InternalServerError
from ..helpers import mime_type_matches_binary_media_types
from ..parameters_mapping import ParametersMapper, ResponseDataMapping
from ..template_mapping import (
ApiGatewayVtlTemplate,
Expand Down Expand Up @@ -83,8 +90,15 @@ def __call__(
response_template = self.get_response_template(
integration_response=integration_response, request=context.invocation_request
)
# binary support
converted_body = self.convert_body(
context,
body=body,
content_handling=integration_response.get("contentHandling"),
)

body, response_override = self.render_response_template_mapping(
context=context, template=response_template, body=body
context=context, template=response_template, body=converted_body
)

# We basically need to remove all headers and replace them with the mapping, then
Expand Down Expand Up @@ -198,19 +212,71 @@ def get_response_template(
LOG.warning("No templates were matched, Using template: %s", template)
return template

@staticmethod
def convert_body(
context: RestApiInvocationContext,
body: bytes,
content_handling: ContentHandlingStrategy | None,
) -> bytes | str:
"""
https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings.html
https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html
:param context: RestApiInvocationContext
:param body: the endpoint response body
:param content_handling: the contentHandling of the IntegrationResponse
:return: the body, either as is, or converted depending on the table in the second link
"""

request: InvocationRequest = context.invocation_request
response: EndpointResponse = context.endpoint_response
binary_media_types = context.deployment.rest_api.rest_api.get("binaryMediaTypes", [])

is_binary_payload = mime_type_matches_binary_media_types(
mime_type=response["headers"].get("Content-Type"),
binary_media_types=binary_media_types,
)
is_binary_accept = mime_type_matches_binary_media_types(
mime_type=request["headers"].get("Accept"),
binary_media_types=binary_media_types,
)

if is_binary_payload:
if (
content_handling and content_handling == ContentHandlingStrategy.CONVERT_TO_TEXT
) or (not content_handling and not is_binary_accept):
body = base64.b64encode(body)
else:
# this means the Payload is of type `Text` in AWS terms for the table
if (
content_handling and content_handling == ContentHandlingStrategy.CONVERT_TO_TEXT
) or (not content_handling and not is_binary_accept):
body = body.decode(encoding="UTF-8", errors="replace")
else:
try:
body = base64.b64decode(body)
except ValueError:
raise InternalServerError("Internal server error")

return body

def render_response_template_mapping(
self, context: RestApiInvocationContext, template: str, body: bytes | str
) -> tuple[bytes, ContextVarsResponseOverride]:
if not template:
return body, ContextVarsResponseOverride(status=0, header={})
return to_bytes(body), ContextVarsResponseOverride(status=0, header={})

# if there are no template, we can pass binary data through
if not isinstance(body, str):
# TODO: check, this might be ApiConfigurationError
raise InternalServerError("Internal server error")

body, response_override = self._vtl_template.render_response(
template=template,
variables=MappingTemplateVariables(
context=context.context_variables,
stageVariables=context.stage_variables or {},
input=MappingTemplateInput(
body=to_str(body),
body=body,
params=MappingTemplateParams(
path=context.invocation_request.get("path_parameters"),
querystring=context.invocation_request.get("query_string_parameters", {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def match(self, context: RestApiInvocationContext) -> tuple[Resource, dict[str,

:param context:
:return: A tuple with the matched resource and the (already parsed) path params
:raises: TODO: Gateway exception in case the given request does not match any operation
:raises: MissingAuthTokenError, weird naming but that is the default NotFound for REST API
"""

request = context.request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,8 @@ def invoke(self, context: RestApiInvocationContext) -> EndpointResponse:
# TODO: maybe centralize this flag inside the context, when we are also using it for other integration types
# AWS_PROXY behaves a bit differently, but this could checked only once earlier
binary_response_accepted = mime_type_matches_binary_media_types(
context.invocation_request["headers"].get("Accept"),
context.deployment.rest_api.rest_api.get("binaryMediaTypes", []),
mime_type=context.invocation_request["headers"].get("Accept"),
binary_media_types=context.deployment.rest_api.rest_api.get("binaryMediaTypes", []),
)
body = self._parse_body(
body=lambda_response.get("body"),
Expand Down
Loading
Loading