Support XML responses in api gateway templating
diff --git a/localstack/services/apigateway/templates.py b/localstack/services/apigateway/templates.py
index fb86cd641818b..9dc1422b9afdd 100644
--- a/localstack/services/apigateway/templates.py
+++ b/localstack/services/apigateway/templates.py
@@ -6,8 +6,10 @@
 from typing import Any, Dict, Union
 from urllib.parse import quote_plus, unquote_plus
 
+import xmltodict
+
 from localstack import config
-from localstack.constants import APPLICATION_JSON
+from localstack.constants import APPLICATION_JSON, APPLICATION_XML
 from localstack.services.apigateway.context import ApiInvocationContext
 from localstack.services.apigateway.helpers import select_integration_response
 from localstack.utils.aws.templating import VelocityUtil, VtlTemplate
@@ -320,18 +322,25 @@ def render(self, api_context: ApiInvocationContext, **kwargs) -> Union[bytes, st
         response.status_code = int(selected_integration_response.get("statusCode", 200))
         response_templates = selected_integration_response.get("responseTemplates", {})
 
-        # we only support JSON templates for now - if there is no template we return
-        # the response as is
-        # TODO - support other content types, besides application/json (based on `Accept` request header)
+        # we only support JSON and XML templates for now - if there is no template we return the response as is
+        # If the content type is not supported we always use application/json as default value
+        # TODO - support other content types, besides application/json and application/xml
         # see https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html#selecting-mapping-templates
-        template = response_templates.get(APPLICATION_JSON, {})
-        if not template:
+        accept = api_context.headers.get("accept", APPLICATION_JSON)
+        supported_types = [APPLICATION_JSON, APPLICATION_XML]
+        media_type = accept if accept in supported_types else APPLICATION_JSON
+        if not (template := response_templates.get(media_type, {})):
             return response._content
 
         # we render the template with the context data and the response content
         variables = self.build_variables_mapping(api_context)
         # update the response body
-        response._content = self._render_as_json(template, variables)
+        response._content = self._render_as_text(template, variables)
+        if media_type == APPLICATION_JSON:
+            self._validate_json(response.content)
+        elif media_type == APPLICATION_XML:
+            self._validate_xml(response.content)
+
         if response_overrides := variables.get("context", {}).get("responseOverride", {}):
             response.headers.update(response_overrides.get("header", {}).items())
             response.status_code = response_overrides.get("status", 200)
@@ -339,16 +348,34 @@ def render(self, api_context: ApiInvocationContext, **kwargs) -> Union[bytes, st
         LOG.debug("Endpoint response body after transformations:\n%s", response._content)
         return response._content
 
-    def _render_as_json(self, template: str, variables: dict[str, Any]) -> str:
+    def _render_as_text(self, template: str, variables: dict[str, Any]) -> str:
         """
-        Render the given Velocity template string + variables into a JSON string.
-        :raise JSONDecodeError: if template result is not valid JSON
-        :return: the template rendering result as a valid JSON string
+        Render the given Velocity template string + variables into a plain string.
+        :return: the template rendering result as a string
         """
         rendered_tpl = self.render_vtl(template, variables=variables)
-        rendered_value = rendered_tpl.strip()
+        return rendered_tpl.strip()
+
+    @staticmethod
+    def _validate_json(content: str):
+        """
+        Checks that the content received is a valid JSON.
+        :raise JSONDecodeError: if content is not valid JSON
+        """
+        try:
+            json.loads(content)
+        except Exception as e:
+            LOG.info("Unable to parse template result as JSON: %s - %s", e, content)
+            raise
+
+    @staticmethod
+    def _validate_xml(content: str):
+        """
+        Checks that the content received is a valid XML.
+        :raise xml.parsers.expat.ExpatError: if content is not valid XML
+        """
         try:
-            return json.dumps(json.loads(rendered_value))
+            xmltodict.parse(content)
         except Exception as e:
-            LOG.info("Unable to parse template result as JSON: %s - %s", e, rendered_value)
+            LOG.info("Unable to parse template result as XML: %s - %s", e, content)
             raise
diff --git a/tests/unit/test_apigateway.py b/tests/unit/test_apigateway.py
index 858a73eec652f..760594923f77b 100644
--- a/tests/unit/test_apigateway.py
+++ b/tests/unit/test_apigateway.py
@@ -1,15 +1,18 @@
 import json
 import unittest
+import xml
 from json import JSONDecodeError
 from typing import Any, Dict
 from unittest.mock import MagicMock, Mock
 
 import boto3
 import pytest
+import xmltodict
 
 from localstack.aws.api.apigateway import Model
 from localstack.constants import (
     APPLICATION_JSON,
+    APPLICATION_XML,
     AWS_REGION_US_EAST_1,
     DEFAULT_AWS_ACCOUNT_ID,
     TEST_AWS_REGION_NAME,
@@ -528,7 +531,7 @@ def test_apply_template_no_json_payload(self):
         self.assertEqual("[]", rendered_request)
 
 
-RESPONSE_TEMPLATE = """
+RESPONSE_TEMPLATE_JSON = """
 
 #set( $body = $input.json("$") )
 #define( $loop )
@@ -571,21 +574,82 @@ def test_apply_template_no_json_payload(self):
 }
 """
 
+RESPONSE_TEMPLATE_WRONG_JSON = """
+#set( $body = $input.json("$") )
+  {
+    "body": $body,
+    "method": $context.httpMethod,
+  }
+"""
+
+RESPONSE_TEMPLATE_XML = """
+
+#set( $body = $input.json("$") )
+#define( $loop )
+    #foreach($e in $map.keySet())
+       #set( $k = $e )
+       #set( $v = $map.get($k))
+       <$k>$v</$k>
+    #end
+#end
+  <root method="$context.httpMethod" principalId="$context.authorizer.principalId" requestPath="$context.resourcePath">
+    <body>$body</body>
+    <stage>$context.stage</stage>
+    <cognitoPoolClaims>
+       <sub>$context.authorizer.claims.sub</sub>
+    </cognitoPoolClaims>
+
+    #set( $map = $context.authorizer )
+    <enhancedAuthContext>$loop</enhancedAuthContext>
+
+    #set( $map = $input.params().header )
+    <headers>$loop</headers>
+
+    #set( $map = $input.params().querystring )
+    <query>$loop</query>
+
+    #set( $map = $input.params().path )
+    <path>$loop</path>
+
+    #set( $map = $context.identity )
+    <identity>$loop</identity>
+
+    #set( $map = $stageVariables )
+    <stageVariables>$loop</stageVariables>
+  </root>
+"""
+
+RESPONSE_TEMPLATE_WRONG_XML = """
+#set( $body = $input.json("$") )
+<root>
+  <body>$body</body>
+  <not-closed>$context.stage
+</root>
+"""
+
 
 class TestTemplates:
-    @pytest.mark.parametrize("template", [RequestTemplates(), ResponseTemplates()])
-    def test_render_custom_template(self, template):
+    @pytest.mark.parametrize(
+        "template,accept_content_type",
+        [
+            (RequestTemplates(), APPLICATION_JSON),
+            (ResponseTemplates(), APPLICATION_JSON),
+            (RequestTemplates(), "*/*"),
+            (ResponseTemplates(), "*/*"),
+        ],
+    )
+    def test_render_custom_template(self, template, accept_content_type):
         api_context = ApiInvocationContext(
             method="POST",
             path="/foo/bar?baz=test",
             data=b'{"spam": "eggs"}',
-            headers={"content-type": APPLICATION_JSON},
+            headers={"content-type": APPLICATION_JSON, "accept": accept_content_type},
             stage="local",
         )
         api_context.integration = {
-            "requestTemplates": {APPLICATION_JSON: RESPONSE_TEMPLATE},
+            "requestTemplates": {APPLICATION_JSON: RESPONSE_TEMPLATE_JSON},
             "integrationResponses": {
-                "200": {"responseTemplates": {APPLICATION_JSON: RESPONSE_TEMPLATE}}
+                "200": {"responseTemplates": {APPLICATION_JSON: RESPONSE_TEMPLATE_JSON}}
             },
         }
         api_context.resource_path = "/{proxy+}"
@@ -609,7 +673,10 @@ def test_render_custom_template(self, template):
         assert result_as_json.get("stage") == "local"
         assert result_as_json.get("enhancedAuthContext") == {"principalId": "12233"}
         assert result_as_json.get("identity") == {"accountId": "00000", "apiKey": "11111"}
-        assert result_as_json.get("headers") == {"content-type": APPLICATION_JSON}
+        assert result_as_json.get("headers") == {
+            "content-type": APPLICATION_JSON,
+            "accept": accept_content_type,
+        }
         assert result_as_json.get("query") == {"baz": "test"}
         assert result_as_json.get("path") == {"id": "bar"}
         assert result_as_json.get("stageVariables") == {
@@ -622,15 +689,105 @@ def test_render_valid_booleans_in_json(self):
 
         # assert that boolean results of _render_json_result(..) are JSON-parseable
         tstring = '{"mybool": $boolTrue}'
-        result = template._render_as_json(tstring, {"boolTrue": "true"})
+        result = template._render_as_text(tstring, {"boolTrue": "true"})
         assert json.loads(result) == {"mybool": True}
-        result = template._render_as_json(tstring, {"boolTrue": True})
+        result = template._render_as_text(tstring, {"boolTrue": True})
         assert json.loads(result) == {"mybool": True}
 
         # older versions of `airspeed` were rendering booleans as False/True, which is no longer valid now
         tstring = '{"mybool": False}'
         with pytest.raises(JSONDecodeError):
-            template._render_as_json(tstring, {})
+            result = template._render_as_text(tstring, {})
+            template._validate_json(result)
+
+    def test_error_when_render_invalid_json(self):
+        api_context = ApiInvocationContext(
+            method="POST",
+            path="/foo/bar?baz=test",
+            data=b"<root></root>",
+            headers={},
+        )
+        api_context.integration = {
+            "integrationResponses": {
+                "200": {"responseTemplates": {APPLICATION_JSON: RESPONSE_TEMPLATE_WRONG_JSON}}
+            },
+        }
+        api_context.response = requests_response({"spam": "eggs"})
+        api_context.context = {}
+        api_context.stage_variables = {}
+
+        template = ResponseTemplates()
+        with pytest.raises(JSONDecodeError):
+            template.render(api_context=api_context)
+
+    @pytest.mark.parametrize("template", [RequestTemplates(), ResponseTemplates()])
+    def test_render_custom_template_in_xml(self, template):
+        api_context = ApiInvocationContext(
+            method="POST",
+            path="/foo/bar?baz=test",
+            data=b'{"spam": "eggs"}',
+            headers={"content-type": APPLICATION_XML, "accept": APPLICATION_XML},
+            stage="local",
+        )
+        api_context.integration = {
+            "requestTemplates": {APPLICATION_XML: RESPONSE_TEMPLATE_XML},
+            "integrationResponses": {
+                "200": {"responseTemplates": {APPLICATION_XML: RESPONSE_TEMPLATE_XML}}
+            },
+        }
+        api_context.resource_path = "/{proxy+}"
+        api_context.path_params = {"id": "bar"}
+        api_context.response = requests_response({"spam": "eggs"})
+        api_context.context = {
+            "httpMethod": api_context.method,
+            "stage": api_context.stage,
+            "authorizer": {"principalId": "12233"},
+            "identity": {"accountId": "00000", "apiKey": "11111"},
+            "resourcePath": api_context.resource_path,
+        }
+        api_context.stage_variables = {"stageVariable1": "value1", "stageVariable2": "value2"}
+
+        rendered_request = template.render(api_context=api_context, template_key=APPLICATION_XML)
+        result_as_xml = xmltodict.parse(rendered_request).get("root", {})
+
+        assert result_as_xml.get("body") == '{"spam": "eggs"}'
+        assert result_as_xml.get("@method") == "POST"
+        assert result_as_xml.get("@principalId") == "12233"
+        assert result_as_xml.get("stage") == "local"
+        assert result_as_xml.get("enhancedAuthContext") == {"principalId": "12233"}
+        assert result_as_xml.get("identity") == {"accountId": "00000", "apiKey": "11111"}
+        assert result_as_xml.get("headers") == {
+            "content-type": APPLICATION_XML,
+            "accept": APPLICATION_XML,
+        }
+        assert result_as_xml.get("query") == {"baz": "test"}
+        assert result_as_xml.get("path") == {"id": "bar"}
+        assert result_as_xml.get("stageVariables") == {
+            "stageVariable1": "value1",
+            "stageVariable2": "value2",
+        }
+
+    def test_error_when_render_invalid_xml(self):
+        api_context = ApiInvocationContext(
+            method="POST",
+            path="/foo/bar?baz=test",
+            data=b"<root></root>",
+            headers={"content-type": APPLICATION_XML, "accept": APPLICATION_XML},
+            stage="local",
+        )
+        api_context.integration = {
+            "integrationResponses": {
+                "200": {"responseTemplates": {APPLICATION_XML: RESPONSE_TEMPLATE_WRONG_XML}}
+            },
+        }
+        api_context.resource_path = "/{proxy+}"
+        api_context.response = requests_response({"spam": "eggs"})
+        api_context.context = {}
+        api_context.stage_variables = {}
+
+        template = ResponseTemplates()
+        with pytest.raises(xml.parsers.expat.ExpatError):
+            template.render(api_context=api_context, template_key=APPLICATION_XML)
 
 
 def test_openapi_resolver_given_unresolvable_references():

## Motivation
https://github.com/localstack/localstack/issues/10380

## Changes
Read the Accept header of the HTTP request and use the corresponding template and validate the response (only XML support added)

<!-- The following sections are optional, but can be useful! 

## Testing
Define an API gateway with an XML template as response_template, and make an HTTP request with Accept: application/xml header. The response must be formatted as a valid XML instead of JSON.

## TODO

What's left to do:

- Add support to other media types
- **I can't label this PR as semver: patch**

