Repository: getsentry/responses

Issue:
Cannot record header to file
### Describe the bug

When recording a request that contains a location header, that header is not there anymore, when replaying that request.

### Additional context

It seems the code in [_add_from_file()](https://github.com/getsentry/responses/blob/83f2e287768de4bdd37ec1c144f56aafe38e29de/responses/__init__.py#L850) doesn't seem to restore headers, while the code in [_dump()](https://github.com/getsentry/responses/blob/83f2e287768de4bdd37ec1c144f56aafe38e29de/responses/_recorder.py#L50) does seem to save them.

### Version of `responses`

0.25.0

### Steps to Reproduce

```python
>>> import responses
>>> responses.post(url="fnord", status=201, headers=dict(Location='fnord'))
<Response(url='fnord' status=201 content_type='text/plain' headers='{"Location": "fnord"}')>
>>> responses.registered()
[<Response(url='fnord' status=201 content_type='text/plain' headers='{"Location": "fnord"}')>, <Response(url='fnord' status=201 content_type='text/plain' headers='{"Location": "fnord"}')>]
>>> import responses._recorder
>>> import yaml
>>> responses._recorder._dump(responses.registered(), open('/dev/stdout', 'w'), yaml.dump)
responses:
- response:
    auto_calculate_content_length: false
    body: ''
    content_type: text/plain
    headers:
      Location: fnord
    method: POST
    status: 201
    url: fnord
>>> responses._recorder._dump(responses.registered(), open('/tmp/dump', 'w'), yaml.dump)
>>> responses._add_from_file(file_path="/tmp/dump")
>>> responses.registered()
[<Response(url='fnord' status=201 content_type='text/plain' headers='{"Location": "fnord"}')>, <Response(url='fnord' status=201 content_type='text/plain' headers='null')>]
```

### Expected Result

Interestingly this does save the location headers, while they are not saved in my application where I discovered this. Still when loading the dump, the headers are ignored.

I'd expect the location header to survive the save / restore.

### Actual Result

Location header gets stripped by the replay from file.

Discussion:

Patch:
diff --git a/CHANGES b/CHANGES
index 7c49851..0679c1c 100644
--- a/CHANGES
+++ b/CHANGES
@@ -2,6 +2,7 @@
 ------
 
 * Mulligan on 0.25.1 to run release pipeline correctly.
+* Added `matchers.body_matcher` for matching string request bodies. See #717
 
 0.25.1
 ------
diff --git a/responses/__init__.py b/responses/__init__.py
index 39db1e7..097e61e 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -814,7 +814,11 @@ class RequestsMock:
 
         if adding_headers is not None:
             kwargs.setdefault("headers", adding_headers)
-        if "content_type" in kwargs and "headers" in kwargs:
+        if (
+            "content_type" in kwargs
+            and "headers" in kwargs
+            and kwargs["headers"] is not None
+        ):
             header_keys = [header.lower() for header in kwargs["headers"]]
             if "content-type" in header_keys:
                 raise RuntimeError(
@@ -852,6 +856,7 @@ class RequestsMock:
                 url=rsp["url"],
                 body=rsp["body"],
                 status=rsp["status"],
+                headers=rsp["headers"] if "headers" in rsp else None,
                 content_type=rsp["content_type"],
                 auto_calculate_content_length=rsp["auto_calculate_content_length"],
             )
@@ -1106,7 +1111,7 @@ class RequestsMock:
             response = self._real_send(adapter, request, **kwargs)  # type: ignore
         else:
             try:
-                response = adapter.build_response(  # type: ignore[no-untyped-call]
+                response = adapter.build_response(  # type: ignore[assignment]
                     request, match.get_response(request)
                 )
             except BaseException as response:
diff --git a/responses/_recorder.py b/responses/_recorder.py
index a533e84..64533e2 100644
--- a/responses/_recorder.py
+++ b/responses/_recorder.py
@@ -36,6 +36,29 @@ def _remove_nones(d: "Any") -> "Any":
     return d
 
 
+def _remove_default_headers(data: "Any") -> "Any":
+    """
+    It would be too verbose to store these headers in the file generated by the
+    record functionality.
+    """
+    if isinstance(data, dict):
+        keys_to_remove = [
+            "Content-Length",
+            "Content-Type",
+            "Date",
+            "Server",
+            "Connection",
+            "Content-Encoding",
+        ]
+        for i, response in enumerate(data["responses"]):
+            for key in keys_to_remove:
+                if key in response["response"]["headers"]:
+                    del data["responses"][i]["response"]["headers"][key]
+            if not response["response"]["headers"]:
+                del data["responses"][i]["response"]["headers"]
+    return data
+
+
 def _dump(
     registered: "List[BaseResponse]",
     destination: "Union[BinaryIO, TextIOWrapper]",
@@ -63,7 +86,8 @@ def _dump(
                 "Cannot dump response object."
                 "Probably you use custom Response object that is missing required attributes"
             ) from exc
-    dumper(_remove_nones(data), destination)
+
+    dumper(_remove_default_headers(_remove_nones(data)), destination)
 
 
 class Recorder(RequestsMock):
@@ -116,11 +140,15 @@ class Recorder(RequestsMock):
         request.params = self._parse_request_params(request.path_url)  # type: ignore[attr-defined]
         request.req_kwargs = kwargs  # type: ignore[attr-defined]
         requests_response = _real_send(adapter, request, **kwargs)
+        headers_values = {
+            key: value for key, value in requests_response.headers.items()
+        }
         responses_response = Response(
             method=str(request.method),
             url=str(requests_response.request.url),
             status=requests_response.status_code,
             body=requests_response.text,
+            headers=headers_values,
         )
         self._registry.add(responses_response)
         return requests_response
diff --git a/responses/matchers.py b/responses/matchers.py
index c9f7a63..40f3a83 100644
--- a/responses/matchers.py
+++ b/responses/matchers.py
@@ -31,6 +31,21 @@ def _filter_dict_recursively(
     return filtered_dict
 
 
+def body_matcher(params: str, *, allow_blank: bool = False) -> Callable[..., Any]:
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
+        reason = ""
+        if isinstance(request.body, bytes):
+            request_body = request.body.decode("utf-8")
+        else:
+            request_body = str(request.body)
+        valid = True if request_body == params else False
+        if not valid:
+            reason = f"request.body doesn't match {params} doesn't match {request_body}"
+        return valid, reason
+
+    return match
+
+
 def urlencoded_params_matcher(
     params: Optional[Mapping[str, str]], *, allow_blank: bool = False
 ) -> Callable[..., Any]:
diff --git a/setup.py b/setup.py
index 81e3af9..7b677e7 100644
--- a/setup.py
+++ b/setup.py
@@ -61,7 +61,7 @@ class PyTest(TestCommand):
 
 setup(
     name="responses",
-    version="0.25.0",
+    version="0.25.2",
     author="David Cramer",
     description="A utility library for mocking out the `requests` Python library.",
     url="https://github.com/getsentry/responses",


Test patch:
diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py
index 67d8e83..2775fff 100644
--- a/responses/tests/test_matchers.py
+++ b/responses/tests/test_matchers.py
@@ -14,6 +14,40 @@ from responses.tests.test_responses import assert_reset
 from responses.tests.test_responses import assert_response
 
 
+def test_body_match_get():
+    @responses.activate
+    def run():
+        url = "http://example.com"
+        responses.add(
+            responses.GET,
+            url,
+            body=b"test",
+            match=[matchers.body_matcher("123456")],
+        )
+        resp = requests.get("http://example.com", data="123456")
+        assert_response(resp, "test")
+
+    run()
+    assert_reset()
+
+
+def test_body_match_post():
+    @responses.activate
+    def run():
+        url = "http://example.com"
+        responses.add(
+            responses.POST,
+            url,
+            body=b"test",
+            match=[matchers.body_matcher("123456")],
+        )
+        resp = requests.post("http://example.com", data="123456")
+        assert_response(resp, "test")
+
+    run()
+    assert_reset()
+
+
 def test_query_string_matcher():
     @responses.activate
     def run():
diff --git a/responses/tests/test_recorder.py b/responses/tests/test_recorder.py
index b195088..9adbca7 100644
--- a/responses/tests/test_recorder.py
+++ b/responses/tests/test_recorder.py
@@ -23,6 +23,7 @@ def get_data(host, port):
                 "response": {
                     "method": "GET",
                     "url": f"http://{host}:{port}/404",
+                    "headers": {"x": "foo"},
                     "body": "404 Not Found",
                     "status": 404,
                     "content_type": "text/plain",
@@ -33,6 +34,7 @@ def get_data(host, port):
                 "response": {
                     "method": "GET",
                     "url": f"http://{host}:{port}/status/wrong",
+                    "headers": {"x": "foo"},
                     "body": "Invalid status code",
                     "status": 400,
                     "content_type": "text/plain",
@@ -43,6 +45,7 @@ def get_data(host, port):
                 "response": {
                     "method": "GET",
                     "url": f"http://{host}:{port}/500",
+                    "headers": {"x": "foo"},
                     "body": "500 Internal Server Error",
                     "status": 500,
                     "content_type": "text/plain",
@@ -89,7 +92,6 @@ class TestRecord:
 
         with open(self.out_file) as file:
             data = yaml.safe_load(file)
-
         assert data == get_data(httpserver.host, httpserver.port)
 
     def test_recorder_toml(self, httpserver):
@@ -122,16 +124,27 @@ class TestRecord:
 
     def prepare_server(self, httpserver):
         httpserver.expect_request("/500").respond_with_data(
-            "500 Internal Server Error", status=500, content_type="text/plain"
+            "500 Internal Server Error",
+            status=500,
+            content_type="text/plain",
+            headers={"x": "foo"},
         )
         httpserver.expect_request("/202").respond_with_data(
-            "OK", status=202, content_type="text/plain"
+            "OK",
+            status=202,
+            content_type="text/plain",
         )
         httpserver.expect_request("/404").respond_with_data(
-            "404 Not Found", status=404, content_type="text/plain"
+            "404 Not Found",
+            status=404,
+            content_type="text/plain",
+            headers={"x": "foo"},
         )
         httpserver.expect_request("/status/wrong").respond_with_data(
-            "Invalid status code", status=400, content_type="text/plain"
+            "Invalid status code",
+            status=400,
+            content_type="text/plain",
+            headers={"x": "foo"},
         )
         url500 = httpserver.url_for("/500")
         url202 = httpserver.url_for("/202")