Repository: getmoto/moto
Pull request #9040 (2025-07-03 20:56:02)

Core: add serializer for Query protocol requests that return JSON responses
For a number of Query protocol-based services (e.g. Cloud Formation, Redshift, SNS), the original `boto` library would add a `ContentType=JSON` parameter to the request, which would cause the AWS backend to return JSON instead of the default XML normally used by the protocol.  When `botocore`/`boto3` came on the scene, they dropped this optional parameter and only supported XML responses for these services.  There was a bit of a painful transition for `moto` during this time, as it had to support both `boto` and `boto3` (see #402, #444, https://github.com/getmoto/moto/commit/3cdb4afad0f6a42c790c39c56e2b6f0680d54ff7).

Direct support for the `boto` library was dropped from `moto` long ago, but the code supporting both JSON and XML responses for some service backends remains.  The `ContentType=JSON` parameter is a limited use-case that (as far as I can tell) was only ever used by the the original `boto` library. I don't believe any current SDKs make use of this parameter, but I have confirmed that it is still supported by the AWS backend.

Despite the limited usefulness, it just wasn't that much work to implement a `QueryJSONSerializer`, so this PR adds support for it to aid in migrating the aforementioned services to our new core serialization code.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/9040?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
Attention: Patch coverage is `96.55172%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 92.87%. Comparing base [(`82b4f99`)](https://app.codecov.io/gh/getmoto/moto/commit/82b4f99217640dde9af2ccf2d70d206e56d3cdb2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`095e0d9`)](https://app.codecov.io/gh/getmoto/moto/commit/095e0d902fb0e19ac740e98380e270dc3901b508?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

| [Files with missing lines](https://app.codecov.io/gh/getmoto/moto/pull/9040?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Patch % | Lines |
|---|---|---|
| [moto/core/serialize.py](https://app.codecov.io/gh/getmoto/moto/pull/9040?src=pr&el=tree&filepath=moto%2Fcore%2Fserialize.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9jb3JlL3NlcmlhbGl6ZS5weQ==) | 96.15% | [1 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/9040?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #9040   +/-   ##
=======================================
  Coverage   92.86%   92.87%           
=======================================
  Files        1288     1288           
  Lines      113394   113422   +28     
=======================================
+ Hits       105309   105336   +27     
- Misses       8085     8086    +1     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/9040/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/9040/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.63% <96.55%> (+0.01%)` | :arrow_up: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/9040/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `92.84% <96.55%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/9040?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/moto/core/responses.py b/moto/core/responses.py
index fc7298456..6ce63f29b 100644
--- a/moto/core/responses.py
+++ b/moto/core/responses.py
@@ -633,7 +633,9 @@ class BaseResponse(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
     def serialized(self, action_result: ActionResult) -> TYPE_RESPONSE:
         service_model = get_service_model(self.service_name)
         operation_model = service_model.operation_model(self._get_action())
-        serializer_cls = SERIALIZERS[service_model.protocol]
+        protocol = service_model.protocol
+        protocol += "-json" if protocol == "query" and self.request_json else ""
+        serializer_cls = SERIALIZERS[protocol]
         context = ActionContext(
             service_model, operation_model, serializer_cls, self.__class__
         )
diff --git a/moto/core/serialize.py b/moto/core/serialize.py
index 59c9cc3f4..5a5c58706 100644
--- a/moto/core/serialize.py
+++ b/moto/core/serialize.py
@@ -856,6 +856,59 @@ class QuerySerializer(BaseXMLSerializer):
         return resp
 
 
+class QueryJSONSerializer(QuerySerializer):
+    """Specialized case for query protocol requests that contain ContentType=JSON parameter."""
+
+    CONTENT_TYPE = "application/json"
+
+    def _serialized_error_to_response(
+        self,
+        resp: ResponseDict,
+        error: Exception,
+        shape: ErrorShape,
+        serialized_error: MutableMapping[str, Any],
+    ) -> ResponseDict:
+        error_wrapper = {
+            "Error": serialized_error,
+            "RequestId": self.context.request_id,
+        }
+        resp["body"] = self._serialize_body(error_wrapper)
+        status_code = shape.metadata.get("error", {}).get(
+            "httpStatusCode", self.DEFAULT_ERROR_RESPONSE_CODE
+        )
+        resp["status_code"] = status_code
+        resp["headers"]["Content-Type"] = self.CONTENT_TYPE
+        return resp
+
+    def _serialize_body(self, body: Mapping[str, Any]) -> str:
+        body_encoded = json.dumps(body, indent=4 if self.pretty_print else None)
+        return body_encoded
+
+    def _serialize_type_boolean(
+        self, serialized: Serialized, value: Any, shape: Shape, key: str
+    ) -> None:
+        # We're slightly more permissive here than we should be because the
+        # moto backends are not consistent in how they store boolean values.
+        # TODO: This should eventually be turned into a strict `is True` check.
+        boolean_value = True if value in [True, "True", "true"] else False
+        self._default_serialize(serialized, boolean_value, shape, key)
+
+    def _serialize_type_list(
+        self, serialized: Serialized, value: Any, shape: ListShape, key: str
+    ) -> None:
+        list_obj = []
+        serialized[key] = list_obj
+        for list_item in value:
+            wrapper = {}
+            assert isinstance(shape.member, Shape)  # mypy hint
+            item_key = shape.member.name
+            self._serialize(wrapper, list_item, shape.member, item_key)
+            if item_key in wrapper:
+                list_obj.append(wrapper[item_key])
+            else:
+                list_obj.append(list_item)
+
+
 class EC2Serializer(QuerySerializer):
     def _serialize_body(self, body: Mapping[str, Any]) -> str:
         body_serialized = xmltodict.unparse(
@@ -923,6 +976,7 @@ SERIALIZERS = {
     "ec2": EC2Serializer,
     "json": JSONSerializer,
     "query": QuerySerializer,
+    "query-json": QueryJSONSerializer,
     "rest-json": RestJSONSerializer,
     "rest-xml": RestXMLSerializer,
 }


Test patch:
diff --git a/tests/test_core/protocols/output/query-json.json b/tests/test_core/protocols/output/query-json.json
new file mode 100644
index 000000000..4987f5aca
--- /dev/null
+++ b/tests/test_core/protocols/output/query-json.json
@@ -0,0 +1,196 @@
+[
+  {
+    "description": "Scalar members",
+    "metadata": {
+      "protocol": "query"
+    },
+    "shapes": {
+      "OutputShape": {
+        "type": "structure",
+        "members": {
+          "Str": {
+            "shape": "StringType"
+          },
+          "Num": {
+            "shape": "IntegerType",
+            "locationName": "FooNum"
+          },
+          "FalseBool": {
+            "shape": "BooleanType"
+          },
+          "TrueBool": {
+            "shape": "BooleanType"
+          },
+          "Float": {
+            "shape": "FloatType"
+          },
+          "Double": {
+            "shape": "DoubleType"
+          },
+          "Long": {
+            "shape": "LongType"
+          },
+          "Char": {
+            "shape": "CharType"
+          },
+          "Timestamp": {
+            "shape": "TimestampType"
+          }
+        }
+      },
+      "StringType": {
+        "type": "string"
+      },
+      "IntegerType": {
+        "type": "integer"
+      },
+      "BooleanType": {
+        "type": "boolean"
+      },
+      "FloatType": {
+        "type": "float"
+      },
+      "DoubleType": {
+        "type": "double"
+      },
+      "LongType": {
+        "type": "long"
+      },
+      "CharType": {
+        "type": "character"
+      },
+      "TimestampType": {
+        "type": "timestamp"
+      }
+    },
+    "cases": [
+      {
+        "given": {
+          "output": {
+            "resultWrapper": "OperationNameResult",
+            "shape": "OutputShape"
+          },
+          "name": "OperationName"
+        },
+        "result": {
+          "Str": "myname",
+          "Num": 123,
+          "FalseBool": false,
+          "TrueBool": true,
+          "Float": 1.2,
+          "Double": 1.3,
+          "Long": 200,
+          "Char": "a",
+          "Timestamp": 1422172800
+        },
+        "response": {
+          "status_code": 200,
+          "headers": {},
+          "body": "{\"OperationNameResponse\": {\"OperationNameResult\": {\"Str\": \"myname\", \"FooNum\": 123, \"FalseBool\": false, \"TrueBool\": true, \"Float\": 1.2, \"Double\": 1.3, \"Long\": 200, \"Char\": \"a\", \"Timestamp\": \"2015-01-25T08:00:00Z\"}, \"ResponseMetadata\": {\"RequestId\": \"request-id\"}}}"
+        }
+      }
+    ]
+  },
+  {
+    "description": "Lists",
+    "metadata": {
+      "protocol": "query"
+    },
+    "shapes": {
+      "OutputShape": {
+        "type": "structure",
+        "members": {
+          "ListMember": {
+            "shape": "ListShape"
+          }
+        }
+      },
+      "ListShape": {
+        "type": "list",
+        "member": {
+          "shape": "StringType"
+        }
+      },
+      "StringType": {
+        "type": "string"
+      }
+    },
+    "cases": [
+      {
+        "given": {
+          "output": {
+            "resultWrapper": "OperationNameResult",
+            "shape": "OutputShape"
+          },
+          "name": "OperationName"
+        },
+        "result": {
+          "ListMember": [
+            "abc",
+            "123"
+          ]
+        },
+        "response": {
+          "status_code": 200,
+          "headers": {},
+          "body": "{\"OperationNameResponse\": {\"OperationNameResult\": {\"ListMember\": [\"abc\", \"123\"]}, \"ResponseMetadata\": {\"RequestId\": \"request-id\"}}}"
+        }
+      }
+    ]
+  },
+  {
+    "description": "Modeled exceptions",
+    "metadata": {
+      "protocol": "query"
+    },
+    "shapes": {
+      "ExceptionShape": {
+        "exception": true,
+        "type": "structure",
+        "members": {
+          "BodyMember": {
+            "shape": "StringType"
+          },
+          "Message": {
+            "shape": "StringType"
+          }
+        }
+      },
+      "OtherExceptionShape": {
+        "exception": true,
+        "type": "structure",
+        "members": {
+          "BodyMember": {
+            "shape": "StringType"
+          }
+        }
+      },
+      "StringType": {
+        "type": "string"
+      }
+    },
+    "cases": [
+      {
+        "given": {
+          "errors": [
+            {
+              "shape": "ExceptionShape"
+            }
+          ],
+          "name": "OperationName"
+        },
+        "error": {
+          "BodyMember": "mybody",
+          "Message": "mymessage"
+        },
+        "errorCode": "ExceptionShape",
+        "errorMessage": "mymessage",
+        "response": {
+          "status_code": 400,
+          "headers": {},
+          "body": "{\"Error\": {\"Type\": \"Sender\", \"Code\": \"ExceptionShape\", \"Message\": \"mymessage\", \"BodyMember\": \"mybody\"}, \"RequestId\": \"request-id\"}"
+        }
+      }
+    ]
+  }
+]
diff --git a/tests/test_core/test_protocols.py b/tests/test_core/test_protocols.py
index 936b80f1e..dc7c75238 100644
--- a/tests/test_core/test_protocols.py
+++ b/tests/test_core/test_protocols.py
@@ -75,6 +75,7 @@ def test_output_compliance(json_description: dict, case: dict, protocol):
         "ec2": "text/xml",
         "json": "application/x-amz-json-1.0",
         "query": "text/xml",
+        "query-json": "application/json",
         "rest-xml": "text/xml",
         "rest-json": "application/json",
     }